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