Skip to main content

miden_client/rpc/
encryption.rs

1//! Client-side encryption of the private transaction inputs sent alongside a submission.
2//!
3//! Transaction inputs are submitted as an IES-sealed blob rather than in the clear, so that the RPC
4//! operator cannot read them and only holders of the validator set's shared encryption secret can.
5//! Sealing uses the `X25519XChaCha20Poly1305` scheme; the sealed blob on the wire is a serialized
6//! [`SealedMessage`](miden_protocol::crypto::ies::SealedMessage). The node rejects a submission
7//! whose inputs are not sealed.
8//!
9//! # Trusting the key
10//!
11//! The key is served by the node's `GetTransactionEncryptionKey` endpoint, which the RPC operator
12//! controls -- and that operator is the party this encryption exists to keep out. A key taken from
13//! that endpoint on faith would let the operator substitute its own, decrypt every submission, and
14//! re-seal under the real validator key undetected.
15//!
16//! So a fetched key is never used directly. [`AttestedTransactionEncryptionKey`] is the only thing
17//! the RPC layer can produce, and the sole way to obtain a usable [`TransactionEncryptionKey`] from
18//! it is [`AttestedTransactionEncryptionKey::verify`], which requires a validator signature over
19//! [`attestation_commitment`] that checks out against a validator signing key committed in a block
20//! header. The commitment binds the genesis commitment, so an attestation cannot be replayed from
21//! another network sharing a validator key.
22//!
23//! Once verified, the key is public data shared by the whole validator set, so it is cached in the
24//! store rather than re-fetched per submission. A submission rejected for having been sealed
25//! against a key the validator no longer holds evicts the cached key, so the next submission
26//! fetches and verifies a fresh one.
27//!
28//! # Matching the validator's transcripts
29//!
30//! The canonical definitions live in the node's `miden_node_proto::domain::encryption`. This module
31//! is a hand-maintained mirror of them, because that is a node crate and this client is `no_std`.
32
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35
36use miden_protocol::block::{BlockNumber, ValidatorConfig};
37use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{
38    PublicKey as ValidatorPublicKey,
39    Signature as ValidatorSignature,
40};
41use miden_protocol::crypto::dsa::eddsa_25519_sha512::PublicKey;
42use miden_protocol::crypto::ies::SealingKey;
43use miden_protocol::transaction::{TransactionId, TransactionInputs};
44use miden_protocol::{Hasher, Word};
45use miden_tx::utils::serde::{
46    ByteReader,
47    ByteWriter,
48    Deserializable,
49    DeserializationError,
50    Serializable,
51};
52use rand::CryptoRng;
53
54use super::generated::submission::IesScheme;
55use super::{RpcError, generated as proto};
56
57// CONSTANTS
58// ================================================================================================
59
60/// Key used to store the transaction encryption key in the settings table.
61pub(crate) const TRANSACTION_ENCRYPTION_KEY_STORE_SETTING: &str = "transaction_encryption_key";
62
63/// Domain tag prefixed to the associated data of sealed transaction inputs.
64///
65/// Separates this transcript from every other use of the same key material, in particular from the
66/// key attestation signed with the validator's signing key. Must match the validator's
67/// `TX_INPUT_SEAL_DOMAIN`.
68const TX_INPUT_SEAL_DOMAIN: &[u8] = b"MIDEN_TX_INPUT_SEAL_V1";
69
70/// Domain tag prefixed to the attestation payload, separating key attestations from block header
71/// signatures made with the same validator signing key.
72///
73/// Must match the validator's `ATTESTATION_DOMAIN`.
74const ATTESTATION_DOMAIN: &[u8] = b"MIDEN_TX_ENCRYPTION_KEY_ATTESTATION_V1";
75
76/// Wire identifier of the only IES scheme this client seals for.
77const SUPPORTED_SCHEME: u32 = IesScheme::X25519Xchacha20Poly1305 as u32;
78
79/// Longest key identifier accepted from the RPC, in bytes.
80///
81/// Must match the validator's `MAX_KEY_ID_LEN`.
82const MAX_KEY_ID_LEN: usize = 64;
83
84// TRANSACTION ENCRYPTION KEY
85// ================================================================================================
86
87/// The validator set's public transaction encryption key, with its attestation already verified.
88///
89/// Holds public key material only, and is shared by every validator in the set; the matching secret
90/// never leaves the validators.
91///
92/// RPC responses can only construct one through [`AttestedTransactionEncryptionKey::verify`].
93/// Deserialization revalidates the scheme and key ID of cached keys.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct TransactionEncryptionKey {
96    scheme: u32,
97    key_id: Vec<u8>,
98    public_key: PublicKey,
99    genesis_commitment: Word,
100}
101
102impl TransactionEncryptionKey {
103    /// Returns the node's opaque identifier for this key.
104    ///
105    /// The identifier changes when the key rotates, which is what lets a cached key be recognized
106    /// as stale. It is treated as opaque bytes: the node derives it from the public key commitment
107    /// but documents the encoding as an implementation detail.
108    pub fn key_id(&self) -> &[u8] {
109        &self.key_id
110    }
111
112    /// Returns the public key.
113    pub fn public_key(&self) -> &PublicKey {
114        &self.public_key
115    }
116
117    /// Builds the associated data authenticating the inputs of the transaction identified by
118    /// `tx_id` when sealed against this key.
119    fn transaction_inputs_associated_data(&self, tx_id: TransactionId) -> Vec<u8> {
120        transaction_inputs_associated_data(
121            self.scheme,
122            &self.key_id,
123            self.genesis_commitment,
124            tx_id,
125        )
126    }
127
128    /// Builds the sealing key used to encrypt transaction inputs against this key.
129    pub fn sealing_key(&self) -> SealingKey {
130        SealingKey::X25519XChaCha20Poly1305(self.public_key.clone())
131    }
132
133    /// Builds a key without an attestation, for tests.
134    ///
135    /// Sealing against the returned key still runs the real transcript and wire path, only the
136    /// attestation is skipped, and that is covered by this module's own tests.
137    #[cfg(feature = "testing")]
138    pub fn new_unattested(
139        key_id: Vec<u8>,
140        public_key: PublicKey,
141        genesis_commitment: Word,
142    ) -> Self {
143        Self {
144            scheme: SUPPORTED_SCHEME,
145            key_id,
146            public_key,
147            genesis_commitment,
148        }
149    }
150}
151
152impl Serializable for TransactionEncryptionKey {
153    fn write_into<W: ByteWriter>(&self, target: &mut W) {
154        target.write_u32(self.scheme);
155        target.write_usize(self.key_id.len());
156        target.write_bytes(&self.key_id);
157        self.public_key.write_into(target);
158        self.genesis_commitment.write_into(target);
159    }
160}
161
162impl Deserializable for TransactionEncryptionKey {
163    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
164        let scheme = source.read_u32()?;
165        let key_id_len = source.read_usize()?;
166        validate_key_metadata(scheme, key_id_len).map_err(DeserializationError::InvalidValue)?;
167
168        let key_id = source.read_vec(key_id_len)?;
169        let public_key = PublicKey::read_from(source)?;
170        let genesis_commitment = Word::read_from(source)?;
171
172        Ok(Self {
173            scheme,
174            key_id,
175            public_key,
176            genesis_commitment,
177        })
178    }
179}
180
181// ATTESTED TRANSACTION ENCRYPTION KEY
182// ================================================================================================
183
184/// The next encryption key announced ahead of a scheduled rotation.
185///
186/// Covered by [`attestation_commitment`], so it cannot be stripped or altered without invalidating
187/// the attestations. Carried for verification only; this client does not yet act on rotations.
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct NextTransactionEncryptionKey {
190    /// Wire identifier of the next key's IES scheme.
191    pub scheme: u32,
192    /// Opaque identifier of the next key.
193    pub key_id: Vec<u8>,
194    /// Raw public key bytes of the next key.
195    pub public_key: Vec<u8>,
196    /// Block number at which the next key takes effect.
197    pub rotation_block_num: BlockNumber,
198}
199
200/// A single validator's endorsement of a served encryption key.
201///
202/// The signature covers [`attestation_commitment`] recomputed from the served fields, and counts
203/// only if `validator_key` is present in a validator set committed in a block header this client
204/// trusts.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub struct ValidatorAttestation {
207    /// Signing key of the attesting validator.
208    pub validator_key: ValidatorPublicKey,
209    /// The validator's signature over [`attestation_commitment`].
210    pub signature: ValidatorSignature,
211}
212
213/// A transaction encryption key exactly as the node served it, before it is trusted.
214///
215/// Deliberately not usable for sealing. [`Self::verify`] is the only way to turn it into a
216/// [`TransactionEncryptionKey`], so a key served by an untrusted RPC cannot reach the seal path
217/// without a validator attestation checking out first.
218///
219/// Fields are kept in their served wire form because the attestation commitment is computed over
220/// exactly those bytes.
221#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct AttestedTransactionEncryptionKey {
223    /// Wire identifier of the key's IES scheme.
224    pub scheme: u32,
225    /// Opaque identifier of the key.
226    pub key_id: Vec<u8>,
227    /// Raw public key bytes.
228    pub public_key: Vec<u8>,
229    /// Validator attestations over [`attestation_commitment`].
230    pub attestations: Vec<ValidatorAttestation>,
231    /// The next key, when a rotation is scheduled.
232    pub next_key: Option<NextTransactionEncryptionKey>,
233}
234
235impl AttestedTransactionEncryptionKey {
236    /// Verifies the served key and returns it in usable form.
237    ///
238    /// Requires at least one attestation whose validator key is present in `validator_keys` -- the
239    /// set committed in a block header this client trusts -- and whose signature covers the
240    /// commitment recomputed from the served fields. Every validator vouches for the same key, so
241    /// one verifiable attestation from a chain-recognized validator is sufficient.
242    ///
243    /// # Errors
244    /// Returns an error if the scheme is unsupported, the public key does not decode, or no
245    /// attestation from a recognized validator verifies.
246    pub fn verify(
247        self,
248        genesis_commitment: Word,
249        validator_keys: &ValidatorConfig,
250    ) -> Result<TransactionEncryptionKey, RpcError> {
251        validate_key_metadata(self.scheme, self.key_id.len())
252            .map_err(RpcError::TransactionEncryptionKeyRejected)?;
253        if let Some(next) = &self.next_key {
254            validate_key_id_len(next.key_id.len(), "next encryption key id")
255                .map_err(RpcError::TransactionEncryptionKeyRejected)?;
256        }
257
258        let commitment = attestation_commitment(
259            self.scheme,
260            &self.key_id,
261            genesis_commitment,
262            &self.public_key,
263            self.next_key.as_ref(),
264        );
265
266        let recognized = validator_keys.keys();
267        let attested = self.attestations.iter().any(|attestation| {
268            recognized.contains(&attestation.validator_key)
269                && attestation.validator_key.verify(commitment, &attestation.signature)
270        });
271        if !attested {
272            return Err(RpcError::TransactionEncryptionKeyRejected(
273                "no attestation from a chain-recognized validator verifies against the key".into(),
274            ));
275        }
276
277        // Parsed after verification: the commitment covers the served bytes, so decoding earlier
278        // would accept a shape the attestation never signed.
279        let public_key = PublicKey::read_from_bytes(&self.public_key)
280            .map_err(|err| RpcError::TransactionEncryptionKeyRejected(err.to_string()))?;
281
282        Ok(TransactionEncryptionKey {
283            scheme: self.scheme,
284            key_id: self.key_id,
285            public_key,
286            genesis_commitment,
287        })
288    }
289}
290
291fn validate_key_metadata(scheme: u32, key_id_len: usize) -> Result<(), String> {
292    if scheme != SUPPORTED_SCHEME {
293        return Err(format!("unsupported IES scheme '{scheme}'"));
294    }
295
296    validate_key_id_len(key_id_len, "encryption key id")
297}
298
299fn validate_key_id_len(key_id_len: usize, field: &str) -> Result<(), String> {
300    if key_id_len == 0 {
301        return Err(format!("{field} is empty"));
302    }
303    if key_id_len > MAX_KEY_ID_LEN {
304        return Err(format!(
305            "{field} is {key_id_len} bytes, which exceeds the maximum of {MAX_KEY_ID_LEN}"
306        ));
307    }
308    Ok(())
309}
310
311/// Computes the commitment a validator signs to attest an encryption key.
312///
313/// Mirrors the validator's `attestation_commitment` (`signers::attestation_commitment` in the
314/// `miden-validator` crate of `0xMiden/node`) so the layout is duplicated here and pinned against
315/// the validator's output by the golden-vector tests below: the Poseidon2 hash of
316/// `ATTESTATION_DOMAIN || scheme || len(key_id) || key_id || genesis_commitment || len(public_key)
317/// || public_key || next_key_transcript`, where the scheme, rotation block number and length
318/// prefixes are 4 bytes little-endian. The length prefixes keep the payload injective, and the
319/// genesis commitment ties the attestation to one chain. Any divergence from the validator's layout
320/// makes every signature fail to verify.
321pub fn attestation_commitment(
322    scheme: u32,
323    key_id: &[u8],
324    genesis_commitment: Word,
325    public_key: &[u8],
326    next_key: Option<&NextTransactionEncryptionKey>,
327) -> Word {
328    let mut payload = Vec::new();
329    payload.extend_from_slice(ATTESTATION_DOMAIN);
330    payload.extend_from_slice(&scheme.to_le_bytes());
331    extend_with_length_prefixed(&mut payload, key_id);
332    payload.extend_from_slice(&genesis_commitment.to_bytes());
333    extend_with_length_prefixed(&mut payload, public_key);
334    if let Some(next) = next_key {
335        payload.extend_from_slice(&next.scheme.to_le_bytes());
336        extend_with_length_prefixed(&mut payload, &next.key_id);
337        extend_with_length_prefixed(&mut payload, &next.public_key);
338        payload.extend_from_slice(&next.rotation_block_num.as_u32().to_le_bytes());
339    }
340
341    Hasher::hash(&payload)
342}
343
344/// Appends a field prefixed with its length as 4 bytes little-endian.
345///
346/// A field longer than `u32::MAX` cannot occur in a response this client accepts, and saturating
347/// keeps the helper infallible; an inaccurate prefix only makes verification fail.
348fn extend_with_length_prefixed(payload: &mut Vec<u8>, field: &[u8]) {
349    let len = u32::try_from(field.len()).unwrap_or(u32::MAX);
350    payload.extend_from_slice(&len.to_le_bytes());
351    payload.extend_from_slice(field);
352}
353
354// ASSOCIATED DATA
355// ================================================================================================
356
357/// Builds the associated data authenticating a sealed set of transaction inputs.
358///
359/// Mirrors the validator's `transaction_inputs_associated_data`. The layout is
360/// `TX_INPUT_SEAL_DOMAIN || scheme || len(key_id) || key_id || genesis_commitment ||
361/// transaction_id`, where the scheme and the length prefix are 4 bytes little-endian. The domain
362/// tag and the scheme are fixed-width, `key_id` is length-prefixed, and the two trailing fields are
363/// a fixed 32 bytes each, so no two distinct inputs produce the same transcript.
364///
365/// Each binding serves a purpose:
366/// - `scheme` and `key_id` tie the blob to one key, so inputs sealed against a retired key fail to
367///   authenticate rather than silently decrypting.
368/// - `genesis_commitment` ties the blob to one network. This matters in practice because every
369///   development stack shares the same insecure default key, so without it a blob captured on one
370///   network would replay onto another.
371/// - `transaction_id` ties the blob to one transaction, so a captured blob cannot be replayed onto
372///   a different transaction.
373///
374/// Deliberately absent is the serialized transaction. The RPC rebuilds the proven transaction with
375/// output-note decorators stripped before forwarding a submission, so binding those bytes would
376/// reject every relayed transaction. The transaction id is invariant under that rebuild, which is
377/// why it is bound instead.
378fn transaction_inputs_associated_data(
379    scheme: u32,
380    key_id: &[u8],
381    genesis_commitment: Word,
382    tx_id: TransactionId,
383) -> Vec<u8> {
384    let genesis_commitment = genesis_commitment.to_bytes();
385    let tx_id = tx_id.as_word().to_bytes();
386    let mut transcript = Vec::with_capacity(
387        TX_INPUT_SEAL_DOMAIN.len()
388            + 2 * size_of::<u32>()
389            + key_id.len()
390            + genesis_commitment.len()
391            + tx_id.len(),
392    );
393    transcript.extend_from_slice(TX_INPUT_SEAL_DOMAIN);
394    transcript.extend_from_slice(&scheme.to_le_bytes());
395    extend_with_length_prefixed(&mut transcript, key_id);
396    transcript.extend_from_slice(&genesis_commitment);
397    transcript.extend_from_slice(&tx_id);
398
399    transcript
400}
401
402// SEALED TRANSACTION INPUTS
403// ================================================================================================
404
405/// The sealed, wire-ready form of a transaction's [`TransactionInputs`].
406///
407/// Wraps the serialized bytes of a [`SealedMessage`](miden_protocol::crypto::ies::SealedMessage) so
408/// that a plaintext blob cannot be passed to submission by mistake, alongside the identifier of the
409/// key they were sealed against.
410#[derive(Clone, Debug, PartialEq, Eq)]
411pub struct SealedTransactionInputs {
412    key_id: Vec<u8>,
413    ciphertext: Vec<u8>,
414}
415
416impl SealedTransactionInputs {
417    /// Returns the identifier of the key these inputs were sealed against.
418    pub fn key_id(&self) -> &[u8] {
419        &self.key_id
420    }
421
422    /// Returns the sealed bytes.
423    pub fn ciphertext(&self) -> &[u8] {
424        &self.ciphertext
425    }
426}
427
428impl From<SealedTransactionInputs> for proto::submission::SealedTransactionInputs {
429    fn from(sealed: SealedTransactionInputs) -> Self {
430        Self {
431            key_id: sealed.key_id,
432            ciphertext: sealed.ciphertext,
433        }
434    }
435}
436
437// SEALING
438// ================================================================================================
439
440/// Seals the inputs of the transaction identified by `tx_id` against `key`, ready to be submitted.
441///
442/// `rng` supplies the scheme's ephemeral key material, so it must be cryptographically secure. Each
443/// call draws a fresh ephemeral key, so sealing the same inputs twice is safe and yields different
444/// ciphertexts.
445pub fn seal_transaction_inputs<R: CryptoRng>(
446    rng: &mut R,
447    key: &TransactionEncryptionKey,
448    tx_id: TransactionId,
449    transaction_inputs: &TransactionInputs,
450) -> Result<SealedTransactionInputs, RpcError> {
451    let associated_data = key.transaction_inputs_associated_data(tx_id);
452    let sealed = key
453        .sealing_key()
454        .seal_bytes_with_associated_data(rng, &transaction_inputs.to_bytes(), &associated_data)
455        .map_err(|err| RpcError::TransactionInputsSealingFailed(err.to_string()))?;
456
457    Ok(SealedTransactionInputs {
458        key_id: key.key_id().to_vec(),
459        ciphertext: sealed.to_bytes(),
460    })
461}
462
463// TESTS
464// ================================================================================================
465
466#[cfg(test)]
467mod tests {
468    use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey as ValidatorSigningKey;
469    use miden_protocol::crypto::dsa::eddsa_25519_sha512::KeyExchangeKey;
470    use miden_protocol::crypto::ies::{SealedMessage, UnsealingKey};
471    use rand::SeedableRng;
472    use rand_chacha::ChaCha20Rng;
473
474    use super::*;
475
476    const TEST_KEY_ID: [u8; 4] = [0xde, 0xad, 0xbe, 0xef];
477
478    #[test]
479    fn transaction_encryption_key_deserialization_validates_metadata() {
480        let (key, _) = key_pair();
481        assert_eq!(TransactionEncryptionKey::read_from_bytes(&key.to_bytes()).unwrap(), key);
482
483        let invalid_keys = [
484            TransactionEncryptionKey {
485                scheme: SUPPORTED_SCHEME + 1,
486                ..key.clone()
487            },
488            TransactionEncryptionKey { key_id: Vec::new(), ..key.clone() },
489            TransactionEncryptionKey {
490                key_id: vec![0; MAX_KEY_ID_LEN + 1],
491                ..key
492            },
493        ];
494        for key in invalid_keys {
495            assert!(matches!(
496                TransactionEncryptionKey::read_from_bytes(&key.to_bytes()),
497                Err(DeserializationError::InvalidValue(_))
498            ));
499        }
500    }
501
502    fn rng() -> ChaCha20Rng {
503        ChaCha20Rng::seed_from_u64(0xface)
504    }
505
506    fn genesis() -> Word {
507        Word::from([1u32, 2, 3, 4])
508    }
509
510    fn tx_id(seed: u32) -> TransactionId {
511        TransactionId::new(
512            Word::from([seed, 0, 0, 0]),
513            Word::from([0, seed, 0, 0]),
514            Word::from([0, 0, seed, 0]),
515            Word::from([0, 0, 0, seed]),
516        )
517    }
518
519    /// Generates a keypair standing in for the validator set's shared key: the public half becomes
520    /// the client's [`TransactionEncryptionKey`], the secret half plays the validator unsealing it.
521    fn key_pair() -> (TransactionEncryptionKey, UnsealingKey) {
522        let secret_key = KeyExchangeKey::with_rng(&mut rng());
523        let key = TransactionEncryptionKey {
524            scheme: SUPPORTED_SCHEME,
525            key_id: TEST_KEY_ID.to_vec(),
526            public_key: secret_key.public_key(),
527            genesis_commitment: genesis(),
528        };
529
530        (key, UnsealingKey::X25519XChaCha20Poly1305(secret_key))
531    }
532
533    /// Unseals the way the validator does: rebuilding the associated data from its own view of the
534    /// key and of the transaction rather than from anything the blob carries.
535    fn unseal(
536        unsealing_key: &UnsealingKey,
537        sealed: &SealedTransactionInputs,
538        key: &TransactionEncryptionKey,
539        tx_id: TransactionId,
540    ) -> Result<Vec<u8>, ()> {
541        let associated_data = key.transaction_inputs_associated_data(tx_id);
542
543        unsealing_key
544            .unseal_bytes_with_associated_data(
545                SealedMessage::read_from_bytes(sealed.ciphertext()).unwrap(),
546                &associated_data,
547            )
548            .map_err(|_| ())
549    }
550
551    fn seal(key: &TransactionEncryptionKey, tx_id: TransactionId) -> SealedTransactionInputs {
552        let associated_data = key.transaction_inputs_associated_data(tx_id);
553        let sealed = key
554            .sealing_key()
555            .seal_bytes_with_associated_data(&mut rng(), b"transaction inputs", &associated_data)
556            .unwrap();
557
558        SealedTransactionInputs {
559            key_id: key.key_id().to_vec(),
560            ciphertext: sealed.to_bytes(),
561        }
562    }
563
564    // ASSOCIATED DATA
565    // --------------------------------------------------------------------------------------------
566
567    /// Pins the transcript byte-for-byte, which also pins *which* fields it binds.
568    ///
569    /// Both sides derive the transcript through their own copy of this function, so a change to it
570    /// would pass every other test in the workspace and surface only as every submission on the
571    /// network failing to authenticate. This vector is the only thing that catches that, so it is
572    /// spelled out here rather than derived from the constants it is checking.
573    #[test]
574    fn associated_data_matches_the_validator_transcript() {
575        let associated_data =
576            transaction_inputs_associated_data(1, &TEST_KEY_ID, genesis(), tx_id(10));
577
578        let mut expected = Vec::new();
579        expected.extend_from_slice(b"MIDEN_TX_INPUT_SEAL_V1");
580        expected.extend_from_slice(&1u32.to_le_bytes());
581        expected.extend_from_slice(&4u32.to_le_bytes());
582        expected.extend_from_slice(&TEST_KEY_ID);
583        expected.extend_from_slice(&genesis().to_bytes());
584        expected.extend_from_slice(&tx_id(10).as_word().to_bytes());
585
586        assert_eq!(associated_data, expected);
587        // 22-byte tag + 4 scheme + 4 length + 4 key id + 32 genesis + 32 transaction id.
588        assert_eq!(associated_data.len(), 98);
589    }
590
591    // SEALING
592    // --------------------------------------------------------------------------------------------
593
594    #[test]
595    fn sealed_inputs_round_trip() {
596        let (key, unsealing_key) = key_pair();
597        let sealed = seal(&key, tx_id(10));
598
599        assert_eq!(sealed.key_id(), key.key_id());
600        let opened = unseal(&unsealing_key, &sealed, &key, tx_id(10)).unwrap();
601        assert_eq!(opened, b"transaction inputs");
602    }
603
604    /// The transaction id binding: a blob captured from one submission must not authenticate when
605    /// replayed onto a different transaction.
606    #[test]
607    fn unsealing_rejects_a_different_transaction() {
608        let (key, unsealing_key) = key_pair();
609        let sealed = seal(&key, tx_id(10));
610
611        assert!(unseal(&unsealing_key, &sealed, &key, tx_id(11)).is_err());
612    }
613
614    /// The key id binding: inputs sealed against a retired key fail to authenticate rather than
615    /// silently decrypting under the current one.
616    #[test]
617    fn unsealing_rejects_a_different_key_id() {
618        let (key, unsealing_key) = key_pair();
619        let sealed = seal(&key, tx_id(10));
620
621        let rotated = TransactionEncryptionKey { key_id: b"other".to_vec(), ..key };
622        assert!(unseal(&unsealing_key, &sealed, &rotated, tx_id(10)).is_err());
623    }
624
625    /// Each seal draws a fresh ephemeral key, so resealing the same inputs for the same transaction
626    /// must not produce a linkable blob. Both seals draw from one RNG, as consecutive submissions
627    /// from a single client do.
628    #[test]
629    fn sealing_the_same_inputs_twice_yields_different_ciphertexts() {
630        let (key, unsealing_key) = key_pair();
631        let associated_data = key.transaction_inputs_associated_data(tx_id(10));
632        let mut rng = rng();
633        let mut seal_once = || {
634            key.sealing_key()
635                .seal_bytes_with_associated_data(&mut rng, b"transaction inputs", &associated_data)
636                .unwrap()
637                .to_bytes()
638        };
639
640        let first = seal_once();
641        let second = seal_once();
642
643        assert_ne!(first, second);
644        for ciphertext in [first, second] {
645            let sealed = SealedTransactionInputs {
646                key_id: key.key_id().to_vec(),
647                ciphertext,
648            };
649            assert_eq!(
650                unseal(&unsealing_key, &sealed, &key, tx_id(10)).unwrap(),
651                b"transaction inputs"
652            );
653        }
654    }
655
656    // ATTESTATION VERIFICATION
657    // --------------------------------------------------------------------------------------------
658
659    /// Builds a response attested by `signer`, the way a validator serves one.
660    fn attested(
661        key: &TransactionEncryptionKey,
662        signer: &ValidatorSigningKey,
663        genesis_commitment: Word,
664    ) -> AttestedTransactionEncryptionKey {
665        attested_with_next(key, signer, genesis_commitment, None)
666    }
667
668    /// Builds a response whose signature also covers `next_key`, so that tests exercising a
669    /// scheduled rotation fail for the reason they name rather than for an invalid signature.
670    fn attested_with_next(
671        key: &TransactionEncryptionKey,
672        signer: &ValidatorSigningKey,
673        genesis_commitment: Word,
674        next_key: Option<NextTransactionEncryptionKey>,
675    ) -> AttestedTransactionEncryptionKey {
676        let public_key = key.public_key().to_bytes();
677        let commitment = attestation_commitment(
678            SUPPORTED_SCHEME,
679            key.key_id(),
680            genesis_commitment,
681            &public_key,
682            next_key.as_ref(),
683        );
684
685        AttestedTransactionEncryptionKey {
686            scheme: SUPPORTED_SCHEME,
687            key_id: key.key_id().to_vec(),
688            public_key,
689            attestations: vec![ValidatorAttestation {
690                validator_key: signer.public_key(),
691                signature: signer.sign(commitment),
692            }],
693            next_key,
694        }
695    }
696
697    #[test]
698    fn verify_accepts_an_attestation_from_a_recognized_validator() {
699        let (key, _) = key_pair();
700        let signer = ValidatorSigningKey::with_rng(&mut rng());
701        let validator_keys = ValidatorConfig::new(vec![signer.public_key()], 1).unwrap();
702
703        let verified =
704            attested(&key, &signer, genesis()).verify(genesis(), &validator_keys).unwrap();
705
706        assert_eq!(verified, key);
707    }
708
709    #[test]
710    fn verify_rejects_a_validator_absent_from_the_committed_set() {
711        let (key, _) = key_pair();
712        let impostor = ValidatorSigningKey::with_rng(&mut rng());
713        let committed = ValidatorSigningKey::with_rng(&mut ChaCha20Rng::seed_from_u64(7));
714        let validator_keys = ValidatorConfig::new(vec![committed.public_key()], 1).unwrap();
715
716        assert!(attested(&key, &impostor, genesis()).verify(genesis(), &validator_keys).is_err());
717    }
718
719    /// The whole point of the attestation: a substituted public key must not verify, even though
720    /// the signature itself is genuine.
721    #[test]
722    fn verify_rejects_a_substituted_public_key() {
723        let (key, _) = key_pair();
724        let signer = ValidatorSigningKey::with_rng(&mut rng());
725        let validator_keys = ValidatorConfig::new(vec![signer.public_key()], 1).unwrap();
726
727        let substitute = KeyExchangeKey::with_rng(&mut ChaCha20Rng::seed_from_u64(99));
728        let mut response = attested(&key, &signer, genesis());
729        response.public_key = substitute.public_key().to_bytes();
730
731        assert!(response.verify(genesis(), &validator_keys).is_err());
732    }
733
734    /// The genesis commitment scopes an attestation to one chain, so the same signed response must
735    /// not verify against a different network.
736    #[test]
737    fn verify_rejects_an_attestation_from_another_network() {
738        let (key, _) = key_pair();
739        let signer = ValidatorSigningKey::with_rng(&mut rng());
740        let validator_keys = ValidatorConfig::new(vec![signer.public_key()], 1).unwrap();
741
742        let response = attested(&key, &signer, genesis());
743
744        assert!(response.verify(Word::from([9u32, 9, 9, 9]), &validator_keys).is_err());
745    }
746
747    /// A scheduled rotation is covered by the signature, so it cannot be injected or altered by the
748    /// operator relaying the response.
749    #[test]
750    fn verify_rejects_an_injected_next_key() {
751        let (key, _) = key_pair();
752        let signer = ValidatorSigningKey::with_rng(&mut rng());
753        let validator_keys = ValidatorConfig::new(vec![signer.public_key()], 1).unwrap();
754
755        let mut response = attested(&key, &signer, genesis());
756        response.next_key = Some(NextTransactionEncryptionKey {
757            scheme: SUPPORTED_SCHEME,
758            key_id: vec![1, 2, 3, 4],
759            public_key: KeyExchangeKey::with_rng(&mut ChaCha20Rng::seed_from_u64(11))
760                .public_key()
761                .to_bytes(),
762            rotation_block_num: 100.into(),
763        });
764
765        assert!(response.verify(genesis(), &validator_keys).is_err());
766    }
767
768    #[test]
769    fn verify_rejects_an_unsupported_scheme() {
770        let (key, _) = key_pair();
771        let signer = ValidatorSigningKey::with_rng(&mut rng());
772        let validator_keys = ValidatorConfig::new(vec![signer.public_key()], 1).unwrap();
773
774        let mut response = attested(&key, &signer, genesis());
775        response.scheme = SUPPORTED_SCHEME + 1;
776
777        assert!(response.verify(genesis(), &validator_keys).is_err());
778    }
779
780    // VALIDATOR PARITY
781    // --------------------------------------------------------------------------------------------
782
783    /// Expected values produced by the validator's own implementation over these exact inputs
784    /// (`miden_validator::attestation_commitment`, `0xMiden/node` rev `5066b383`, identical on
785    /// `next` at `da261511`). The commitment layout is duplicated on both sides, so these vectors
786    /// are what ties them together: if either side changes its layout, this test fails rather than
787    /// every attestation quietly failing to verify. Regenerate by feeding the same inputs to the
788    /// node's function.
789    #[test]
790    fn attestation_commitment_matches_the_validator_implementation() {
791        let genesis = Word::from([101u32, 102, 103, 104]);
792
793        let no_rotation =
794            attestation_commitment(1, b"golden-key-id", genesis, b"golden-public-key", None);
795        assert_eq!(
796            no_rotation.to_hex(),
797            "0x245d1f2d45d4a60d9edd4576691244d6b9ee16fe67635425dc685cd54918a970"
798        );
799
800        let next = NextTransactionEncryptionKey {
801            scheme: 2,
802            key_id: b"next-key-id".to_vec(),
803            public_key: b"next-public-key".to_vec(),
804            rotation_block_num: BlockNumber::from(7u32),
805        };
806        let with_rotation =
807            attestation_commitment(1, b"golden-key-id", genesis, b"golden-public-key", Some(&next));
808        assert_eq!(
809            with_rotation.to_hex(),
810            "0xddfd7907b6a1ea6f294809ff0ed775f270b649ca15b21f88127c8335945e4752"
811        );
812    }
813}