Skip to main content

miden_validator/
private_record.rs

1use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2use chacha20poly1305::{XChaCha20Poly1305, XNonce};
3use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes};
4use golden_ehtdh1::{Ciphertext, Combiner, DecryptionShare, SealingKey};
5use golden_halo2curves::golden_group::Secp256k1GoldenGroup;
6use miden_node_persistence::generated::private_record_file::Record;
7use miden_node_persistence::generated::{PrivateRecordFile, PrivateRecordFileV1};
8use miden_node_persistence::miden_protobuf::{ConversionError, DecodeMessageExt};
9use miden_node_persistence::{PersistenceError, ProtobufValue};
10use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey;
11use miden_protocol::transaction::TransactionId;
12use miden_protocol::utils::serde::{Deserializable, DeserializationError, Serializable};
13use rand_core_06::{CryptoRng, RngCore};
14use zeroize::Zeroizing;
15
16use crate::{GoldenOperatorKey, StorageKeyEpoch};
17
18/// Supported private record formats.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[repr(u32)]
21pub enum PrivateRecordFormatVersion {
22    /// Protobuf transaction effects encrypted with XChaCha20-Poly1305.
23    V1 = 1,
24}
25
26impl PrivateRecordFormatVersion {
27    /// Returns the version number used in storage and encrypted record contexts.
28    pub const fn as_u32(self) -> u32 {
29        self as u32
30    }
31}
32
33impl TryFrom<u32> for PrivateRecordFormatVersion {
34    type Error = PrivateRecordError;
35
36    fn try_from(value: u32) -> Result<Self, Self::Error> {
37        match value {
38            1 => Ok(Self::V1),
39            other => Err(PrivateRecordError::UnsupportedFormat(other)),
40        }
41    }
42}
43
44const CONTEXT_DOMAIN_V1: &[u8] = b"miden-private-record-context-v1";
45pub(crate) const CONTENT_KEY_BYTES: usize = 32;
46const NONCE_BYTES: usize = 24;
47const TAG_BYTES: usize = 16;
48const VALIDATOR_ID_BYTES: usize = 33;
49
50type StorageGroup = Secp256k1GoldenGroup;
51
52/// Identifier for the chain that owns a private record.
53#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
54pub struct PrivateRecordChainId([u8; 32]);
55
56impl PrivateRecordChainId {
57    /// Creates a chain identifier from its canonical bytes.
58    pub const fn new(bytes: [u8; 32]) -> Self {
59        Self(bytes)
60    }
61
62    /// Returns the canonical chain identifier bytes.
63    pub const fn as_bytes(&self) -> &[u8; 32] {
64        &self.0
65    }
66}
67
68/// Global identity of one validator's encrypted record for a transaction.
69#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
70pub struct PrivateRecordId {
71    transaction_id: TransactionId,
72    validator_id: [u8; VALIDATOR_ID_BYTES],
73}
74
75impl PrivateRecordId {
76    /// Creates a record identity from a transaction and validator signing key.
77    pub fn new(transaction_id: TransactionId, validator_public_key: &PublicKey) -> Self {
78        let validator_id = validator_public_key
79            .to_bytes()
80            .try_into()
81            .expect("validator public keys have a fixed canonical length");
82        Self { transaction_id, validator_id }
83    }
84
85    /// Rebuilds a record identity from its canonical fields.
86    pub fn from_parts(
87        transaction_id: TransactionId,
88        validator_id: [u8; VALIDATOR_ID_BYTES],
89    ) -> Result<Self, PrivateRecordError> {
90        PublicKey::read_from_bytes(&validator_id)
91            .map_err(PrivateRecordError::InvalidValidatorId)?;
92        Ok(Self { transaction_id, validator_id })
93    }
94
95    /// Returns the transaction identifier.
96    pub const fn transaction_id(&self) -> TransactionId {
97        self.transaction_id
98    }
99
100    /// Returns the canonical validator signing public key bytes.
101    pub const fn validator_id(&self) -> &[u8; VALIDATOR_ID_BYTES] {
102        &self.validator_id
103    }
104}
105
106/// Values bound to one private record and its Golden decryption shares.
107///
108/// The format version is part of the context, so both encryption layers authenticate it. A record
109/// that holds one format cannot be read as another format.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub struct PrivateRecordContext {
112    chain_id: PrivateRecordChainId,
113    key_epoch: StorageKeyEpoch,
114    transaction_id: TransactionId,
115    format_version: PrivateRecordFormatVersion,
116}
117
118impl PrivateRecordContext {
119    /// Creates a context for a new record in the current format.
120    pub const fn new(
121        chain_id: PrivateRecordChainId,
122        key_epoch: StorageKeyEpoch,
123        transaction_id: TransactionId,
124    ) -> Self {
125        Self::with_format_version(
126            chain_id,
127            key_epoch,
128            transaction_id,
129            PrivateRecordFormatVersion::V1,
130        )
131    }
132
133    /// Creates a context for a stored record in the given format.
134    ///
135    /// The caller must supply the version that the record was sealed with. A different version
136    /// produces a context that fails to authenticate the record.
137    pub const fn with_format_version(
138        chain_id: PrivateRecordChainId,
139        key_epoch: StorageKeyEpoch,
140        transaction_id: TransactionId,
141        format_version: PrivateRecordFormatVersion,
142    ) -> Self {
143        Self {
144            chain_id,
145            key_epoch,
146            transaction_id,
147            format_version,
148        }
149    }
150
151    /// Returns the chain identifier.
152    pub const fn chain_id(&self) -> PrivateRecordChainId {
153        self.chain_id
154    }
155
156    /// Returns the storage key epoch.
157    pub const fn key_epoch(&self) -> StorageKeyEpoch {
158        self.key_epoch
159    }
160
161    /// Returns the transaction identifier.
162    pub const fn transaction_id(&self) -> TransactionId {
163        self.transaction_id
164    }
165
166    /// Returns the record format version.
167    pub const fn format_version(&self) -> PrivateRecordFormatVersion {
168        self.format_version
169    }
170
171    /// Returns the canonical context used by the record cipher and Golden.
172    pub fn to_bytes(self) -> Vec<u8> {
173        let transaction_id = self.transaction_id.to_bytes();
174        let mut context = Vec::with_capacity(CONTEXT_DOMAIN_V1.len() + 3 * 32 + size_of::<u32>());
175        context.extend_from_slice(CONTEXT_DOMAIN_V1);
176        context.extend_from_slice(self.chain_id.as_bytes());
177        context.extend_from_slice(self.key_epoch.as_bytes());
178        context.extend_from_slice(&transaction_id);
179        context.extend_from_slice(&self.format_version.as_u32().to_be_bytes());
180        context
181    }
182}
183
184/// Exact record values that an operator must approve before issuing a share.
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct PrivateRecordShareRequest {
187    record_id: PrivateRecordId,
188    key_epoch: StorageKeyEpoch,
189    context: Vec<u8>,
190}
191
192impl PrivateRecordShareRequest {
193    /// Creates a request for one transaction, epoch, and canonical context.
194    pub fn new(record_id: PrivateRecordId, key_epoch: StorageKeyEpoch, context: Vec<u8>) -> Self {
195        Self { record_id, key_epoch, context }
196    }
197
198    /// Creates a request from one checked stored record.
199    pub fn for_record(record: &StoredPrivateRecord) -> Self {
200        let context = record.context();
201        Self::new(record.record_id(), context.key_epoch(), context.to_bytes())
202    }
203
204    /// Returns the requested transaction identifier.
205    pub const fn transaction_id(&self) -> TransactionId {
206        self.record_id.transaction_id()
207    }
208
209    /// Returns the requested private-record identifier.
210    pub const fn record_id(&self) -> PrivateRecordId {
211        self.record_id
212    }
213
214    /// Returns the requested storage key epoch.
215    pub const fn key_epoch(&self) -> StorageKeyEpoch {
216        self.key_epoch
217    }
218
219    /// Returns the exact requested decryption context.
220    pub fn context(&self) -> &[u8] {
221        &self.context
222    }
223}
224
225/// Public Golden key used to seal private records for one epoch.
226#[derive(Clone, Debug)]
227pub struct PrivateRecordSealer {
228    key_epoch: StorageKeyEpoch,
229    setup_context_id: [u8; 32],
230    sealing_key: SealingKey<StorageGroup>,
231}
232
233impl PrivateRecordSealer {
234    /// Creates a sealer from a validated operator key without copying its secret share.
235    pub fn from_operator_key(operator_key: &GoldenOperatorKey) -> Self {
236        Self {
237            key_epoch: operator_key.key_epoch(),
238            setup_context_id: operator_key.setup_context_id(),
239            sealing_key: operator_key.sealing_key().clone(),
240        }
241    }
242
243    /// Returns the storage-key epoch used to seal records.
244    pub fn key_epoch(&self) -> StorageKeyEpoch {
245        self.key_epoch
246    }
247
248    /// Encrypts a record and wraps only its fresh content key with Golden.
249    pub fn seal<R: RngCore + CryptoRng>(
250        &self,
251        rng: &mut R,
252        record_id: PrivateRecordId,
253        context: PrivateRecordContext,
254        plaintext: &[u8],
255    ) -> Result<StoredPrivateRecord, PrivateRecordError> {
256        if context.key_epoch() != self.key_epoch {
257            return Err(PrivateRecordError::KeyEpochMismatch);
258        }
259        if record_id.transaction_id() != context.transaction_id() {
260            return Err(PrivateRecordError::RecordIdMismatch);
261        }
262
263        let context_bytes = context.to_bytes();
264        let mut content_key = Zeroizing::new([0u8; CONTENT_KEY_BYTES]);
265        let mut nonce = [0u8; NONCE_BYTES];
266        rng.fill_bytes(content_key.as_mut());
267        rng.fill_bytes(&mut nonce);
268
269        let cipher = XChaCha20Poly1305::new_from_slice(content_key.as_ref())
270            .map_err(|_| PrivateRecordError::RecordEncryption)?;
271        let encrypted_record = cipher
272            .encrypt(&XNonce::from(nonce), Payload { msg: plaintext, aad: &context_bytes })
273            .map_err(|_| PrivateRecordError::RecordEncryption)?;
274        let encrypted_record_key = self
275            .sealing_key
276            .seal_bytes_with_associated_data(rng, content_key.as_ref(), &context_bytes)
277            .map_err(PrivateRecordError::ContentKeyEncryption)?;
278        if encrypted_record_key.encrypted_payload.len() != CONTENT_KEY_BYTES {
279            return Err(PrivateRecordError::InvalidEncryptedRecordKey);
280        }
281
282        Ok(StoredPrivateRecord {
283            record_id,
284            context,
285            setup_context_id: self.setup_context_id,
286            nonce,
287            encrypted_record,
288            encrypted_record_key: to_wire_bytes(&encrypted_record_key),
289        })
290    }
291}
292
293/// Public Golden setup used to verify shares and open private records.
294#[derive(Clone, Debug)]
295pub struct PrivateRecordCombiner {
296    key_epoch: StorageKeyEpoch,
297    setup_context_id: [u8; 32],
298    combiner: Combiner<StorageGroup>,
299}
300
301impl PrivateRecordCombiner {
302    /// Creates a combiner from one validated operator key without copying its secret share.
303    pub fn from_operator_key(operator_key: &GoldenOperatorKey) -> Result<Self, PrivateRecordError> {
304        let combiner = Combiner::new(
305            operator_key.public_key_set().clone(),
306            operator_key.setup_context().clone(),
307        )
308        .map_err(PrivateRecordError::InvalidCombinerSetup)?;
309        Ok(Self {
310            key_epoch: operator_key.key_epoch(),
311            setup_context_id: operator_key.setup_context_id(),
312            combiner,
313        })
314    }
315
316    /// Verifies exact threshold share bytes and opens the private record.
317    pub fn open(
318        &self,
319        request: &PrivateRecordShareRequest,
320        record: &StoredPrivateRecord,
321        share_bytes: &[Vec<u8>],
322    ) -> Result<Zeroizing<Vec<u8>>, PrivateRecordError> {
323        record.validate_share_request(request, self.key_epoch, self.setup_context_id)?;
324        let ciphertext = record.decode_encrypted_record_key()?;
325        let shares = share_bytes
326            .iter()
327            .map(|bytes| {
328                from_wire_bytes::<DecryptionShare<StorageGroup>>(bytes)
329                    .map_err(PrivateRecordError::InvalidDecryptionShare)
330            })
331            .collect::<Result<Vec<_>, _>>()?;
332        let context = request.context();
333        let content_key = Zeroizing::new(
334            self.combiner
335                .combine_exact_with_associated_data(&ciphertext, context, context, &shares)
336                .map_err(PrivateRecordError::ShareCombination)?,
337        );
338        if content_key.len() != CONTENT_KEY_BYTES {
339            return Err(PrivateRecordError::InvalidEncryptedRecordKey);
340        }
341
342        let cipher = XChaCha20Poly1305::new_from_slice(content_key.as_ref())
343            .map_err(|_| PrivateRecordError::RecordDecryption)?;
344        cipher
345            .decrypt(
346                &XNonce::from(*record.nonce()),
347                Payload {
348                    msg: record.encrypted_record(),
349                    aad: context,
350                },
351            )
352            .map(Zeroizing::new)
353            .map_err(|_| PrivateRecordError::RecordDecryption)
354    }
355}
356
357#[cfg(test)]
358pub(crate) fn test_private_record_sealer(
359    key_epoch: StorageKeyEpoch,
360    setup_context_id: [u8; 32],
361) -> PrivateRecordSealer {
362    use golden_core::{GoldenGroup, GoldenScalar};
363    use golden_halo2curves::golden_group::Secp256k1Scalar;
364
365    let scalar = Secp256k1Scalar::from_u64(11).expect("test scalar is valid");
366    PrivateRecordSealer {
367        key_epoch,
368        setup_context_id,
369        sealing_key: SealingKey::new(StorageGroup::mul_generator(&scalar))
370            .expect("test sealing key is valid"),
371    }
372}
373
374/// Database fields for one versioned private record.
375#[derive(Clone, Debug, Eq, PartialEq)]
376pub struct PrivateRecordStorageFields {
377    /// Operational identity used to find and export the record.
378    pub record_id: PrivateRecordId,
379    /// Values bound into both encryption layers, including the record format version.
380    pub context: PrivateRecordContext,
381    /// Golden setup context identifier.
382    pub setup_context_id: [u8; 32],
383    /// Public record cipher nonce.
384    pub nonce: Vec<u8>,
385    /// Authenticated record ciphertext.
386    pub encrypted_record: Vec<u8>,
387    /// Canonical Golden ciphertext for the content key.
388    pub encrypted_record_key: Vec<u8>,
389}
390
391/// Versioned encrypted private record stored by the validator.
392#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct StoredPrivateRecord {
394    record_id: PrivateRecordId,
395    context: PrivateRecordContext,
396    setup_context_id: [u8; 32],
397    nonce: [u8; NONCE_BYTES],
398    encrypted_record: Vec<u8>,
399    encrypted_record_key: Vec<u8>,
400}
401
402impl StoredPrivateRecord {
403    /// Rebuilds a record after validating its stored fields.
404    pub fn from_storage_fields(
405        fields: PrivateRecordStorageFields,
406    ) -> Result<Self, PrivateRecordError> {
407        let nonce = fields.nonce.try_into().map_err(|nonce: Vec<u8>| {
408            PrivateRecordError::InvalidNonceLength { actual: nonce.len() }
409        })?;
410        if fields.encrypted_record.len() < TAG_BYTES {
411            return Err(PrivateRecordError::InvalidRecordCiphertext);
412        }
413        if fields.record_id.transaction_id() != fields.context.transaction_id() {
414            return Err(PrivateRecordError::RecordIdMismatch);
415        }
416
417        let record = Self {
418            record_id: fields.record_id,
419            context: fields.context,
420            setup_context_id: fields.setup_context_id,
421            nonce,
422            encrypted_record: fields.encrypted_record,
423            encrypted_record_key: fields.encrypted_record_key,
424        };
425        record.verify_encrypted_record_key()?;
426        Ok(record)
427    }
428
429    /// Splits a checked record into the fields stored by the database.
430    pub fn into_storage_fields(self) -> PrivateRecordStorageFields {
431        PrivateRecordStorageFields {
432            record_id: self.record_id,
433            context: self.context,
434            setup_context_id: self.setup_context_id,
435            nonce: self.nonce.to_vec(),
436            encrypted_record: self.encrypted_record,
437            encrypted_record_key: self.encrypted_record_key,
438        }
439    }
440
441    /// Returns the operational identity used to find and export this record.
442    pub const fn record_id(&self) -> PrivateRecordId {
443        self.record_id
444    }
445
446    /// Returns the values bound into both encryption layers.
447    pub const fn context(&self) -> PrivateRecordContext {
448        self.context
449    }
450
451    /// Returns the Golden setup context identifier.
452    pub const fn setup_context_id(&self) -> &[u8; 32] {
453        &self.setup_context_id
454    }
455
456    /// Returns the public record cipher nonce.
457    pub const fn nonce(&self) -> &[u8; NONCE_BYTES] {
458        &self.nonce
459    }
460
461    /// Returns the authenticated record ciphertext.
462    pub fn encrypted_record(&self) -> &[u8] {
463        &self.encrypted_record
464    }
465
466    /// Returns the canonical Golden ciphertext for the content key.
467    pub fn encrypted_record_key(&self) -> &[u8] {
468        &self.encrypted_record_key
469    }
470
471    /// Checks the canonical Golden content key ciphertext and its context.
472    pub fn verify_encrypted_record_key(&self) -> Result<(), PrivateRecordError> {
473        self.decode_encrypted_record_key().map(drop)
474    }
475
476    /// Decodes and checks the canonical Golden content key ciphertext.
477    pub(crate) fn decode_encrypted_record_key(
478        &self,
479    ) -> Result<Ciphertext<StorageGroup>, PrivateRecordError> {
480        let ciphertext: Ciphertext<StorageGroup> = from_wire_bytes(&self.encrypted_record_key)
481            .map_err(PrivateRecordError::InvalidGoldenEncoding)?;
482        if ciphertext.encrypted_payload.len() != CONTENT_KEY_BYTES {
483            return Err(PrivateRecordError::InvalidEncryptedRecordKey);
484        }
485        ciphertext
486            .verify_with_associated_data(&self.context.to_bytes())
487            .map_err(PrivateRecordError::InvalidGoldenEncoding)?;
488        Ok(ciphertext)
489    }
490
491    /// Checks that a share request, stored record, and active Golden key name the same values.
492    pub(crate) fn validate_share_request(
493        &self,
494        request: &PrivateRecordShareRequest,
495        key_epoch: StorageKeyEpoch,
496        setup_context_id: [u8; 32],
497    ) -> Result<(), PrivateRecordError> {
498        if request.record_id() != self.record_id {
499            return Err(PrivateRecordError::RecordIdMismatch);
500        }
501        if request.key_epoch() != self.context.key_epoch() || request.key_epoch() != key_epoch {
502            return Err(PrivateRecordError::KeyEpochMismatch);
503        }
504        if self.setup_context_id != setup_context_id {
505            return Err(PrivateRecordError::SetupContextMismatch);
506        }
507        if request.context() != self.context.to_bytes() {
508            return Err(PrivateRecordError::DecryptionContextMismatch);
509        }
510        Ok(())
511    }
512}
513
514impl ProtobufValue for StoredPrivateRecord {
515    type Message = PrivateRecordFile;
516
517    fn to_proto(&self) -> Self::Message {
518        PrivateRecordFile {
519            record: Some(Record::V1(PrivateRecordFileV1 {
520                record_format_version: self.context.format_version().as_u32(),
521                chain_id: self.context.chain_id().as_bytes().to_vec(),
522                key_epoch: self.context.key_epoch().as_bytes().to_vec(),
523                transaction_id: Some(self.context.transaction_id().into()),
524                validator_id: self.record_id.validator_id().to_vec(),
525                setup_context_id: self.setup_context_id.to_vec(),
526                nonce: self.nonce.to_vec(),
527                encrypted_record: self.encrypted_record.clone(),
528                encrypted_record_key: self.encrypted_record_key.clone(),
529            })),
530        }
531    }
532
533    fn from_proto(message: Self::Message) -> Result<Self, PersistenceError> {
534        fn fixed_bytes<const N: usize>(
535            bytes: Vec<u8>,
536            field: &str,
537        ) -> Result<[u8; N], ConversionError> {
538            bytes.try_into().map_err(|bytes: Vec<u8>| {
539                ConversionError::message(format!(
540                    "private record {field} has {} bytes, expected {N}",
541                    bytes.len(),
542                ))
543            })
544        }
545
546        let Some(Record::V1(message)) = message.record else {
547            return Err(ConversionError::message("private record file payload is missing").into());
548        };
549
550        let format_version = PrivateRecordFormatVersion::try_from(message.record_format_version)
551            .map_err(ConversionError::new)?;
552        let transaction_id = message
553            .transaction_id
554            .ok_or_else(|| ConversionError::message("private record transaction id is missing"))?
555            .decode_and_verify()?;
556        let record_id = PrivateRecordId::from_parts(
557            transaction_id,
558            fixed_bytes(message.validator_id, "validator id")?,
559        )
560        .map_err(ConversionError::new)?;
561        Self::from_storage_fields(PrivateRecordStorageFields {
562            record_id,
563            context: PrivateRecordContext::with_format_version(
564                PrivateRecordChainId::new(fixed_bytes(message.chain_id, "chain id")?),
565                StorageKeyEpoch::new(fixed_bytes(message.key_epoch, "key epoch")?),
566                transaction_id,
567                format_version,
568            ),
569            setup_context_id: fixed_bytes(message.setup_context_id, "setup context id")?,
570            nonce: message.nonce,
571            encrypted_record: message.encrypted_record,
572            encrypted_record_key: message.encrypted_record_key,
573        })
574        .map_err(|error| ConversionError::new(error).into())
575    }
576}
577
578/// Error raised while sealing or reading a private record.
579#[derive(Debug, thiserror::Error)]
580pub enum PrivateRecordError {
581    /// The record, request, and active storage key name different epochs.
582    #[error("private record key epoch does not match")]
583    KeyEpochMismatch,
584    /// The share request names a different private record.
585    #[error("private record share request names a different record")]
586    RecordIdMismatch,
587    /// The validator identity is not a canonical signing public key.
588    #[error("private record validator id is not a canonical signing public key")]
589    InvalidValidatorId(#[source] DeserializationError),
590    /// The operator and record name different Golden setups.
591    #[error("private record Golden setup does not match the operator")]
592    SetupContextMismatch,
593    /// The request does not carry the record's exact canonical context.
594    #[error("private record decryption context does not match the record")]
595    DecryptionContextMismatch,
596    /// The authenticated record cipher failed.
597    #[error("failed to encrypt private record")]
598    RecordEncryption,
599    /// Golden failed to seal the content key.
600    #[error("failed to encrypt private record content key")]
601    ContentKeyEncryption(#[source] golden_ehtdh1::Error),
602    /// The canonical Golden value could not be decoded or verified.
603    #[error("invalid Golden content key ciphertext")]
604    InvalidGoldenEncoding(#[source] golden_ehtdh1::Error),
605    /// The public Golden setup cannot create a combiner.
606    #[error("invalid Golden combiner setup")]
607    InvalidCombinerSetup(#[source] golden_ehtdh1::Error),
608    /// A canonical decryption share could not be decoded.
609    #[error("invalid Golden decryption share")]
610    InvalidDecryptionShare(#[source] golden_ehtdh1::Error),
611    /// Golden could not issue a decryption share.
612    #[error("failed to issue Golden decryption share")]
613    ShareGeneration(#[source] golden_ehtdh1::Error),
614    /// Golden rejected the provided share set.
615    #[error("failed to combine Golden decryption shares")]
616    ShareCombination(#[source] golden_ehtdh1::CombineError),
617    /// The Golden ciphertext does not wrap a content key of the expected size.
618    #[error("Golden ciphertext has the wrong content key size")]
619    InvalidEncryptedRecordKey,
620    /// The stored record format is not supported.
621    #[error("unsupported private record format version {0}")]
622    UnsupportedFormat(u32),
623    /// The stored nonce has the wrong size.
624    #[error("private record nonce has {actual} bytes, expected {NONCE_BYTES}")]
625    InvalidNonceLength { actual: usize },
626    /// The stored record ciphertext cannot contain an authentication tag.
627    #[error("private record ciphertext is shorter than its authentication tag")]
628    InvalidRecordCiphertext,
629    /// The authenticated record cipher rejected the content key, context, or ciphertext.
630    #[error("failed to decrypt private record")]
631    RecordDecryption,
632}
633
634#[cfg(test)]
635mod tests {
636    use golden_ehtdh1::DecryptionShare;
637    use miden_protocol::Word;
638    use miden_protocol::account::auth::AuthScheme;
639    use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey;
640    use miden_protocol::transaction::TransactionInputs;
641    use miden_protocol::utils::serde::Deserializable;
642    use miden_testing::{Auth, MockChainBuilder};
643    use rand_chacha_03::ChaCha20Rng;
644    use rand_chacha_03::rand_core::SeedableRng;
645
646    use super::*;
647    use crate::storage_key::tests::operator_keys;
648
649    const CHAIN_ID: PrivateRecordChainId = PrivateRecordChainId::new([1; 32]);
650    const EPOCH: StorageKeyEpoch = StorageKeyEpoch::new([2; 32]);
651
652    fn transaction_id() -> TransactionId {
653        TransactionId::from_raw(Word::from([4u32, 5, 6, 7]))
654    }
655
656    fn record_id(transaction_id: TransactionId) -> PrivateRecordId {
657        record_id_for_validator(transaction_id, 7)
658    }
659
660    fn record_id_for_validator(
661        transaction_id: TransactionId,
662        validator_seed: u8,
663    ) -> PrivateRecordId {
664        let signer = SigningKey::read_from_bytes(&[validator_seed; 32]).unwrap();
665        PrivateRecordId::new(transaction_id, &signer.public_key())
666    }
667
668    fn context() -> PrivateRecordContext {
669        PrivateRecordContext::new(CHAIN_ID, EPOCH, transaction_id())
670    }
671
672    fn sealer() -> PrivateRecordSealer {
673        test_private_record_sealer(EPOCH, [8; 32])
674    }
675
676    fn threshold_record(
677        operator_key: &GoldenOperatorKey,
678        transaction_id: TransactionId,
679        seed: u8,
680        plaintext: &[u8],
681    ) -> StoredPrivateRecord {
682        let context = PrivateRecordContext::new(CHAIN_ID, operator_key.key_epoch(), transaction_id);
683        let mut rng = ChaCha20Rng::from_seed([seed; 32]);
684        PrivateRecordSealer::from_operator_key(operator_key)
685            .seal(&mut rng, record_id(transaction_id), context, plaintext)
686            .unwrap()
687    }
688
689    fn issue_share(
690        operator_key: &GoldenOperatorKey,
691        request: &PrivateRecordShareRequest,
692        record: &StoredPrivateRecord,
693        seed: u8,
694    ) -> Vec<u8> {
695        let mut rng = ChaCha20Rng::from_seed([seed; 32]);
696        operator_key.issue_private_record_share(&mut rng, request, record).unwrap()
697    }
698
699    fn transaction_inputs() -> TransactionInputs {
700        let mut builder = MockChainBuilder::new();
701        let account = builder
702            .add_existing_wallet(Auth::BasicAuth {
703                auth_scheme: AuthScheme::Falcon512Poseidon2,
704            })
705            .unwrap();
706        builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap()
707    }
708
709    #[test]
710    fn context_has_one_fixed_canonical_encoding() {
711        let bytes = context().to_bytes();
712        let transaction_id = transaction_id().to_bytes();
713
714        assert_eq!(&bytes[..CONTEXT_DOMAIN_V1.len()], CONTEXT_DOMAIN_V1);
715        assert_eq!(
716            &bytes[CONTEXT_DOMAIN_V1.len()..CONTEXT_DOMAIN_V1.len() + 32],
717            CHAIN_ID.as_bytes(),
718        );
719        assert_eq!(
720            &bytes[CONTEXT_DOMAIN_V1.len() + 32..CONTEXT_DOMAIN_V1.len() + 64],
721            EPOCH.as_bytes(),
722        );
723        assert_eq!(
724            &bytes[CONTEXT_DOMAIN_V1.len() + 64..CONTEXT_DOMAIN_V1.len() + 96],
725            transaction_id,
726        );
727        assert_eq!(&bytes[CONTEXT_DOMAIN_V1.len() + 96..], &1_u32.to_be_bytes());
728    }
729
730    #[test]
731    fn seal_uses_a_fresh_key_and_nonce_and_wraps_only_the_key() {
732        let plaintext = b"private transaction inputs";
733        let mut expected_rng = ChaCha20Rng::from_seed([9; 32]);
734        let mut expected_content_key = Zeroizing::new([0u8; CONTENT_KEY_BYTES]);
735        let mut expected_nonce = [0u8; NONCE_BYTES];
736        expected_rng.fill_bytes(expected_content_key.as_mut());
737        expected_rng.fill_bytes(&mut expected_nonce);
738
739        let mut rng = ChaCha20Rng::from_seed([9; 32]);
740        let first = sealer()
741            .seal(&mut rng, record_id(transaction_id()), context(), plaintext)
742            .unwrap();
743        let second = sealer()
744            .seal(&mut rng, record_id(transaction_id()), context(), plaintext)
745            .unwrap();
746
747        assert_eq!(first.nonce(), &expected_nonce);
748        assert_ne!(first.nonce(), second.nonce());
749        assert_ne!(first.encrypted_record(), second.encrypted_record());
750        assert_ne!(first.encrypted_record_key(), second.encrypted_record_key());
751        assert_eq!(first.encrypted_record().len(), plaintext.len() + TAG_BYTES);
752        assert_eq!(first.context(), context());
753        assert_eq!(first.setup_context_id(), &[8; 32]);
754
755        let cipher = XChaCha20Poly1305::new_from_slice(expected_content_key.as_ref()).unwrap();
756        let opened = cipher
757            .decrypt(
758                &XNonce::from(*first.nonce()),
759                Payload {
760                    msg: first.encrypted_record(),
761                    aad: &context().to_bytes(),
762                },
763            )
764            .unwrap();
765        assert_eq!(opened, plaintext);
766        assert!(
767            cipher
768                .decrypt(
769                    &XNonce::from(*first.nonce()),
770                    Payload {
771                        msg: first.encrypted_record(),
772                        aad: b"wrong context",
773                    },
774                )
775                .is_err(),
776        );
777
778        let encrypted_record_key = first.decode_encrypted_record_key().unwrap();
779        assert_eq!(encrypted_record_key.encrypted_payload.len(), CONTENT_KEY_BYTES);
780        assert_eq!(encrypted_record_key.associated_data(), context().to_bytes());
781    }
782
783    #[test]
784    fn storage_fields_round_trip() {
785        let mut rng = ChaCha20Rng::from_seed([12; 32]);
786        let expected = sealer()
787            .seal(&mut rng, record_id(transaction_id()), context(), b"record")
788            .unwrap();
789
790        let actual =
791            StoredPrivateRecord::from_storage_fields(expected.clone().into_storage_fields())
792                .unwrap();
793
794        assert_eq!(actual, expected);
795        let bytes = miden_node_persistence::encode(&expected);
796        assert_eq!(
797            miden_node_persistence::decode::<StoredPrivateRecord>(&bytes).unwrap(),
798            expected
799        );
800    }
801
802    #[test]
803    fn private_record_file_round_trips_with_versioned_payload() {
804        let mut rng = ChaCha20Rng::from_seed([12; 32]);
805        let record = sealer()
806            .seal(&mut rng, record_id(transaction_id()), context(), b"record")
807            .unwrap();
808
809        let bytes = miden_node_persistence::encode(&record);
810        assert_eq!(bytes.first().copied(), Some(0x0a));
811        assert_eq!(miden_node_persistence::decode::<StoredPrivateRecord>(&bytes).unwrap(), record);
812    }
813
814    #[test]
815    fn private_record_bundle_rejects_invalid_fields() {
816        use miden_node_persistence::ProtobufValue;
817        use miden_node_persistence::prost::Message;
818        let mut rng = ChaCha20Rng::from_seed([12; 32]);
819        let record = sealer()
820            .seal(&mut rng, record_id(transaction_id()), context(), b"record")
821            .unwrap();
822        assert!(miden_node_persistence::decode::<StoredPrivateRecord>(&[]).is_err());
823        assert!(miden_node_persistence::decode::<StoredPrivateRecord>(&[0xff]).is_err());
824        let Some(Record::V1(valid)) = record.to_proto().record else {
825            unreachable!("the codec writes a v1 private record file")
826        };
827        let mutations: &[fn(&mut PrivateRecordFileV1)] = &[
828            |message| message.chain_id.pop().map(drop).unwrap(),
829            |message| message.key_epoch.clear(),
830            |message| message.transaction_id = None,
831            |message| {
832                message.transaction_id =
833                    Some(miden_node_proto::generated::transaction::TransactionId::default());
834            },
835            |message| {
836                message.transaction_id =
837                    Some(TransactionId::from_raw(Word::from([99u32; 4])).into());
838            },
839            |message| message.validator_id.fill(0),
840            |message| message.validator_id.clear(),
841            |message| message.setup_context_id.clear(),
842            |message| message.nonce.clear(),
843            |message| message.encrypted_record.truncate(TAG_BYTES - 1),
844            |message| message.encrypted_record_key.pop().map(drop).unwrap(),
845            |message| message.chain_id[0] ^= 1,
846            |message| message.key_epoch[0] ^= 1,
847        ];
848        for mutate in mutations {
849            let mut message = valid.clone();
850            mutate(&mut message);
851            let message = PrivateRecordFile { record: Some(Record::V1(message)) };
852            assert!(
853                miden_node_persistence::decode::<StoredPrivateRecord>(&message.encode_to_vec())
854                    .is_err()
855            );
856        }
857    }
858
859    #[test]
860    fn private_record_file_rejects_missing_payload_and_unsupported_record_format() {
861        use miden_node_persistence::ProtobufValue;
862        use miden_node_persistence::prost::Message;
863        let mut rng = ChaCha20Rng::from_seed([13; 32]);
864        let record = sealer()
865            .seal(&mut rng, record_id(transaction_id()), context(), b"record")
866            .unwrap();
867        assert!(miden_node_persistence::decode::<StoredPrivateRecord>(&[]).is_err());
868        assert!(miden_node_persistence::decode::<StoredPrivateRecord>(&[0x12, 0]).is_err());
869        for version in [0, 2, u32::MAX] {
870            let mut message = record.to_proto();
871            let Some(Record::V1(payload)) = message.record.as_mut() else {
872                unreachable!("the codec writes a v1 private record file")
873            };
874            payload.record_format_version = version;
875            assert!(
876                miden_node_persistence::decode::<StoredPrivateRecord>(&message.encode_to_vec())
877                    .is_err()
878            );
879        }
880    }
881
882    #[test]
883    fn storage_fields_reject_invalid_metadata() {
884        let mut rng = ChaCha20Rng::from_seed([13; 32]);
885        let record = sealer()
886            .seal(&mut rng, record_id(transaction_id()), context(), b"record")
887            .unwrap();
888
889        let mut wrong_nonce = record.clone().into_storage_fields();
890        wrong_nonce.nonce.pop();
891        assert!(matches!(
892            StoredPrivateRecord::from_storage_fields(wrong_nonce),
893            Err(PrivateRecordError::InvalidNonceLength { actual: 23 }),
894        ));
895
896        let mut short_ciphertext = record.into_storage_fields();
897        short_ciphertext.encrypted_record.truncate(TAG_BYTES - 1);
898        assert!(matches!(
899            StoredPrivateRecord::from_storage_fields(short_ciphertext),
900            Err(PrivateRecordError::InvalidRecordCiphertext),
901        ));
902    }
903
904    #[test]
905    fn seal_rejects_a_different_epoch() {
906        let mut rng = ChaCha20Rng::from_seed([11; 32]);
907        let wrong_context =
908            PrivateRecordContext::new(CHAIN_ID, StorageKeyEpoch::new([99; 32]), transaction_id());
909
910        assert!(matches!(
911            sealer().seal(&mut rng, record_id(transaction_id()), wrong_context, b"record",),
912            Err(PrivateRecordError::KeyEpochMismatch),
913        ));
914    }
915
916    #[test]
917    fn independent_writers_with_same_inputs_produce_distinct_ciphertexts() {
918        let operator_keys = operator_keys();
919        let transaction_id = transaction_id();
920        let plaintext = transaction_inputs().to_bytes();
921        let context =
922            PrivateRecordContext::new(CHAIN_ID, operator_keys[0].key_epoch(), transaction_id);
923        let first_record_id = record_id_for_validator(transaction_id, 7);
924        let second_record_id = record_id_for_validator(transaction_id, 8);
925        let mut first_rng = ChaCha20Rng::from_seed([31; 32]);
926        let mut second_rng = ChaCha20Rng::from_seed([32; 32]);
927
928        assert_eq!(operator_keys[0].sealing_key(), operator_keys[1].sealing_key());
929        assert_ne!(first_record_id, second_record_id);
930
931        let first = PrivateRecordSealer::from_operator_key(&operator_keys[0])
932            .seal(&mut first_rng, first_record_id, context, &plaintext)
933            .unwrap();
934        let second = PrivateRecordSealer::from_operator_key(&operator_keys[1])
935            .seal(&mut second_rng, second_record_id, context, &plaintext)
936            .unwrap();
937
938        assert_eq!(first.context(), second.context());
939        assert_eq!(
940            first.decode_encrypted_record_key().unwrap().associated_data(),
941            second.decode_encrypted_record_key().unwrap().associated_data(),
942        );
943        assert_ne!(first.nonce(), second.nonce());
944        assert_ne!(first.encrypted_record(), second.encrypted_record());
945        assert_ne!(first.encrypted_record_key(), second.encrypted_record_key());
946    }
947
948    #[test]
949    fn two_of_three_canonical_shares_open_the_record() {
950        let operator_keys = operator_keys();
951        let inputs = transaction_inputs();
952        let plaintext = inputs.to_bytes();
953        let original = threshold_record(&operator_keys[0], transaction_id(), 20, &plaintext);
954        let record: StoredPrivateRecord =
955            miden_node_persistence::decode(&miden_node_persistence::encode(&original)).unwrap();
956        let request = PrivateRecordShareRequest::for_record(&record);
957
958        let shares = [
959            issue_share(&operator_keys[0], &request, &record, 22),
960            issue_share(&operator_keys[1], &request, &record, 23),
961        ];
962        for bytes in &shares {
963            let share = from_wire_bytes::<DecryptionShare<StorageGroup>>(bytes).unwrap();
964            assert_eq!(to_wire_bytes(&share), *bytes);
965        }
966
967        let opened = PrivateRecordCombiner::from_operator_key(&operator_keys[2])
968            .unwrap()
969            .open(&request, &record, &shares)
970            .unwrap();
971        assert_eq!(TransactionInputs::read_from_bytes(&opened).unwrap(), inputs);
972    }
973
974    #[test]
975    fn shares_for_independently_sealed_records_do_not_combine() {
976        let operator_keys = operator_keys();
977        let plaintext = transaction_inputs().to_bytes();
978        let first_record = threshold_record(&operator_keys[0], transaction_id(), 31, &plaintext);
979        let second_record = threshold_record(&operator_keys[0], transaction_id(), 32, &plaintext);
980        assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key(),);
981
982        let first_request = PrivateRecordShareRequest::for_record(&first_record);
983        let second_request = PrivateRecordShareRequest::for_record(&second_record);
984        assert_eq!(first_request, second_request);
985        let shares = [
986            issue_share(&operator_keys[0], &first_request, &first_record, 33),
987            issue_share(&operator_keys[1], &second_request, &second_record, 34),
988        ];
989
990        let result = PrivateRecordCombiner::from_operator_key(&operator_keys[2]).unwrap().open(
991            &first_request,
992            &first_record,
993            &shares,
994        );
995        assert!(matches!(result, Err(PrivateRecordError::ShareCombination(_))));
996    }
997
998    #[test]
999    fn share_requests_bind_record_epoch_context_and_setup() {
1000        let operator_keys = operator_keys();
1001        let record = threshold_record(&operator_keys[0], transaction_id(), 24, b"record");
1002        let request = PrivateRecordShareRequest::for_record(&record);
1003
1004        let wrong_transaction = PrivateRecordShareRequest::new(
1005            record_id(TransactionId::from_raw(Word::from([99u32; 4]))),
1006            request.key_epoch(),
1007            request.context().to_vec(),
1008        );
1009        let mut rng = ChaCha20Rng::from_seed([25; 32]);
1010        assert!(matches!(
1011            operator_keys[0].issue_private_record_share(&mut rng, &wrong_transaction, &record,),
1012            Err(PrivateRecordError::RecordIdMismatch),
1013        ));
1014
1015        let wrong_epoch = PrivateRecordShareRequest::new(
1016            request.record_id(),
1017            StorageKeyEpoch::new([99; 32]),
1018            request.context().to_vec(),
1019        );
1020        assert!(matches!(
1021            operator_keys[0].issue_private_record_share(&mut rng, &wrong_epoch, &record,),
1022            Err(PrivateRecordError::KeyEpochMismatch),
1023        ));
1024
1025        let mut wrong_context_bytes = request.context().to_vec();
1026        wrong_context_bytes[0] ^= 1;
1027        let wrong_context = PrivateRecordShareRequest::new(
1028            request.record_id(),
1029            request.key_epoch(),
1030            wrong_context_bytes,
1031        );
1032        assert!(matches!(
1033            operator_keys[0].issue_private_record_share(&mut rng, &wrong_context, &record,),
1034            Err(PrivateRecordError::DecryptionContextMismatch),
1035        ));
1036
1037        let mut wrong_setup_fields = record.into_storage_fields();
1038        wrong_setup_fields.setup_context_id = [99; 32];
1039        let wrong_setup = StoredPrivateRecord::from_storage_fields(wrong_setup_fields).unwrap();
1040        assert!(matches!(
1041            operator_keys[0].issue_private_record_share(&mut rng, &request, &wrong_setup,),
1042            Err(PrivateRecordError::SetupContextMismatch),
1043        ));
1044    }
1045
1046    #[test]
1047    fn combiner_rejects_bad_shares_and_damaged_ciphertext() {
1048        let operator_keys = operator_keys();
1049        let record = threshold_record(&operator_keys[0], transaction_id(), 26, b"record A");
1050        let request = PrivateRecordShareRequest::for_record(&record);
1051        let other_record = threshold_record(
1052            &operator_keys[0],
1053            TransactionId::from_raw(Word::from([8u32, 9, 10, 11])),
1054            27,
1055            b"record B",
1056        );
1057        let other_request = PrivateRecordShareRequest::for_record(&other_record);
1058        let first = issue_share(&operator_keys[0], &request, &record, 28);
1059        let second = issue_share(&operator_keys[1], &request, &record, 29);
1060        let mixed = issue_share(&operator_keys[1], &other_request, &other_record, 30);
1061        let combiner = PrivateRecordCombiner::from_operator_key(&operator_keys[2]).unwrap();
1062
1063        assert!(matches!(
1064            combiner.open(&request, &record, std::slice::from_ref(&first)),
1065            Err(PrivateRecordError::ShareCombination(_)),
1066        ));
1067        assert!(matches!(
1068            combiner.open(&request, &record, &[first.clone(), first.clone()]),
1069            Err(PrivateRecordError::ShareCombination(_)),
1070        ));
1071        assert!(matches!(
1072            combiner.open(&request, &record, &[vec![0], second.clone()]),
1073            Err(PrivateRecordError::InvalidDecryptionShare(_)),
1074        ));
1075        assert!(matches!(
1076            combiner.open(&request, &record, &[first.clone(), mixed]),
1077            Err(PrivateRecordError::ShareCombination(_)),
1078        ));
1079
1080        let mut damaged_message = miden_node_persistence::ProtobufValue::to_proto(&record);
1081        let Some(Record::V1(payload)) = damaged_message.record.as_mut() else {
1082            unreachable!("the codec writes a v1 private record file")
1083        };
1084        payload.encrypted_record[0] ^= 1;
1085        let damaged = <StoredPrivateRecord as miden_node_persistence::ProtobufValue>::from_proto(
1086            damaged_message,
1087        )
1088        .unwrap();
1089        assert!(matches!(
1090            combiner.open(&request, &damaged, &[first, second]),
1091            Err(PrivateRecordError::RecordDecryption),
1092        ));
1093    }
1094}