feat: add input encryption for validators#2342
Conversation
|
I'm planning to review this today. |
huitseeker
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| /// Constructs an encryptor from a locally provisioned shared secret. | ||
| pub fn new_local(secret_key: KeyExchangeKey) -> Self { | ||
| Self::Local(secret_key) | ||
| } |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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.
| 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(), |
There was a problem hiding this comment.
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 ).
| 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) | ||
| } |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
| /// 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()) | ||
| } |
There was a problem hiding this comment.
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.
| 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), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
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:
--encryption-key.hex / MIDEN_VALIDATOR_ENCRYPTION_KEY. If ommited, an insecure development default is used (that logs a loud warning at startup).ValidatorEncryptorwraps the key and produces the attestation. Each validator signs a Poseidon2 commitment overdomain_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.GetTransactionEncryptionKeyendpoint on the validator's internal API returns the public key, IES scheme id, key id, and attesting signature (TransactionEncryptionKeyproto 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::signwas generalized tosign_commitmentso both block headers and key attestations go through the same local/KMS signing path.Changelog