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 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#[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 pub fn new(record_id: PrivateRecordId, key_epoch: StorageKeyEpoch, context: Vec<u8>) -> Self {
220 Self { record_id, key_epoch, context }
221 }
222
223 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 pub const fn transaction_id(&self) -> TransactionId {
231 self.record_id.transaction_id()
232 }
233
234 pub const fn record_id(&self) -> PrivateRecordId {
236 self.record_id
237 }
238
239 pub const fn key_epoch(&self) -> StorageKeyEpoch {
241 self.key_epoch
242 }
243
244 pub fn context(&self) -> &[u8] {
246 &self.context
247 }
248}
249
250#[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 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 pub fn key_epoch(&self) -> StorageKeyEpoch {
270 self.key_epoch
271 }
272
273 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#[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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
401pub struct PrivateRecordStorageFields {
402 pub record_id: PrivateRecordId,
404 pub context: PrivateRecordContext,
406 pub setup_context_id: [u8; 32],
408 pub nonce: Vec<u8>,
410 pub encrypted_record: Vec<u8>,
412 pub encrypted_record_key: Vec<u8>,
414}
415
416#[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 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 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 pub const fn record_id(&self) -> PrivateRecordId {
468 self.record_id
469 }
470
471 pub const fn context(&self) -> PrivateRecordContext {
473 self.context
474 }
475
476 pub const fn setup_context_id(&self) -> &[u8; 32] {
478 &self.setup_context_id
479 }
480
481 pub const fn nonce(&self) -> &[u8; NONCE_BYTES] {
483 &self.nonce
484 }
485
486 pub fn encrypted_record(&self) -> &[u8] {
488 &self.encrypted_record
489 }
490
491 pub fn encrypted_record_key(&self) -> &[u8] {
493 &self.encrypted_record_key
494 }
495
496 pub fn verify_encrypted_record_key(&self) -> Result<(), PrivateRecordError> {
498 self.decode_encrypted_record_key().map(drop)
499 }
500
501 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 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#[derive(Debug, thiserror::Error)]
605pub enum PrivateRecordError {
606 #[error("private record key epoch does not match")]
608 KeyEpochMismatch,
609 #[error("private record share request names a different record")]
611 RecordIdMismatch,
612 #[error("private record validator id is not a canonical signing public key")]
614 InvalidValidatorId(#[source] DeserializationError),
615 #[error("private record Golden setup does not match the operator")]
617 SetupContextMismatch,
618 #[error("private record decryption context does not match the record")]
620 DecryptionContextMismatch,
621 #[error("private record decryption context is malformed")]
623 MalformedDecryptionContext,
624 #[error("failed to encrypt private record")]
626 RecordEncryption,
627 #[error("failed to encrypt private record content key")]
629 ContentKeyEncryption(#[source] golden_ehtdh1::Error),
630 #[error("invalid Golden content key ciphertext")]
632 InvalidGoldenEncoding(#[source] golden_ehtdh1::Error),
633 #[error("invalid Golden combiner setup")]
635 InvalidCombinerSetup(#[source] golden_ehtdh1::Error),
636 #[error("invalid Golden decryption share")]
638 InvalidDecryptionShare(#[source] golden_ehtdh1::Error),
639 #[error("failed to issue Golden decryption share")]
641 ShareGeneration(#[source] golden_ehtdh1::Error),
642 #[error("failed to combine Golden decryption shares")]
644 ShareCombination(#[source] golden_ehtdh1::CombineError),
645 #[error("Golden ciphertext has the wrong content key size")]
647 InvalidEncryptedRecordKey,
648 #[error("unsupported private record format version {0}")]
650 UnsupportedFormat(u32),
651 #[error("private record nonce has {actual} bytes, expected {NONCE_BYTES}")]
653 InvalidNonceLength { actual: usize },
654 #[error("private record ciphertext is shorter than its authentication tag")]
656 InvalidRecordCiphertext,
657 #[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 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 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 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}