1use alloc::{format, string::String, vec, vec::Vec};
4
5use rand::RngCore;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 encryption::{
10 derive_key_hkdf, derive_key_hkdf_raw, Algorithm as EncryptionAlgorithm, EncryptOptions,
11 EncryptionResult, Key,
12 },
13 hash::{compare_hashes, generate_hmac, HashAlgorithm},
14 Error, Result,
15};
16
17#[cfg(feature = "compression")]
18use crate::compression::{self, CompressionAlgorithm, CompressionOptions};
19
20pub const SHELL_TAG_SIZE: usize = 16;
22
23pub const FUSED_SHELL_MAGIC: [u8; 4] = *b"VFS2";
25
26pub const FUSED_SHELL_VERSION: u8 = 0x01;
28
29pub const PROTECTED_ARTIFACT_MAGIC: [u8; 4] = *b"VOF2";
31
32pub const PROTECTED_ARTIFACT_VERSION: u8 = 0x01;
34
35pub const SHELL_NONCE_SIZE: usize = 12;
37
38pub const DEFAULT_CHUNK_SIZE: usize = 16 * 1024;
40
41const MIN_CHUNK_SIZE: usize = 256;
42const MAX_CHUNK_SIZE: usize = 1024 * 1024;
43const FUSED_SHELL_HEADER_LEN: usize = 4 + 1 + 1 + 4 + SHELL_NONCE_SIZE;
44const PROTECTED_ARTIFACT_HEADER_LEN: usize = 4 + 1 + 1 + 1 + 1 + 4 + 8 + 8 + SHELL_NONCE_SIZE;
45
46fn domain_info(domain_label: &str) -> String {
47 format!("voided:shell:{domain_label}")
48}
49
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[repr(u8)]
53pub enum FusedPreset {
54 Compact = 0x01,
56 Balanced = 0x02,
58 Concealed = 0x03,
60}
61
62impl FusedPreset {
63 pub fn from_byte(byte: u8) -> Result<Self> {
65 match byte {
66 0x01 => Ok(Self::Compact),
67 0x02 => Ok(Self::Balanced),
68 0x03 => Ok(Self::Concealed),
69 other => Err(Error::UnsupportedPreset(other)),
70 }
71 }
72
73 pub fn from_name(name: &str) -> Result<Self> {
75 match name {
76 "compact" => Ok(Self::Compact),
77 "balanced" => Ok(Self::Balanced),
78 "concealed" => Ok(Self::Concealed),
79 other => Err(Error::InvalidConfiguration(format!(
80 "unsupported fused preset `{other}`"
81 ))),
82 }
83 }
84
85 pub fn name(self) -> &'static str {
87 match self {
88 Self::Compact => "compact",
89 Self::Balanced => "balanced",
90 Self::Concealed => "concealed",
91 }
92 }
93
94 fn default_chunk_size(self) -> usize {
95 match self {
96 Self::Compact => DEFAULT_CHUNK_SIZE,
97 Self::Balanced => DEFAULT_CHUNK_SIZE,
98 Self::Concealed => 8 * 1024,
99 }
100 }
101}
102
103impl Default for FusedPreset {
104 fn default() -> Self {
105 Self::Balanced
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FusedShellOptions {
112 pub preset: FusedPreset,
114 pub chunk_size: Option<usize>,
116 pub shell_nonce: Option<[u8; SHELL_NONCE_SIZE]>,
118}
119
120impl Default for FusedShellOptions {
121 fn default() -> Self {
122 Self {
123 preset: FusedPreset::Balanced,
124 chunk_size: None,
125 shell_nonce: None,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct FusedShellInfo {
133 pub version: u8,
135 pub preset: FusedPreset,
137 pub preset_label: String,
139 pub chunk_size: u32,
141 pub chunk_count: usize,
143 pub payload_size: usize,
145 pub shell_size: usize,
147 pub metadata_size: usize,
149 pub tag_size: usize,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FusedShellEnvelope {
156 pub version: u8,
158 pub preset: FusedPreset,
160 pub chunk_size: u32,
162 pub nonce: [u8; SHELL_NONCE_SIZE],
164 pub payload: Vec<u8>,
166 pub tag: [u8; SHELL_TAG_SIZE],
168}
169
170impl FusedShellEnvelope {
171 pub fn to_bytes(&self) -> Vec<u8> {
173 let mut bytes = self.to_unsigned_bytes();
174 bytes.extend_from_slice(&self.tag);
175 bytes
176 }
177
178 pub fn to_unsigned_bytes(&self) -> Vec<u8> {
180 let mut bytes = Vec::with_capacity(FUSED_SHELL_HEADER_LEN + self.payload.len());
181 bytes.extend_from_slice(&FUSED_SHELL_MAGIC);
182 bytes.push(self.version);
183 bytes.push(self.preset as u8);
184 bytes.extend_from_slice(&self.chunk_size.to_be_bytes());
185 bytes.extend_from_slice(&self.nonce);
186 bytes.extend_from_slice(&self.payload);
187 bytes
188 }
189
190 pub fn from_bytes(data: &[u8]) -> Result<Self> {
192 if data.len() < FUSED_SHELL_HEADER_LEN + SHELL_TAG_SIZE {
193 return Err(Error::TruncatedPayload {
194 expected: FUSED_SHELL_HEADER_LEN + SHELL_TAG_SIZE,
195 actual: data.len(),
196 });
197 }
198 if data[..4] != FUSED_SHELL_MAGIC {
199 return Err(Error::InvalidFormat);
200 }
201
202 let version = data[4];
203 if version != FUSED_SHELL_VERSION {
204 return Err(Error::UnsupportedVersion(version));
205 }
206
207 let preset = FusedPreset::from_byte(data[5])?;
208 let chunk_size = u32::from_be_bytes([data[6], data[7], data[8], data[9]]);
209 let mut nonce = [0u8; SHELL_NONCE_SIZE];
210 nonce.copy_from_slice(&data[10..10 + SHELL_NONCE_SIZE]);
211
212 let payload_end = data.len() - SHELL_TAG_SIZE;
213 let payload = data[FUSED_SHELL_HEADER_LEN..payload_end].to_vec();
214 let mut tag = [0u8; SHELL_TAG_SIZE];
215 tag.copy_from_slice(&data[payload_end..]);
216
217 Ok(Self {
218 version,
219 preset,
220 chunk_size,
221 nonce,
222 payload,
223 tag,
224 })
225 }
226
227 pub fn info(&self) -> FusedShellInfo {
229 let chunk_size = self.chunk_size.max(1) as usize;
230 FusedShellInfo {
231 version: self.version,
232 preset: self.preset,
233 preset_label: self.preset.name().to_string(),
234 chunk_size: self.chunk_size,
235 chunk_count: chunk_count(self.payload.len(), chunk_size),
236 payload_size: self.payload.len(),
237 shell_size: self.payload.len() + FUSED_SHELL_HEADER_LEN + SHELL_TAG_SIZE,
238 metadata_size: FUSED_SHELL_HEADER_LEN,
239 tag_size: SHELL_TAG_SIZE,
240 }
241 }
242}
243
244#[cfg(feature = "compression")]
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ProtectOptions {
248 pub preset: FusedPreset,
250 pub compression_algorithm: CompressionAlgorithm,
252 pub compression_level: u32,
254 pub compression_min_size_threshold: usize,
256 pub encryption_algorithm: Option<EncryptionAlgorithm>,
258 pub shell_chunk_size: Option<usize>,
260 pub shell_nonce: Option<[u8; SHELL_NONCE_SIZE]>,
262}
263
264#[cfg(feature = "compression")]
265impl Default for ProtectOptions {
266 fn default() -> Self {
267 Self {
268 preset: FusedPreset::Balanced,
269 compression_algorithm: CompressionAlgorithm::Brotli,
270 compression_level: 6,
271 compression_min_size_threshold: 100,
272 encryption_algorithm: None,
273 shell_chunk_size: None,
274 shell_nonce: None,
275 }
276 }
277}
278
279#[cfg(feature = "compression")]
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ProtectedArtifactInfo {
283 pub version: u8,
285 pub preset: FusedPreset,
287 pub preset_label: String,
289 pub compression_algorithm: CompressionAlgorithm,
291 pub encryption_algorithm: EncryptionAlgorithm,
293 pub original_size: usize,
295 pub compressed_size: usize,
297 pub encrypted_size: usize,
299 pub protected_size: usize,
301 pub shell_chunk_size: u32,
303 pub shell_chunk_count: usize,
305 pub shell_nonce: [u8; SHELL_NONCE_SIZE],
307}
308
309#[cfg(feature = "compression")]
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct ProtectResult {
313 pub artifact: Vec<u8>,
315 pub info: ProtectedArtifactInfo,
317}
318
319#[cfg(feature = "compression")]
320#[derive(Debug, Clone)]
321struct ProtectedArtifactEnvelope {
322 version: u8,
323 preset: FusedPreset,
324 compression_algorithm: CompressionAlgorithm,
325 encryption_algorithm: EncryptionAlgorithm,
326 chunk_size: u32,
327 original_size: u64,
328 compressed_size: u64,
329 nonce: [u8; SHELL_NONCE_SIZE],
330 payload: Vec<u8>,
331 tag: [u8; SHELL_TAG_SIZE],
332}
333
334#[cfg(feature = "compression")]
335impl ProtectedArtifactEnvelope {
336 fn to_bytes(&self) -> Vec<u8> {
337 let mut bytes = self.to_unsigned_bytes();
338 bytes.extend_from_slice(&self.tag);
339 bytes
340 }
341
342 fn to_unsigned_bytes(&self) -> Vec<u8> {
343 let mut bytes = Vec::with_capacity(PROTECTED_ARTIFACT_HEADER_LEN + self.payload.len());
344 bytes.extend_from_slice(&PROTECTED_ARTIFACT_MAGIC);
345 bytes.push(self.version);
346 bytes.push(self.preset as u8);
347 bytes.push(self.compression_algorithm as u8);
348 bytes.push(self.encryption_algorithm as u8);
349 bytes.extend_from_slice(&self.chunk_size.to_be_bytes());
350 bytes.extend_from_slice(&self.original_size.to_be_bytes());
351 bytes.extend_from_slice(&self.compressed_size.to_be_bytes());
352 bytes.extend_from_slice(&self.nonce);
353 bytes.extend_from_slice(&self.payload);
354 bytes
355 }
356
357 fn from_bytes(data: &[u8]) -> Result<Self> {
358 if data.len() < PROTECTED_ARTIFACT_HEADER_LEN + SHELL_TAG_SIZE {
359 return Err(Error::TruncatedPayload {
360 expected: PROTECTED_ARTIFACT_HEADER_LEN + SHELL_TAG_SIZE,
361 actual: data.len(),
362 });
363 }
364 if data[..4] != PROTECTED_ARTIFACT_MAGIC {
365 return Err(Error::InvalidFormat);
366 }
367
368 let version = data[4];
369 if version != PROTECTED_ARTIFACT_VERSION {
370 return Err(Error::UnsupportedVersion(version));
371 }
372
373 let preset = FusedPreset::from_byte(data[5])?;
374 let compression_algorithm = CompressionAlgorithm::from_byte(data[6])?;
375 let encryption_algorithm = EncryptionAlgorithm::from_byte(data[7])?;
376 let chunk_size = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
377 let original_size = u64::from_be_bytes([
378 data[12], data[13], data[14], data[15], data[16], data[17], data[18], data[19],
379 ]);
380 let compressed_size = u64::from_be_bytes([
381 data[20], data[21], data[22], data[23], data[24], data[25], data[26], data[27],
382 ]);
383 let mut nonce = [0u8; SHELL_NONCE_SIZE];
384 nonce.copy_from_slice(&data[28..28 + SHELL_NONCE_SIZE]);
385
386 let payload_end = data.len() - SHELL_TAG_SIZE;
387 let payload = data[PROTECTED_ARTIFACT_HEADER_LEN..payload_end].to_vec();
388 let mut tag = [0u8; SHELL_TAG_SIZE];
389 tag.copy_from_slice(&data[payload_end..]);
390
391 Ok(Self {
392 version,
393 preset,
394 compression_algorithm,
395 encryption_algorithm,
396 chunk_size,
397 original_size,
398 compressed_size,
399 nonce,
400 payload,
401 tag,
402 })
403 }
404
405 fn info(&self) -> ProtectedArtifactInfo {
406 let chunk_size = self.chunk_size.max(1) as usize;
407 ProtectedArtifactInfo {
408 version: self.version,
409 preset: self.preset,
410 preset_label: self.preset.name().to_string(),
411 compression_algorithm: self.compression_algorithm,
412 encryption_algorithm: self.encryption_algorithm,
413 original_size: self.original_size as usize,
414 compressed_size: self.compressed_size as usize,
415 encrypted_size: self.payload.len(),
416 protected_size: self.payload.len() + PROTECTED_ARTIFACT_HEADER_LEN + SHELL_TAG_SIZE,
417 shell_chunk_size: self.chunk_size,
418 shell_chunk_count: chunk_count(self.payload.len(), chunk_size),
419 shell_nonce: self.nonce,
420 }
421 }
422}
423
424pub fn derive_domain_key(master: &Key, salt: Option<&[u8]>, domain_label: &str) -> Result<Key> {
426 derive_key_hkdf(
427 master.as_bytes(),
428 salt,
429 domain_info(domain_label).as_bytes(),
430 )
431}
432
433pub fn derive_domain_bytes(
435 master: &Key,
436 salt: Option<&[u8]>,
437 domain_label: &str,
438 len: usize,
439) -> Result<Vec<u8>> {
440 derive_key_hkdf_raw(
441 master.as_bytes(),
442 salt,
443 domain_info(domain_label).as_bytes(),
444 len,
445 )
446}
447
448pub fn derive_chunk_seed(
450 shell_key: &Key,
451 nonce: &[u8],
452 chunk_index: u32,
453 purpose: &str,
454 len: usize,
455) -> Result<Vec<u8>> {
456 let mut info = Vec::new();
457 info.extend_from_slice(b"voided:shell:chunk:");
458 info.extend_from_slice(purpose.as_bytes());
459 info.extend_from_slice(b":");
460 info.extend_from_slice(&chunk_index.to_be_bytes());
461 derive_key_hkdf_raw(shell_key.as_bytes(), Some(nonce), &info, len)
462}
463
464pub fn compute_shell_tag(data: &[u8], tag_key: &Key) -> Result<[u8; SHELL_TAG_SIZE]> {
466 let mac = generate_hmac(data, tag_key.as_bytes(), HashAlgorithm::Sha256)?;
467 let mut tag = [0u8; SHELL_TAG_SIZE];
468 tag.copy_from_slice(&mac[..SHELL_TAG_SIZE]);
469 Ok(tag)
470}
471
472pub fn verify_shell_tag(data: &[u8], tag: &[u8], tag_key: &Key) -> Result<bool> {
474 if tag.len() != SHELL_TAG_SIZE {
475 return Ok(false);
476 }
477 let expected = compute_shell_tag(data, tag_key)?;
478 Ok(compare_hashes(&expected, tag))
479}
480
481pub fn fuse(
483 data: &[u8],
484 master_key: &Key,
485 options: Option<FusedShellOptions>,
486) -> Result<FusedShellEnvelope> {
487 let opts = options.unwrap_or_default();
488 let chunk_size = resolve_chunk_size(opts.preset, opts.chunk_size)?;
489 let nonce = canonicalize_nonce(opts.shell_nonce.unwrap_or_else(random_nonce));
490 let (shell_key, tag_key) = derive_shell_keys(master_key, &nonce)?;
491 let payload = transform_payload(data, &shell_key, &nonce, opts.preset, chunk_size, true)?;
492
493 let mut envelope = FusedShellEnvelope {
494 version: FUSED_SHELL_VERSION,
495 preset: opts.preset,
496 chunk_size: chunk_size as u32,
497 nonce,
498 payload,
499 tag: [0u8; SHELL_TAG_SIZE],
500 };
501 envelope.tag = compute_shell_tag(&envelope.to_unsigned_bytes(), &tag_key)?;
502 Ok(envelope)
503}
504
505pub fn unfuse(envelope: &FusedShellEnvelope, master_key: &Key) -> Result<Vec<u8>> {
507 let (shell_key, tag_key) = derive_shell_keys(master_key, &envelope.nonce)?;
508 if !verify_shell_tag(&envelope.to_unsigned_bytes(), &envelope.tag, &tag_key)? {
509 return Err(Error::AuthenticationFailed);
510 }
511
512 transform_payload(
513 &envelope.payload,
514 &shell_key,
515 &envelope.nonce,
516 envelope.preset,
517 envelope.chunk_size.max(1) as usize,
518 false,
519 )
520}
521
522pub fn fuse_bytes(
524 data: &[u8],
525 master_key: &Key,
526 options: Option<FusedShellOptions>,
527) -> Result<Vec<u8>> {
528 Ok(fuse(data, master_key, options)?.to_bytes())
529}
530
531pub fn unfuse_bytes(data: &[u8], master_key: &Key) -> Result<Vec<u8>> {
533 unfuse(&FusedShellEnvelope::from_bytes(data)?, master_key)
534}
535
536pub fn inspect_fused(data: &[u8]) -> Result<FusedShellInfo> {
538 Ok(FusedShellEnvelope::from_bytes(data)?.info())
539}
540
541#[cfg(feature = "compression")]
542pub fn protect(
544 plaintext: &[u8],
545 master_key: &Key,
546 options: Option<ProtectOptions>,
547) -> Result<ProtectResult> {
548 let opts = options.unwrap_or_default();
549 let chunk_size = resolve_chunk_size(opts.preset, opts.shell_chunk_size)?;
550 let nonce = canonicalize_nonce(opts.shell_nonce.unwrap_or_else(random_nonce));
551
552 let compression_result = compression::compress(
553 plaintext,
554 Some(CompressionOptions {
555 algorithm: opts.compression_algorithm,
556 min_size_threshold: opts.compression_min_size_threshold,
557 level: opts.compression_level,
558 }),
559 )?;
560
561 let encrypted = crate::encryption::encrypt(
562 &compression_result.compressed,
563 master_key,
564 Some(EncryptOptions {
565 algorithm: opts.encryption_algorithm,
566 aad: None,
567 }),
568 )?;
569 let encrypted_bytes = encrypted.to_bytes();
570
571 let (shell_key, tag_key) = derive_shell_keys(master_key, &nonce)?;
572 let payload = transform_payload(
573 &encrypted_bytes,
574 &shell_key,
575 &nonce,
576 opts.preset,
577 chunk_size,
578 true,
579 )?;
580
581 let mut envelope = ProtectedArtifactEnvelope {
582 version: PROTECTED_ARTIFACT_VERSION,
583 preset: opts.preset,
584 compression_algorithm: compression_result.algorithm,
585 encryption_algorithm: encrypted.algorithm,
586 chunk_size: chunk_size as u32,
587 original_size: plaintext.len() as u64,
588 compressed_size: compression_result.compressed.len() as u64,
589 nonce,
590 payload,
591 tag: [0u8; SHELL_TAG_SIZE],
592 };
593 envelope.tag = compute_shell_tag(&envelope.to_unsigned_bytes(), &tag_key)?;
594
595 let artifact = envelope.to_bytes();
596 let info = envelope.info();
597 Ok(ProtectResult { artifact, info })
598}
599
600#[cfg(feature = "compression")]
601pub fn open(artifact: &[u8], master_key: &Key) -> Result<Vec<u8>> {
603 let envelope = ProtectedArtifactEnvelope::from_bytes(artifact)?;
604 let (shell_key, tag_key) = derive_shell_keys(master_key, &envelope.nonce)?;
605 if !verify_shell_tag(&envelope.to_unsigned_bytes(), &envelope.tag, &tag_key)? {
606 return Err(Error::AuthenticationFailed);
607 }
608
609 let encrypted_bytes = transform_payload(
610 &envelope.payload,
611 &shell_key,
612 &envelope.nonce,
613 envelope.preset,
614 envelope.chunk_size.max(1) as usize,
615 false,
616 )?;
617 let encrypted = EncryptionResult::from_bytes(&encrypted_bytes)?;
618 if encrypted.algorithm != envelope.encryption_algorithm {
619 return Err(Error::InvalidFormat);
620 }
621
622 let compressed = crate::encryption::decrypt(&encrypted, master_key)?;
623 if compressed.len() != envelope.compressed_size as usize {
624 return Err(Error::SizeMismatch {
625 expected: envelope.compressed_size as usize,
626 actual: compressed.len(),
627 });
628 }
629
630 let plaintext = compression::decompress(&compressed, envelope.compression_algorithm)?;
631 if plaintext.len() != envelope.original_size as usize {
632 return Err(Error::SizeMismatch {
633 expected: envelope.original_size as usize,
634 actual: plaintext.len(),
635 });
636 }
637
638 Ok(plaintext)
639}
640
641#[cfg(feature = "compression")]
642pub fn inspect_artifact(artifact: &[u8]) -> Result<ProtectedArtifactInfo> {
644 Ok(ProtectedArtifactEnvelope::from_bytes(artifact)?.info())
645}
646
647#[cfg(feature = "compression")]
648pub fn repack_artifact(
650 artifact: &[u8],
651 master_key: &Key,
652 options: Option<ProtectOptions>,
653) -> Result<ProtectResult> {
654 let plaintext = open(artifact, master_key)?;
655 protect(&plaintext, master_key, options)
656}
657
658fn derive_shell_keys(master_key: &Key, nonce: &[u8; SHELL_NONCE_SIZE]) -> Result<(Key, Key)> {
659 let shell_key = derive_domain_key(master_key, Some(nonce), "fused-shell")?;
660 let tag_key = derive_domain_key(master_key, Some(nonce), "fused-tag")?;
661 Ok((shell_key, tag_key))
662}
663
664fn transform_payload(
665 data: &[u8],
666 shell_key: &Key,
667 nonce: &[u8; SHELL_NONCE_SIZE],
668 preset: FusedPreset,
669 chunk_size: usize,
670 encode: bool,
671) -> Result<Vec<u8>> {
672 let mut output = Vec::with_capacity(data.len());
673 for (chunk_index, chunk) in data.chunks(chunk_size.max(1)).enumerate() {
674 let chunk_index = chunk_index as u32;
675 let segment_lengths = segment_lengths(preset, chunk.len(), chunk_index);
676 let mut offset = 0usize;
677 for (segment_index, segment_len) in segment_lengths.into_iter().enumerate() {
678 let next_offset = offset + segment_len;
679 let purpose = segment_purpose(preset, segment_index);
680 let transformed = if encode {
681 encode_segment(
682 &chunk[offset..next_offset],
683 shell_key,
684 nonce,
685 chunk_index * 8 + segment_index as u32,
686 &purpose,
687 )?
688 } else {
689 decode_segment(
690 &chunk[offset..next_offset],
691 shell_key,
692 nonce,
693 chunk_index * 8 + segment_index as u32,
694 &purpose,
695 )?
696 };
697 output.extend_from_slice(&transformed);
698 offset = next_offset;
699 }
700 }
701 Ok(output)
702}
703
704fn encode_segment(
705 segment: &[u8],
706 shell_key: &Key,
707 nonce: &[u8; SHELL_NONCE_SIZE],
708 chunk_index: u32,
709 purpose: &str,
710) -> Result<Vec<u8>> {
711 let seed = derive_chunk_seed(shell_key, nonce, chunk_index, purpose, 32)?;
712 let mut rng = DeterministicRng::from_seed(&seed);
713 let permutation = build_permutation(segment.len(), &mut rng);
714 let mut mask = vec![0u8; segment.len()];
715 rng.fill_bytes(&mut mask);
716 let mut encoded = vec![0u8; segment.len()];
717
718 for (source_index, &target_index) in permutation.iter().enumerate() {
719 encoded[target_index] = segment[source_index] ^ mask[source_index];
720 }
721
722 Ok(encoded)
723}
724
725fn decode_segment(
726 segment: &[u8],
727 shell_key: &Key,
728 nonce: &[u8; SHELL_NONCE_SIZE],
729 chunk_index: u32,
730 purpose: &str,
731) -> Result<Vec<u8>> {
732 let seed = derive_chunk_seed(shell_key, nonce, chunk_index, purpose, 32)?;
733 let mut rng = DeterministicRng::from_seed(&seed);
734 let permutation = build_permutation(segment.len(), &mut rng);
735 let mut mask = vec![0u8; segment.len()];
736 rng.fill_bytes(&mut mask);
737 let mut decoded = vec![0u8; segment.len()];
738
739 for (source_index, &target_index) in permutation.iter().enumerate() {
740 decoded[source_index] = segment[target_index] ^ mask[source_index];
741 }
742
743 Ok(decoded)
744}
745
746fn resolve_chunk_size(preset: FusedPreset, requested: Option<usize>) -> Result<usize> {
747 let chunk_size = requested.unwrap_or_else(|| preset.default_chunk_size());
748 if !(MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&chunk_size) {
749 return Err(Error::InvalidConfiguration(format!(
750 "shell chunk size must be between {MIN_CHUNK_SIZE} and {MAX_CHUNK_SIZE} bytes"
751 )));
752 }
753 Ok(chunk_size)
754}
755
756fn chunk_count(payload_len: usize, chunk_size: usize) -> usize {
757 if payload_len == 0 {
758 0
759 } else {
760 (payload_len + chunk_size.saturating_sub(1)) / chunk_size.max(1)
761 }
762}
763
764fn segment_lengths(preset: FusedPreset, chunk_len: usize, chunk_index: u32) -> Vec<usize> {
765 if chunk_len <= 1 {
766 return vec![chunk_len];
767 }
768
769 let weights: &[u8] = match preset {
770 FusedPreset::Compact => &[1],
771 FusedPreset::Balanced => match chunk_index % 3 {
772 0 => &[3, 2, 1],
773 1 => &[2, 1, 3],
774 _ => &[1, 3, 2],
775 },
776 FusedPreset::Concealed => match chunk_index % 4 {
777 0 => &[4, 2, 1, 1],
778 1 => &[1, 4, 2, 1],
779 2 => &[1, 1, 4, 2],
780 _ => &[2, 1, 1, 4],
781 },
782 };
783
784 if weights.len() == 1 || chunk_len < weights.len() {
785 return vec![chunk_len];
786 }
787
788 split_len_by_weights(chunk_len, weights)
789}
790
791fn split_len_by_weights(total_len: usize, weights: &[u8]) -> Vec<usize> {
792 if total_len == 0 {
793 return vec![0];
794 }
795
796 let total_weight: usize = weights.iter().map(|&weight| weight as usize).sum();
797 let mut lengths = Vec::with_capacity(weights.len());
798 let mut consumed = 0usize;
799 let mut remaining_weight = total_weight;
800
801 for (index, &weight) in weights.iter().enumerate() {
802 let remaining_segments = weights.len() - index;
803 let remaining_len = total_len - consumed;
804
805 let segment_len = if remaining_segments == 1 {
806 remaining_len
807 } else {
808 let proportional = remaining_len.saturating_mul(weight as usize) / remaining_weight;
809 proportional.max(1)
810 };
811
812 lengths.push(segment_len);
813 consumed += segment_len;
814 remaining_weight = remaining_weight.saturating_sub(weight as usize);
815 }
816
817 let mut overflow = consumed.saturating_sub(total_len);
818 let mut cursor = lengths.len();
819 while overflow > 0 && cursor > 0 {
820 cursor -= 1;
821 if lengths[cursor] > 1 {
822 lengths[cursor] -= 1;
823 overflow -= 1;
824 }
825 }
826
827 if consumed < total_len {
828 if let Some(last) = lengths.last_mut() {
829 *last += total_len - consumed;
830 }
831 }
832
833 lengths
834}
835
836fn segment_purpose(preset: FusedPreset, segment_index: usize) -> String {
837 format!("{}-segment-{}", preset.name(), segment_index)
838}
839
840fn random_nonce() -> [u8; SHELL_NONCE_SIZE] {
841 let mut nonce = [0u8; SHELL_NONCE_SIZE];
842 rand::thread_rng().fill_bytes(&mut nonce);
843 nonce
844}
845
846fn canonicalize_nonce(nonce: [u8; SHELL_NONCE_SIZE]) -> [u8; SHELL_NONCE_SIZE] {
847 nonce
848}
849
850fn build_permutation(len: usize, rng: &mut DeterministicRng) -> Vec<usize> {
851 let mut permutation: Vec<usize> = (0..len).collect();
852 if len <= 1 {
853 return permutation;
854 }
855 for index in (1..len).rev() {
856 let swap_index = rng.next_index(index + 1);
857 permutation.swap(index, swap_index);
858 }
859 permutation
860}
861
862#[derive(Debug, Clone, Copy)]
863struct DeterministicRng {
864 state: u64,
865}
866
867impl DeterministicRng {
868 fn from_seed(seed: &[u8]) -> Self {
869 let mut state = 0x9E37_79B9_7F4A_7C15u64;
870 for chunk in seed.chunks(8) {
871 let mut word = [0u8; 8];
872 word[..chunk.len()].copy_from_slice(chunk);
873 state ^= u64::from_le_bytes(word);
874 state = state.rotate_left(17).wrapping_mul(0xBF58_476D_1CE4_E5B9);
875 }
876 Self { state }
877 }
878
879 fn next_u64(&mut self) -> u64 {
880 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
881 let mut z = self.state;
882 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
883 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
884 z ^ (z >> 31)
885 }
886
887 fn next_index(&mut self, upper: usize) -> usize {
888 if upper <= 1 {
889 0
890 } else {
891 (self.next_u64() as usize) % upper
892 }
893 }
894
895 fn fill_bytes(&mut self, output: &mut [u8]) {
896 let mut offset = 0usize;
897 while offset < output.len() {
898 let bytes = self.next_u64().to_le_bytes();
899 let take = (output.len() - offset).min(bytes.len());
900 output[offset..offset + take].copy_from_slice(&bytes[..take]);
901 offset += take;
902 }
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 fn fixed_key() -> Key {
911 Key::from_bytes(&[0x42; 32]).expect("valid fixed key")
912 }
913
914 #[test]
915 fn derive_domain_key_is_deterministic() {
916 let master = fixed_key();
917 let salt = b"shell-salt";
918 let first = derive_domain_key(&master, Some(salt), "shell").unwrap();
919 let second = derive_domain_key(&master, Some(salt), "shell").unwrap();
920 let different = derive_domain_key(&master, Some(salt), "shell-tag").unwrap();
921
922 assert_eq!(first.as_bytes(), second.as_bytes());
923 assert_ne!(first.as_bytes(), different.as_bytes());
924 }
925
926 #[test]
927 fn derive_chunk_seed_varies_by_chunk_and_purpose() {
928 let shell_key = fixed_key();
929 let nonce = [7u8; SHELL_NONCE_SIZE];
930
931 let first = derive_chunk_seed(&shell_key, &nonce, 0, "fused-prefix", 32).unwrap();
932 let second = derive_chunk_seed(&shell_key, &nonce, 0, "fused-prefix", 32).unwrap();
933 let next_chunk = derive_chunk_seed(&shell_key, &nonce, 1, "fused-prefix", 32).unwrap();
934 let next_purpose = derive_chunk_seed(&shell_key, &nonce, 0, "compare", 32).unwrap();
935
936 assert_eq!(first, second);
937 assert_ne!(first, next_chunk);
938 assert_ne!(first, next_purpose);
939 }
940
941 #[test]
942 fn shell_tag_roundtrip_detects_wrong_key_and_tamper() {
943 let payload = b"outer shell payload";
944 let tag_key = fixed_key();
945 let wrong_key = Key::from_bytes(&[0x24; 32]).unwrap();
946
947 let tag = compute_shell_tag(payload, &tag_key).unwrap();
948
949 assert!(verify_shell_tag(payload, &tag, &tag_key).unwrap());
950 assert!(!verify_shell_tag(payload, &tag, &wrong_key).unwrap());
951 assert!(!verify_shell_tag(b"tampered", &tag, &tag_key).unwrap());
952 }
953
954 #[test]
955 fn fused_shell_roundtrip_for_all_presets() {
956 let master = fixed_key();
957 let payload = vec![0xA5u8; DEFAULT_CHUNK_SIZE + 257];
958
959 for preset in [
960 FusedPreset::Compact,
961 FusedPreset::Balanced,
962 FusedPreset::Concealed,
963 ] {
964 let envelope = fuse(
965 &payload,
966 &master,
967 Some(FusedShellOptions {
968 preset,
969 chunk_size: None,
970 shell_nonce: Some([9u8; SHELL_NONCE_SIZE]),
971 }),
972 )
973 .unwrap();
974
975 let restored = unfuse(&envelope, &master).unwrap();
976 assert_eq!(restored, payload);
977 assert_eq!(envelope.info().preset, preset);
978 }
979 }
980
981 #[test]
982 fn fused_shell_detects_tamper() {
983 let master = fixed_key();
984 let mut bytes = fuse_bytes(b"hello fused world", &master, None).unwrap();
985 let payload_offset = FUSED_SHELL_HEADER_LEN;
986 bytes[payload_offset] ^= 0xFF;
987
988 let err = unfuse_bytes(&bytes, &master).unwrap_err();
989 assert_eq!(err, Error::AuthenticationFailed);
990 }
991
992 #[cfg(feature = "compression")]
993 #[test]
994 fn protect_open_roundtrip_for_all_presets() {
995 let master = fixed_key();
996 let payload = b"voided v2 fused flow protects this payload cleanly".repeat(1024);
997
998 for preset in [
999 FusedPreset::Compact,
1000 FusedPreset::Balanced,
1001 FusedPreset::Concealed,
1002 ] {
1003 let protected = protect(
1004 &payload,
1005 &master,
1006 Some(ProtectOptions {
1007 preset,
1008 ..ProtectOptions::default()
1009 }),
1010 )
1011 .unwrap();
1012
1013 let restored = open(&protected.artifact, &master).unwrap();
1014 assert_eq!(restored, payload);
1015
1016 let inspected = inspect_artifact(&protected.artifact).unwrap();
1017 assert_eq!(inspected.preset, preset);
1018 assert_eq!(inspected.original_size, payload.len());
1019 }
1020 }
1021
1022 #[cfg(feature = "compression")]
1023 #[test]
1024 fn repack_changes_preset_and_keeps_plaintext() {
1025 let master = fixed_key();
1026 let payload = vec![0x6Du8; 65_537];
1027 let protected = protect(&payload, &master, None).unwrap();
1028
1029 let repacked = repack_artifact(
1030 &protected.artifact,
1031 &master,
1032 Some(ProtectOptions {
1033 preset: FusedPreset::Concealed,
1034 ..ProtectOptions::default()
1035 }),
1036 )
1037 .unwrap();
1038
1039 assert_eq!(repacked.info.preset, FusedPreset::Concealed);
1040 assert_eq!(open(&repacked.artifact, &master).unwrap(), payload);
1041 }
1042}