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
4//! RPC operator cannot read them and only holders of the validator set's shared encryption secret
5//! can. Sealing uses the `X25519XChaCha20Poly1305` scheme; the sealed blob on the wire is a
6//! serialized [`SealedMessage`](miden_protocol::crypto::ies::SealedMessage). The node rejects a
7//! submission 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::ToString;
34use alloc::vec::Vec;
35
36use miden_protocol::block::{BlockNumber, ValidatorKeys};
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::transaction::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
90/// secret never leaves the validators.
91///
92/// Only [`AttestedTransactionEncryptionKey::verify`] constructs one, so a key that reaches the seal
93/// path has necessarily been vouched for by a chain-recognized validator.
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
136    /// the 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        let key_id = source.read_vec(key_id_len)?;
167        let public_key = PublicKey::read_from(source)?;
168        let genesis_commitment = Word::read_from(source)?;
169
170        Ok(Self {
171            scheme,
172            key_id,
173            public_key,
174            genesis_commitment,
175        })
176    }
177}
178
179// ATTESTED TRANSACTION ENCRYPTION KEY
180// ================================================================================================
181
182/// The next encryption key announced ahead of a scheduled rotation.
183///
184/// Covered by [`attestation_commitment`], so it cannot be stripped or altered without invalidating
185/// the attestations. Carried for verification only; this client does not yet act on rotations.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub struct NextTransactionEncryptionKey {
188    /// Wire identifier of the next key's IES scheme.
189    pub scheme: u32,
190    /// Opaque identifier of the next key.
191    pub key_id: Vec<u8>,
192    /// Raw public key bytes of the next key.
193    pub public_key: Vec<u8>,
194    /// Block number at which the next key takes effect.
195    pub rotation_block_num: BlockNumber,
196}
197
198/// A single validator's endorsement of a served encryption key.
199///
200/// The signature covers [`attestation_commitment`] recomputed from the served fields, and counts
201/// only if `validator_key` is present in a validator set committed in a block header this client
202/// trusts.
203#[derive(Clone, Debug, PartialEq, Eq)]
204pub struct ValidatorAttestation {
205    /// Signing key of the attesting validator.
206    pub validator_key: ValidatorPublicKey,
207    /// The validator's signature over [`attestation_commitment`].
208    pub signature: ValidatorSignature,
209}
210
211/// A transaction encryption key exactly as the node served it, before it is trusted.
212///
213/// Deliberately not usable for sealing. [`Self::verify`] is the only way to turn it into a
214/// [`TransactionEncryptionKey`], so a key served by an untrusted RPC cannot reach the seal path
215/// without a validator attestation checking out first.
216///
217/// Fields are kept in their served wire form because the attestation commitment is computed over
218/// exactly those bytes.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub struct AttestedTransactionEncryptionKey {
221    /// Wire identifier of the key's IES scheme.
222    pub scheme: u32,
223    /// Opaque identifier of the key.
224    pub key_id: Vec<u8>,
225    /// Raw public key bytes.
226    pub public_key: Vec<u8>,
227    /// Validator attestations over [`attestation_commitment`].
228    pub attestations: Vec<ValidatorAttestation>,
229    /// The next key, when a rotation is scheduled.
230    pub next_key: Option<NextTransactionEncryptionKey>,
231}
232
233impl AttestedTransactionEncryptionKey {
234    /// Verifies the served key and returns it in usable form.
235    ///
236    /// Requires at least one attestation whose validator key is present in `validator_keys` -- the
237    /// set committed in a block header this client trusts -- and whose signature covers the
238    /// commitment recomputed from the served fields. Every validator vouches for the same key, so
239    /// one verifiable attestation from a chain-recognized validator is sufficient.
240    ///
241    /// # Errors
242    /// Returns an error if the scheme is unsupported, the public key does not decode, or no
243    /// attestation from a recognized validator verifies.
244    pub fn verify(
245        self,
246        genesis_commitment: Word,
247        validator_keys: &ValidatorKeys,
248    ) -> Result<TransactionEncryptionKey, RpcError> {
249        if self.scheme != SUPPORTED_SCHEME {
250            return Err(RpcError::TransactionEncryptionKeyRejected(format!(
251                "unsupported IES scheme '{}'",
252                self.scheme
253            )));
254        }
255
256        validate_key_id(&self.key_id, "encryption key id")?;
257        if let Some(next) = &self.next_key {
258            validate_key_id(&next.key_id, "next encryption key id")?;
259        }
260
261        let commitment = attestation_commitment(
262            self.scheme,
263            &self.key_id,
264            genesis_commitment,
265            &self.public_key,
266            self.next_key.as_ref(),
267        );
268
269        let recognized = validator_keys.as_keys();
270        let attested = self.attestations.iter().any(|attestation| {
271            recognized.contains(&attestation.validator_key)
272                && attestation.validator_key.verify(commitment, &attestation.signature)
273        });
274        if !attested {
275            return Err(RpcError::TransactionEncryptionKeyRejected(
276                "no attestation from a chain-recognized validator verifies against the key".into(),
277            ));
278        }
279
280        // Parsed after verification: the commitment covers the served bytes, so decoding earlier
281        // would accept a shape the attestation never signed.
282        let public_key = PublicKey::read_from_bytes(&self.public_key)
283            .map_err(|err| RpcError::TransactionEncryptionKeyRejected(err.to_string()))?;
284
285        Ok(TransactionEncryptionKey {
286            scheme: self.scheme,
287            key_id: self.key_id,
288            public_key,
289            genesis_commitment,
290        })
291    }
292}
293
294/// Rejects a served key identifier that is empty or longer than [`MAX_KEY_ID_LEN`].
295///
296/// Mirrors the validator's `validate_key_id` so the client refuses a key the node itself
297/// would never serve.
298fn validate_key_id(key_id: &[u8], field: &str) -> Result<(), RpcError> {
299    if key_id.is_empty() {
300        return Err(RpcError::TransactionEncryptionKeyRejected(format!("{field} is empty")));
301    }
302    if key_id.len() > MAX_KEY_ID_LEN {
303        return Err(RpcError::TransactionEncryptionKeyRejected(format!(
304            "{field} is {} bytes, which exceeds the maximum of {MAX_KEY_ID_LEN}",
305            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)
408/// so that a plaintext blob cannot be passed to submission by mistake, alongside the identifier of
409/// the 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::transaction::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    fn rng() -> ChaCha20Rng {
479        ChaCha20Rng::seed_from_u64(0xface)
480    }
481
482    fn genesis() -> Word {
483        Word::from([1u32, 2, 3, 4])
484    }
485
486    fn tx_id(seed: u32) -> TransactionId {
487        TransactionId::new(
488            Word::from([seed, 0, 0, 0]),
489            Word::from([0, seed, 0, 0]),
490            Word::from([0, 0, seed, 0]),
491            Word::from([0, 0, 0, seed]),
492        )
493    }
494
495    /// Generates a keypair standing in for the validator set's shared key: the public half becomes
496    /// the client's [`TransactionEncryptionKey`], the secret half plays the validator unsealing it.
497    fn key_pair() -> (TransactionEncryptionKey, UnsealingKey) {
498        let secret_key = KeyExchangeKey::with_rng(&mut rng());
499        let key = TransactionEncryptionKey {
500            scheme: SUPPORTED_SCHEME,
501            key_id: TEST_KEY_ID.to_vec(),
502            public_key: secret_key.public_key(),
503            genesis_commitment: genesis(),
504        };
505
506        (key, UnsealingKey::X25519XChaCha20Poly1305(secret_key))
507    }
508
509    /// Unseals the way the validator does: rebuilding the associated data from its own view of the
510    /// key and of the transaction rather than from anything the blob carries.
511    fn unseal(
512        unsealing_key: &UnsealingKey,
513        sealed: &SealedTransactionInputs,
514        key: &TransactionEncryptionKey,
515        tx_id: TransactionId,
516    ) -> Result<Vec<u8>, ()> {
517        let associated_data = key.transaction_inputs_associated_data(tx_id);
518
519        unsealing_key
520            .unseal_bytes_with_associated_data(
521                SealedMessage::read_from_bytes(sealed.ciphertext()).unwrap(),
522                &associated_data,
523            )
524            .map_err(|_| ())
525    }
526
527    fn seal(key: &TransactionEncryptionKey, tx_id: TransactionId) -> SealedTransactionInputs {
528        let associated_data = key.transaction_inputs_associated_data(tx_id);
529        let sealed = key
530            .sealing_key()
531            .seal_bytes_with_associated_data(&mut rng(), b"transaction inputs", &associated_data)
532            .unwrap();
533
534        SealedTransactionInputs {
535            key_id: key.key_id().to_vec(),
536            ciphertext: sealed.to_bytes(),
537        }
538    }
539
540    // ASSOCIATED DATA
541    // --------------------------------------------------------------------------------------------
542
543    /// Pins the transcript byte-for-byte, which also pins *which* fields it binds.
544    ///
545    /// Both sides derive the transcript through their own copy of this function, so a change to it
546    /// would pass every other test in the workspace and surface only as every submission on the
547    /// network failing to authenticate. This vector is the only thing that catches that, so it is
548    /// spelled out here rather than derived from the constants it is checking.
549    #[test]
550    fn associated_data_matches_the_validator_transcript() {
551        let associated_data =
552            transaction_inputs_associated_data(1, &TEST_KEY_ID, genesis(), tx_id(10));
553
554        let mut expected = Vec::new();
555        expected.extend_from_slice(b"MIDEN_TX_INPUT_SEAL_V1");
556        expected.extend_from_slice(&1u32.to_le_bytes());
557        expected.extend_from_slice(&4u32.to_le_bytes());
558        expected.extend_from_slice(&TEST_KEY_ID);
559        expected.extend_from_slice(&genesis().to_bytes());
560        expected.extend_from_slice(&tx_id(10).as_word().to_bytes());
561
562        assert_eq!(associated_data, expected);
563        // 22-byte tag + 4 scheme + 4 length + 4 key id + 32 genesis + 32 transaction id.
564        assert_eq!(associated_data.len(), 98);
565    }
566
567    // SEALING
568    // --------------------------------------------------------------------------------------------
569
570    #[test]
571    fn sealed_inputs_round_trip() {
572        let (key, unsealing_key) = key_pair();
573        let sealed = seal(&key, tx_id(10));
574
575        assert_eq!(sealed.key_id(), key.key_id());
576        let opened = unseal(&unsealing_key, &sealed, &key, tx_id(10)).unwrap();
577        assert_eq!(opened, b"transaction inputs");
578    }
579
580    /// The transaction id binding: a blob captured from one submission must not authenticate when
581    /// replayed onto a different transaction.
582    #[test]
583    fn unsealing_rejects_a_different_transaction() {
584        let (key, unsealing_key) = key_pair();
585        let sealed = seal(&key, tx_id(10));
586
587        assert!(unseal(&unsealing_key, &sealed, &key, tx_id(11)).is_err());
588    }
589
590    /// The key id binding: inputs sealed against a retired key fail to authenticate rather than
591    /// silently decrypting under the current one.
592    #[test]
593    fn unsealing_rejects_a_different_key_id() {
594        let (key, unsealing_key) = key_pair();
595        let sealed = seal(&key, tx_id(10));
596
597        let rotated = TransactionEncryptionKey { key_id: b"other".to_vec(), ..key };
598        assert!(unseal(&unsealing_key, &sealed, &rotated, tx_id(10)).is_err());
599    }
600
601    /// Each seal draws a fresh ephemeral key, so resealing the same inputs for the same transaction
602    /// must not produce a linkable blob. Both seals draw from one RNG, as consecutive submissions
603    /// from a single client do.
604    #[test]
605    fn sealing_the_same_inputs_twice_yields_different_ciphertexts() {
606        let (key, unsealing_key) = key_pair();
607        let associated_data = key.transaction_inputs_associated_data(tx_id(10));
608        let mut rng = rng();
609        let mut seal_once = || {
610            key.sealing_key()
611                .seal_bytes_with_associated_data(&mut rng, b"transaction inputs", &associated_data)
612                .unwrap()
613                .to_bytes()
614        };
615
616        let first = seal_once();
617        let second = seal_once();
618
619        assert_ne!(first, second);
620        for ciphertext in [first, second] {
621            let sealed = SealedTransactionInputs {
622                key_id: key.key_id().to_vec(),
623                ciphertext,
624            };
625            assert_eq!(
626                unseal(&unsealing_key, &sealed, &key, tx_id(10)).unwrap(),
627                b"transaction inputs"
628            );
629        }
630    }
631
632    // ATTESTATION VERIFICATION
633    // --------------------------------------------------------------------------------------------
634
635    /// Builds a response attested by `signer`, the way a validator serves one.
636    fn attested(
637        key: &TransactionEncryptionKey,
638        signer: &ValidatorSigningKey,
639        genesis_commitment: Word,
640    ) -> AttestedTransactionEncryptionKey {
641        attested_with_next(key, signer, genesis_commitment, None)
642    }
643
644    /// Builds a response whose signature also covers `next_key`, so that tests exercising a
645    /// scheduled rotation fail for the reason they name rather than for an invalid signature.
646    fn attested_with_next(
647        key: &TransactionEncryptionKey,
648        signer: &ValidatorSigningKey,
649        genesis_commitment: Word,
650        next_key: Option<NextTransactionEncryptionKey>,
651    ) -> AttestedTransactionEncryptionKey {
652        let public_key = key.public_key().to_bytes();
653        let commitment = attestation_commitment(
654            SUPPORTED_SCHEME,
655            key.key_id(),
656            genesis_commitment,
657            &public_key,
658            next_key.as_ref(),
659        );
660
661        AttestedTransactionEncryptionKey {
662            scheme: SUPPORTED_SCHEME,
663            key_id: key.key_id().to_vec(),
664            public_key,
665            attestations: vec![ValidatorAttestation {
666                validator_key: signer.public_key(),
667                signature: signer.sign(commitment),
668            }],
669            next_key,
670        }
671    }
672
673    #[test]
674    fn verify_accepts_an_attestation_from_a_recognized_validator() {
675        let (key, _) = key_pair();
676        let signer = ValidatorSigningKey::with_rng(&mut rng());
677        let validator_keys = ValidatorKeys::new(vec![signer.public_key()]).unwrap();
678
679        let verified =
680            attested(&key, &signer, genesis()).verify(genesis(), &validator_keys).unwrap();
681
682        assert_eq!(verified, key);
683    }
684
685    #[test]
686    fn verify_rejects_a_validator_absent_from_the_committed_set() {
687        let (key, _) = key_pair();
688        let impostor = ValidatorSigningKey::with_rng(&mut rng());
689        let committed = ValidatorSigningKey::with_rng(&mut ChaCha20Rng::seed_from_u64(7));
690        let validator_keys = ValidatorKeys::new(vec![committed.public_key()]).unwrap();
691
692        assert!(attested(&key, &impostor, genesis()).verify(genesis(), &validator_keys).is_err());
693    }
694
695    /// The whole point of the attestation: a substituted public key must not verify, even though
696    /// the signature itself is genuine.
697    #[test]
698    fn verify_rejects_a_substituted_public_key() {
699        let (key, _) = key_pair();
700        let signer = ValidatorSigningKey::with_rng(&mut rng());
701        let validator_keys = ValidatorKeys::new(vec![signer.public_key()]).unwrap();
702
703        let substitute = KeyExchangeKey::with_rng(&mut ChaCha20Rng::seed_from_u64(99));
704        let mut response = attested(&key, &signer, genesis());
705        response.public_key = substitute.public_key().to_bytes();
706
707        assert!(response.verify(genesis(), &validator_keys).is_err());
708    }
709
710    /// The genesis commitment scopes an attestation to one chain, so the same signed response must
711    /// not verify against a different network.
712    #[test]
713    fn verify_rejects_an_attestation_from_another_network() {
714        let (key, _) = key_pair();
715        let signer = ValidatorSigningKey::with_rng(&mut rng());
716        let validator_keys = ValidatorKeys::new(vec![signer.public_key()]).unwrap();
717
718        let response = attested(&key, &signer, genesis());
719
720        assert!(response.verify(Word::from([9u32, 9, 9, 9]), &validator_keys).is_err());
721    }
722
723    /// A scheduled rotation is covered by the signature, so it cannot be injected or altered by the
724    /// operator relaying the response.
725    #[test]
726    fn verify_rejects_an_injected_next_key() {
727        let (key, _) = key_pair();
728        let signer = ValidatorSigningKey::with_rng(&mut rng());
729        let validator_keys = ValidatorKeys::new(vec![signer.public_key()]).unwrap();
730
731        let mut response = attested(&key, &signer, genesis());
732        response.next_key = Some(NextTransactionEncryptionKey {
733            scheme: SUPPORTED_SCHEME,
734            key_id: vec![1, 2, 3, 4],
735            public_key: KeyExchangeKey::with_rng(&mut ChaCha20Rng::seed_from_u64(11))
736                .public_key()
737                .to_bytes(),
738            rotation_block_num: 100.into(),
739        });
740
741        assert!(response.verify(genesis(), &validator_keys).is_err());
742    }
743
744    #[test]
745    fn verify_rejects_an_unsupported_scheme() {
746        let (key, _) = key_pair();
747        let signer = ValidatorSigningKey::with_rng(&mut rng());
748        let validator_keys = ValidatorKeys::new(vec![signer.public_key()]).unwrap();
749
750        let mut response = attested(&key, &signer, genesis());
751        response.scheme = SUPPORTED_SCHEME + 1;
752
753        assert!(response.verify(genesis(), &validator_keys).is_err());
754    }
755
756    // VALIDATOR PARITY
757    // --------------------------------------------------------------------------------------------
758
759    /// Expected values produced by the validator's own implementation over these exact inputs
760    /// (`miden_validator::attestation_commitment`, `0xMiden/node` rev `5066b383`, identical on
761    /// `next` at `da261511`). The commitment layout is duplicated on both sides, so these vectors
762    /// are what ties them together: if either side changes its layout, this test fails rather
763    /// than every attestation quietly failing to verify. Regenerate by feeding the same inputs to
764    /// the node's function.
765    #[test]
766    fn attestation_commitment_matches_the_validator_implementation() {
767        let genesis = Word::from([101u32, 102, 103, 104]);
768
769        let no_rotation =
770            attestation_commitment(1, b"golden-key-id", genesis, b"golden-public-key", None);
771        assert_eq!(
772            no_rotation.to_hex(),
773            "0x245d1f2d45d4a60d9edd4576691244d6b9ee16fe67635425dc685cd54918a970"
774        );
775
776        let next = NextTransactionEncryptionKey {
777            scheme: 2,
778            key_id: b"next-key-id".to_vec(),
779            public_key: b"next-public-key".to_vec(),
780            rotation_block_num: BlockNumber::from(7u32),
781        };
782        let with_rotation =
783            attestation_commitment(1, b"golden-key-id", genesis, b"golden-public-key", Some(&next));
784        assert_eq!(
785            with_rotation.to_hex(),
786            "0xddfd7907b6a1ea6f294809ff0ed775f270b649ca15b21f88127c8335945e4752"
787        );
788    }
789}