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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[repr(u32)]
21pub enum PrivateRecordFormatVersion {
22 V1 = 1,
24}
25
26impl PrivateRecordFormatVersion {
27 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
54pub struct PrivateRecordChainId([u8; 32]);
55
56impl PrivateRecordChainId {
57 pub const fn new(bytes: [u8; 32]) -> Self {
59 Self(bytes)
60 }
61
62 pub const fn as_bytes(&self) -> &[u8; 32] {
64 &self.0
65 }
66}
67
68#[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 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 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 pub const fn transaction_id(&self) -> TransactionId {
97 self.transaction_id
98 }
99
100 pub const fn validator_id(&self) -> &[u8; VALIDATOR_ID_BYTES] {
102 &self.validator_id
103 }
104}
105
106#[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 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 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 pub const fn chain_id(&self) -> PrivateRecordChainId {
153 self.chain_id
154 }
155
156 pub const fn key_epoch(&self) -> StorageKeyEpoch {
158 self.key_epoch
159 }
160
161 pub const fn transaction_id(&self) -> TransactionId {
163 self.transaction_id
164 }
165
166 pub const fn format_version(&self) -> PrivateRecordFormatVersion {
168 self.format_version
169 }
170
171 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#[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 pub fn new(record_id: PrivateRecordId, key_epoch: StorageKeyEpoch, context: Vec<u8>) -> Self {
195 Self { record_id, key_epoch, context }
196 }
197
198 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 pub const fn transaction_id(&self) -> TransactionId {
206 self.record_id.transaction_id()
207 }
208
209 pub const fn record_id(&self) -> PrivateRecordId {
211 self.record_id
212 }
213
214 pub const fn key_epoch(&self) -> StorageKeyEpoch {
216 self.key_epoch
217 }
218
219 pub fn context(&self) -> &[u8] {
221 &self.context
222 }
223}
224
225#[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 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 pub fn key_epoch(&self) -> StorageKeyEpoch {
245 self.key_epoch
246 }
247
248 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#[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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
376pub struct PrivateRecordStorageFields {
377 pub record_id: PrivateRecordId,
379 pub context: PrivateRecordContext,
381 pub setup_context_id: [u8; 32],
383 pub nonce: Vec<u8>,
385 pub encrypted_record: Vec<u8>,
387 pub encrypted_record_key: Vec<u8>,
389}
390
391#[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 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 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 pub const fn record_id(&self) -> PrivateRecordId {
443 self.record_id
444 }
445
446 pub const fn context(&self) -> PrivateRecordContext {
448 self.context
449 }
450
451 pub const fn setup_context_id(&self) -> &[u8; 32] {
453 &self.setup_context_id
454 }
455
456 pub const fn nonce(&self) -> &[u8; NONCE_BYTES] {
458 &self.nonce
459 }
460
461 pub fn encrypted_record(&self) -> &[u8] {
463 &self.encrypted_record
464 }
465
466 pub fn encrypted_record_key(&self) -> &[u8] {
468 &self.encrypted_record_key
469 }
470
471 pub fn verify_encrypted_record_key(&self) -> Result<(), PrivateRecordError> {
473 self.decode_encrypted_record_key().map(drop)
474 }
475
476 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 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#[derive(Debug, thiserror::Error)]
580pub enum PrivateRecordError {
581 #[error("private record key epoch does not match")]
583 KeyEpochMismatch,
584 #[error("private record share request names a different record")]
586 RecordIdMismatch,
587 #[error("private record validator id is not a canonical signing public key")]
589 InvalidValidatorId(#[source] DeserializationError),
590 #[error("private record Golden setup does not match the operator")]
592 SetupContextMismatch,
593 #[error("private record decryption context does not match the record")]
595 DecryptionContextMismatch,
596 #[error("failed to encrypt private record")]
598 RecordEncryption,
599 #[error("failed to encrypt private record content key")]
601 ContentKeyEncryption(#[source] golden_ehtdh1::Error),
602 #[error("invalid Golden content key ciphertext")]
604 InvalidGoldenEncoding(#[source] golden_ehtdh1::Error),
605 #[error("invalid Golden combiner setup")]
607 InvalidCombinerSetup(#[source] golden_ehtdh1::Error),
608 #[error("invalid Golden decryption share")]
610 InvalidDecryptionShare(#[source] golden_ehtdh1::Error),
611 #[error("failed to issue Golden decryption share")]
613 ShareGeneration(#[source] golden_ehtdh1::Error),
614 #[error("failed to combine Golden decryption shares")]
616 ShareCombination(#[source] golden_ehtdh1::CombineError),
617 #[error("Golden ciphertext has the wrong content key size")]
619 InvalidEncryptedRecordKey,
620 #[error("unsupported private record format version {0}")]
622 UnsupportedFormat(u32),
623 #[error("private record nonce has {actual} bytes, expected {NONCE_BYTES}")]
625 InvalidNonceLength { actual: usize },
626 #[error("private record ciphertext is shorter than its authentication tag")]
628 InvalidRecordCiphertext,
629 #[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}