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.
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).
| #[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?
There was a problem hiding this comment.
We just need to ensure KMS supports this key type, otherwise we cannot decrypt.
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 🙏
huitseeker
left a comment
There was a problem hiding this comment.
LGTM with the known follow-ups discussed in thread.
bobbinth
left a comment
There was a problem hiding this comment.
Looks good! Thank you! I left a couple of comments inline - but these can be addresses in follow-ups (if we decide to address them).
| // The public key is shared across the whole validator set, while each attesting signature is | ||
| // specific to one validator. Verifying an attestation 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.
I would have maybe arranged things a bit differently:
// Same as now
message ValidatorKeyAttestation {
bytes validator_public_key = 1;
bytes signature = 2;
}
message TransactionEncryptionKey {
IesScheme scheme = 1;
bytes key_id = 2;
bytes public_key = 3;
repeated ValidatorKeyAttestation attestations = 4;
}
message NextTransactionEncryptionKey {
TransactionEncryptionKey key = 1;
fixed32 rotation_block_num = 2;
}
message TransactionEncryptionKeyResponse {
TransactionEncryptionKey current_key = 1;
optional NextTransactionEncryptionKey = 2;
}This way, the signature doesn't need to cover next_key_transcript.
There was a problem hiding this comment.
Thanks for the suggestion! I'll apply it in a follow-up PR that adds rotation to the validator keys.
| signer: ValidatorSigner, | ||
| /// Decrypter for transaction inputs sealed against the shared encryption key. | ||
| #[expect(dead_code, reason = "used by the submit path in a follow-up PR")] | ||
| decrypter: Arc<dyn TransactionInputDecrypter>, |
There was a problem hiding this comment.
Question (not a change request): why did we decide to go with a dyn trait for the decrypter but with an enum for the signer? Is there something different between these two such that we can't use the same approach for both?
There was a problem hiding this comment.
We went with dyn trait follows @huitseeker suggestion that we should expose a minimal api for the decrypter to better acommodate later implementations.
Left the signer as an enum, as it was already like that from before this PR. We can modify it to better align the two types.
| #[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.
We just need to ensure KMS supports this key type, otherwise we cannot decrypt.
| /// Unlike the signing key, the key material behind an implementation must be identical across | ||
| /// every validator in the set. This lets any validator unseal an encrypted submission, regardless | ||
| /// of which validator attested the encryption key to the client. |
There was a problem hiding this comment.
@huitseeker for my edification - does this hold true under multisig conditions?
A single global public encryption key, but decryption requires N of M different keys? Or does encryption require M public keys?
There was a problem hiding this comment.
Decryption also currently requires just one key - but this is just the first step. After this PR, there are two more steps:
- The validators will perform threshold re-encryption. That is, they will decrypt the private inputs with the single decryption key (same as now), but then will re-encrypt it with threshold encryption so that decryption requires collaboration with others.
- The decryption and re-encryption moves into a TEE.
The end result would be that a validator never sees plaintext data but gets a threshold-encrypted version of it from the TEE. After this, a subset of validators meeting the required threshold requirement can decrypt a given transaction.
There was a problem hiding this comment.
iiuc this means there will be a single TEE shared amongst all validators? And that the user-facing encryption key will be the TEE's pub key?
There was a problem hiding this comment.
There would be multiple TEEs (one per validator) but the attestations for the encryption key will probably be a bit more complicated so that the user can be convinced that the encryption key comes from the validators' TEEs (I don't have the exact scheme in mind yet).
There was a problem hiding this comment.
Okay yeah I didn't understand how one could attest that, nor how one could share such a key amongst TEEs without exposing the key to the TEE operator.
Pushed COMMIT that addressed this. Added a new argument option |
|
Should we merge this now? Or does anyone want to talk one last look? |
I'm okay with merging; I'm sure other things will fall out in the follow-up PRs. |
bobbinth
left a comment
There was a problem hiding this comment.
All looks good! Thank you!
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