Title: Transaction input encryption — simple validator-key scheme (Phase 1)
Background
From the design discussion: today, private transaction data (TransactionInputs) is visible in plaintext to both the RPC operator and the validators. The long-term design (Bobbin's 3-step sequencing) is:
- Simple scheme (this issue): a single shared submission (encryption) key across the validator set, signed-attested by each validator's own signing key, no TEE.
- Threshold encryption in
miden-crypto to separate submission keys from archival keys (separate issue).
- TEE integration, including a proper distributed setup protocol so no party outside the TEEs ever learns the shared key (separate issue).
This issue covers only step 1.
Scope
In scope:
- Each validator continues to manage its own individual signing keypair (as today — one signing key per validator, committed in the block header of blocks it signs).
- In addition, every validator in the set is configured with the same shared X25519 encryption keypair (the "submission key"). This is provisioned identically to each validator instance rather than derived independently.
- New
GetTransactionEncryptionKey endpoint, served by each RPC (forwarded to a validator), returning the shared public key attested by the corresponding validator's own signing key.
- Any validator in the set can decrypt
TransactionInputs on receipt, since they all hold the same shared secret.
Out of scope (future issues):
- The distributed setup protocol that lets multiple validators arrive at the same encryption key without any party (including the validators themselves) knowing it outside a TEE — that's step 3. For this phase, the shared secret is provisioned to each validator out-of-band by the operator.
- Threshold encryption / threshold key-derivation scheme (step 2).
- TEE-based validator execution (step 3).
- Client-side changes (periodic key fetch, encrypting
TransactionInputs before submission) — these belong in miden-client/miden-base, not this repo.
Design
Key management
Each validator keeps its existing per-validator ValidatorSigner (bin/validator/src/signers/mod.rs) unchanged. Alongside it, add an encryption-key counterpart built on miden-crypto's existing IES machinery (ies::{SealingKey, UnsealingKey}, ecdh::x25519, dsa::eddsa_25519_sha512::KeyExchangeKey). Unlike the signing key, this key's secret material must be identical across every validator in the set.
No KMS-backed key
Unlike the signing key (--key.kms-id, backed by AWS KMS Sign), the encryption key has no KMS equivalent: AWS KMS's DeriveSharedSecret (ECDH) only supports NIST P-256/P-384/P-521, and both curves miden-crypto's IES module actually offers — secp256k1 (K256XChaCha20Poly1305) and X25519 (X25519XChaCha20Poly1305/X25519AeadPoseidon2) — are excluded from KMS key agreement entirely (they're sign/verify-only key specs in KMS). Adding a NIST-curve IES scheme, plus a miden-crypto API that accepts an externally-computed shared secret (so a KMS-derived secret could feed the existing KDF/AEAD pipeline), would be required to change this (which will not be done). This is also a dead end strategically, not just technically: step 3 replaces this key with TEE-managed material derived via a distributed, attested setup protocol, a trust model KMS has no place in — so this key was never going to live in KMS long-term regardless of AWS's curve support.
Secrets Manager
The shared secret will be provisioned to every validator via a secrets manager (e.g. AWS Secrets Manager) rather than KMS: each validator fetches the same secret value at startup and constructs the local UnsealingKey/SealingKey from it directly. Config should mirror the signing key's CLI/env pattern in bin/validator/src/commands/mod.rs (--key.hex/MIDEN_VALIDATOR_KEY): add --encryption-key.hex/MIDEN_VALIDATOR_ENCRYPTION_KEY, sourced from Secrets Manager in production deployments. Actually protecting the key from the validator's own operator is out of scope for this phase — that's what step 3's TEE-based setup protocol is for.
Attestation
Each validator signs the shared encryption public-key bytes with its own individual ECDSA signing key (the same key already committed in the block header as validator_key for blocks it produces) — so the signature differs per validator even though the underlying encryption public key bytes are identical across the set. That is, for the same encryption_pubkey_bytes, validator A's response carries sign(encryption_pubkey_bytes, A's signing key) while validator B's carries sign(encryption_pubkey_bytes, B's signing key): same data, different (validator-specific) signature. "Attested" here means the signature is proof that a specific, chain-recognized validator identity is vouching for the key — it's what lets a client trust the key came from a legitimate validator rather than an impostor RPC operator. No block header / consensus format changes needed: the encryption key is delivered out-of-band, and the caller verifies the signature against whichever validator's signing key it already trusts from the chain.
Wire format for encrypted inputs
Currently ProvenTransaction.transaction_inputs (in proto/proto/types/transaction.proto) carries raw serialized TransactionInputs bytes, decoded directly in bin/validator/src/tx_validation/data_store.rs / submit_proven_transaction.rs. This needs to become a serialized SealedMessage (miden-crypto already provides Serializable/Deserializable for it) wrapping the encrypted TransactionInputs bytes. Since the decryption secret is shared, whichever validator in the set receives the submission can decrypt it — this is what makes the scheme tolerant of routing to any validator, not just the one whose signing key the client originally verified against.
Encryption at rest
Today, after validate_transaction executes the (decrypted) TransactionInputs, the validator persists the derived plaintext — account_delta, input_notes, output_notes — into the validated_transactions table (bin/validator/src/db/schema.rs, insert_transaction in bin/validator/src/db/mod.rs). Instead, the durable record should be the original ciphertext (SealedMessage) that arrived over the wire, not data derived from decrypting and executing it.
Implementation tasks
Proto (proto/proto/)
- Add
GetTransactionEncryptionKey rpc + request/response messages to internal/validator.proto (response: X25519 public key bytes + this validator's ECDSA signature over those bytes + scheme identifier).
- Add the same rpc (forwarding) to
rpc.proto.
Validator (bin/validator/)
signers/mod.rs: add an encryption-key counterpart to ValidatorSigner (e.g. ValidatorEncryptor wrapping UnsealingKey/SealingKey). Each validator process loads the same shared secret (sourced from Secrets Manager) via its own instance of this type.
commands/mod.rs / commands/start.rs: add CLI flags/env vars for the shared encryption key, threaded into ValidatorService::new. Document clearly (help text/docs) that this value must match across all validators in the set, unlike the per-validator signing key flags.
server/validator_service/mod.rs: hold the encryptor on ValidatorService; add a new get_transaction_encryption_key handler module returning the shared public key plus a signature from this validator's own signing key.
server/validator_service/submit_proven_transaction.rs (decode): unseal transaction_inputs via the encryptor before TransactionInputs::read_from_bytes. Works identically regardless of which validator instance receives the submission, since the secret is shared.
tx_validation/data_store.rs: no change expected — it already only consumes the decoded TransactionInputs.
db/schema.rs / db/models.rs / db/mod.rs (insert_transaction): change the validated_transactions table to persist the original ciphertext (SealedMessage bytes) received on submission instead of the derived plaintext account_delta/input_notes/output_notes.
RPC (crates/rpc/, crates/proto/)
crates/proto/src/clients/mod.rs: extend ValidatorClient with a method to call the validator's new endpoint (mirrors the existing submit_batch pattern used in crates/rpc/src/server/api/submit_proven_tx_batch.rs).
crates/rpc/src/server/api/: add get_transaction_encryption_key.rs implementing the RPC-side handler, forwarding to whichever validator the RPC is connected to and passing the response through unchanged (pure proxy, same shape as other forwarded calls).
Docs
- Update
docs/external/src/rpc/public-api.md (and docs/internal/src/rpc.md if it documents validator internals) with the new endpoint, including a note that the returned encryption key is shared across the validator set while the attesting signature is validator-specific.
Open questions to resolve during implementation
- Key rotation policy/cadence for the shared encryption key — since it's not chain-committed, how do we keep all validators in sync if it's rotated, and how does a client detect/handle a rotation mid-flight?
- Whether the derived plaintext fields currently stored in
validated_transactions (account_delta, input_notes, output_notes) are needed in any form going forward, or can be fully replaced by the stored ciphertext blob.
Acceptance criteria
- Every validator in the set exposes a working
GetTransactionEncryptionKey endpoint, each returning the same shared X25519 public key attested by its own signing key.
- RPC forwards the request to whichever validator it's connected to, unchanged.
- Any validator in the set can decrypt a
SealedMessage-wrapped TransactionInputs payload and validate the transaction as before, regardless of which validator originally issued the key to the client.
- Validated transactions are persisted with the original ciphertext (
SealedMessage bytes), not the derived plaintext account_delta/input_notes/output_notes.
- Existing signing/genesis flows are unaffected (no block header format changes).
Title: Transaction input encryption — simple validator-key scheme (Phase 1)
Background
From the design discussion: today, private transaction data (
TransactionInputs) is visible in plaintext to both the RPC operator and the validators. The long-term design (Bobbin's 3-step sequencing) is:miden-cryptoto separate submission keys from archival keys (separate issue).This issue covers only step 1.
Scope
In scope:
GetTransactionEncryptionKeyendpoint, served by each RPC (forwarded to a validator), returning the shared public key attested by the corresponding validator's own signing key.TransactionInputson receipt, since they all hold the same shared secret.Out of scope (future issues):
TransactionInputsbefore submission) — these belong inmiden-client/miden-base, not this repo.Design
Key management
Each validator keeps its existing per-validator
ValidatorSigner(bin/validator/src/signers/mod.rs) unchanged. Alongside it, add an encryption-key counterpart built on miden-crypto's existing IES machinery (ies::{SealingKey, UnsealingKey},ecdh::x25519,dsa::eddsa_25519_sha512::KeyExchangeKey). Unlike the signing key, this key's secret material must be identical across every validator in the set.No KMS-backed key
Unlike the signing key (
--key.kms-id, backed by AWS KMSSign), the encryption key has no KMS equivalent: AWS KMS'sDeriveSharedSecret(ECDH) only supports NIST P-256/P-384/P-521, and both curves miden-crypto's IES module actually offers — secp256k1 (K256XChaCha20Poly1305) and X25519 (X25519XChaCha20Poly1305/X25519AeadPoseidon2) — are excluded from KMS key agreement entirely (they're sign/verify-only key specs in KMS). Adding a NIST-curve IES scheme, plus a miden-crypto API that accepts an externally-computed shared secret (so a KMS-derived secret could feed the existing KDF/AEAD pipeline), would be required to change this (which will not be done). This is also a dead end strategically, not just technically: step 3 replaces this key with TEE-managed material derived via a distributed, attested setup protocol, a trust model KMS has no place in — so this key was never going to live in KMS long-term regardless of AWS's curve support.Secrets Manager
The shared secret will be provisioned to every validator via a secrets manager (e.g. AWS Secrets Manager) rather than KMS: each validator fetches the same secret value at startup and constructs the local
UnsealingKey/SealingKeyfrom it directly. Config should mirror the signing key's CLI/env pattern inbin/validator/src/commands/mod.rs(--key.hex/MIDEN_VALIDATOR_KEY): add--encryption-key.hex/MIDEN_VALIDATOR_ENCRYPTION_KEY, sourced from Secrets Manager in production deployments. Actually protecting the key from the validator's own operator is out of scope for this phase — that's what step 3's TEE-based setup protocol is for.Attestation
Each validator signs the shared encryption public-key bytes with its own individual ECDSA signing key (the same key already committed in the block header as
validator_keyfor blocks it produces) — so the signature differs per validator even though the underlying encryption public key bytes are identical across the set. That is, for the sameencryption_pubkey_bytes, validator A's response carriessign(encryption_pubkey_bytes, A's signing key)while validator B's carriessign(encryption_pubkey_bytes, B's signing key): same data, different (validator-specific) signature. "Attested" here means the signature is proof that a specific, chain-recognized validator identity is vouching for the key — it's what lets a client trust the key came from a legitimate validator rather than an impostor RPC operator. No block header / consensus format changes needed: the encryption key is delivered out-of-band, and the caller verifies the signature against whichever validator's signing key it already trusts from the chain.Wire format for encrypted inputs
Currently
ProvenTransaction.transaction_inputs(inproto/proto/types/transaction.proto) carries raw serializedTransactionInputsbytes, decoded directly inbin/validator/src/tx_validation/data_store.rs/submit_proven_transaction.rs. This needs to become a serializedSealedMessage(miden-crypto already providesSerializable/Deserializablefor it) wrapping the encryptedTransactionInputsbytes. Since the decryption secret is shared, whichever validator in the set receives the submission can decrypt it — this is what makes the scheme tolerant of routing to any validator, not just the one whose signing key the client originally verified against.Encryption at rest
Today, after
validate_transactionexecutes the (decrypted)TransactionInputs, the validator persists the derived plaintext —account_delta,input_notes,output_notes— into thevalidated_transactionstable (bin/validator/src/db/schema.rs,insert_transactioninbin/validator/src/db/mod.rs). Instead, the durable record should be the original ciphertext (SealedMessage) that arrived over the wire, not data derived from decrypting and executing it.Implementation tasks
Proto (
proto/proto/)GetTransactionEncryptionKeyrpc + request/response messages tointernal/validator.proto(response: X25519 public key bytes + this validator's ECDSA signature over those bytes + scheme identifier).rpc.proto.Validator (
bin/validator/)signers/mod.rs: add an encryption-key counterpart toValidatorSigner(e.g.ValidatorEncryptorwrappingUnsealingKey/SealingKey). Each validator process loads the same shared secret (sourced from Secrets Manager) via its own instance of this type.commands/mod.rs/commands/start.rs: add CLI flags/env vars for the shared encryption key, threaded intoValidatorService::new. Document clearly (help text/docs) that this value must match across all validators in the set, unlike the per-validator signing key flags.server/validator_service/mod.rs: hold the encryptor onValidatorService; add a newget_transaction_encryption_keyhandler module returning the shared public key plus a signature from this validator's own signing key.server/validator_service/submit_proven_transaction.rs(decode): unsealtransaction_inputsvia the encryptor beforeTransactionInputs::read_from_bytes. Works identically regardless of which validator instance receives the submission, since the secret is shared.tx_validation/data_store.rs: no change expected — it already only consumes the decodedTransactionInputs.db/schema.rs/db/models.rs/db/mod.rs(insert_transaction): change thevalidated_transactionstable to persist the original ciphertext (SealedMessagebytes) received on submission instead of the derived plaintextaccount_delta/input_notes/output_notes.RPC (
crates/rpc/,crates/proto/)crates/proto/src/clients/mod.rs: extendValidatorClientwith a method to call the validator's new endpoint (mirrors the existingsubmit_batchpattern used incrates/rpc/src/server/api/submit_proven_tx_batch.rs).crates/rpc/src/server/api/: addget_transaction_encryption_key.rsimplementing the RPC-side handler, forwarding to whichever validator the RPC is connected to and passing the response through unchanged (pure proxy, same shape as other forwarded calls).Docs
docs/external/src/rpc/public-api.md(anddocs/internal/src/rpc.mdif it documents validator internals) with the new endpoint, including a note that the returned encryption key is shared across the validator set while the attesting signature is validator-specific.Open questions to resolve during implementation
validated_transactions(account_delta,input_notes,output_notes) are needed in any form going forward, or can be fully replaced by the stored ciphertext blob.Acceptance criteria
GetTransactionEncryptionKeyendpoint, each returning the same shared X25519 public key attested by its own signing key.SealedMessage-wrappedTransactionInputspayload and validate the transaction as before, regardless of which validator originally issued the key to the client.SealedMessagebytes), not the derived plaintextaccount_delta/input_notes/output_notes.