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.
There was a problem hiding this comment.
Added trait and renamed local implementation in cfe6987
| /// 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.
There was a problem hiding this comment.
Decoupled in (marked as dead_code as it's unused until next iteration)
| .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.
There was a problem hiding this comment.
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; |
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.
There was a problem hiding this comment.
Reserved field 5 to 9 for future implementations.
| /// 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.
There was a problem hiding this comment.
Gated behind the test flag as of cfe6987
| 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.
| address, | ||
| grpc_options, | ||
| signer, | ||
| decryptor, |
| 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). |
There was a problem hiding this comment.
(submission-path encryption lands in a
follow-up change)
Probably not necessary to add that line to the docs.
bobbinth
left a comment
There was a problem hiding this comment.
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:
- 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.
- 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).
| ## 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)). | ||
|
|
There was a problem hiding this comment.
Why did we need to remove the newline here?
Also, seems like we don't have a changelog entry for this PR.
| // 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) {} |
There was a problem hiding this comment.
"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.
| // 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 { |
There was a problem hiding this comment.
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.
| // IES scheme identifier as defined by [miden_protocol::crypto::ies::IesScheme]. | ||
| // | ||
| // Currently always `1` (X25519 + XChaCha20-Poly1305). | ||
| uint32 scheme = 1; |
| // Opaque identifier of the current encryption key. Changes when the key rotates. | ||
| bytes key_id = 2; |
| `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 |
There was a problem hiding this comment.
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.
| #[cfg(test)] | ||
| mod tests { |
There was a problem hiding this comment.
nit: can we add a section separator above this line?
| 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>, |
There was a problem hiding this comment.
Should we change these to signing_key and signing_key_kms_id?
| #[arg( | ||
| long = "encryption-key.hex", | ||
| env = ENV_ENCRYPTION_KEY, | ||
| value_name = "VALIDATOR_ENCRYPTION_KEY", | ||
| default_value = INSECURE_ENCRYPTION_KEY_HEX | ||
| )] | ||
| encryption_key: String, |
There was a problem hiding this comment.
Not for this PR, but should we also provide an option to get the key from KMS?
| /// Opaque identifier of the current encryption key. | ||
| pub key_id: Vec<u8>, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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], |
There was a problem hiding this comment.
Would it make sense to create a type for the associated data we expect here? If not in this PR then subsequent ones?
sergerad
left a comment
There was a problem hiding this comment.
LGTM just wondering about whether its worth adding a type for associated_data in the trait. Also the decryptor->decrypter rename 🙏
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