Skip to main content

voided_core/
shell.rs

1//! Fused shell primitives and fused-first protect/open flows for Voided v2.
2
3use 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
20/// Truncated outer shell tag size in bytes.
21pub const SHELL_TAG_SIZE: usize = 16;
22
23/// Fused shell envelope magic bytes.
24pub const FUSED_SHELL_MAGIC: [u8; 4] = *b"VFS2";
25
26/// Fused shell envelope version.
27pub const FUSED_SHELL_VERSION: u8 = 0x01;
28
29/// Protected artifact magic bytes.
30pub const PROTECTED_ARTIFACT_MAGIC: [u8; 4] = *b"VOF2";
31
32/// Protected artifact version.
33pub const PROTECTED_ARTIFACT_VERSION: u8 = 0x01;
34
35/// Shell nonce size in bytes.
36pub const SHELL_NONCE_SIZE: usize = 12;
37
38/// Default fused shell chunk size in bytes.
39pub 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/// Stable fused preset ids for Voided v2.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[repr(u8)]
53pub enum FusedPreset {
54    /// Cheapest fused shell lane.
55    Compact = 0x01,
56    /// Default fused shell lane.
57    Balanced = 0x02,
58    /// Highest-variation fused shell lane.
59    Concealed = 0x03,
60}
61
62impl FusedPreset {
63    /// Parse a preset from its serialized byte.
64    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    /// Parse a preset from its stable user-facing label.
74    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    /// Get the stable user-facing label for this preset.
86    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/// Fused shell primitive options.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FusedShellOptions {
112    /// Fused preset to use.
113    pub preset: FusedPreset,
114    /// Override the default chunk size.
115    pub chunk_size: Option<usize>,
116    /// Optional deterministic shell nonce.
117    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/// Lightweight fused shell metadata available without a key.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct FusedShellInfo {
133    /// Envelope version.
134    pub version: u8,
135    /// Selected fused preset.
136    pub preset: FusedPreset,
137    /// Stable preset label.
138    pub preset_label: String,
139    /// Effective chunk size.
140    pub chunk_size: u32,
141    /// Chunk count implied by the payload length.
142    pub chunk_count: usize,
143    /// Obfuscated payload bytes.
144    pub payload_size: usize,
145    /// Total envelope bytes.
146    pub shell_size: usize,
147    /// Header bytes.
148    pub metadata_size: usize,
149    /// Tag bytes.
150    pub tag_size: usize,
151}
152
153/// Serialized fused shell envelope.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FusedShellEnvelope {
156    /// Envelope version.
157    pub version: u8,
158    /// Fused preset id.
159    pub preset: FusedPreset,
160    /// Effective chunk size.
161    pub chunk_size: u32,
162    /// Shell nonce.
163    pub nonce: [u8; SHELL_NONCE_SIZE],
164    /// Shell-transformed bytes.
165    pub payload: Vec<u8>,
166    /// Truncated authentication tag.
167    pub tag: [u8; SHELL_TAG_SIZE],
168}
169
170impl FusedShellEnvelope {
171    /// Serialize the envelope to bytes.
172    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    /// Serialize the envelope without the trailing tag.
179    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    /// Parse a serialized fused shell envelope.
191    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    /// Return lightweight envelope metadata.
228    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/// Protect/open pipeline options.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ProtectOptions {
248    /// Fused preset for the outer shell.
249    pub preset: FusedPreset,
250    /// Compression algorithm preference.
251    pub compression_algorithm: CompressionAlgorithm,
252    /// Compression level.
253    pub compression_level: u32,
254    /// Minimum size before compression is attempted.
255    pub compression_min_size_threshold: usize,
256    /// Preferred encryption algorithm.
257    pub encryption_algorithm: Option<EncryptionAlgorithm>,
258    /// Optional override for shell chunk size.
259    pub shell_chunk_size: Option<usize>,
260    /// Optional deterministic shell nonce.
261    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/// Keyless metadata for a fused protected artifact.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ProtectedArtifactInfo {
283    /// Artifact version.
284    pub version: u8,
285    /// Fused preset id.
286    pub preset: FusedPreset,
287    /// Stable preset label.
288    pub preset_label: String,
289    /// Compression algorithm stored in the artifact.
290    pub compression_algorithm: CompressionAlgorithm,
291    /// Encryption algorithm used for the sealed payload.
292    pub encryption_algorithm: EncryptionAlgorithm,
293    /// Original plaintext size.
294    pub original_size: usize,
295    /// Size after compression and before encryption.
296    pub compressed_size: usize,
297    /// Size of the encrypted bytes before shell transformation.
298    pub encrypted_size: usize,
299    /// Total artifact bytes.
300    pub protected_size: usize,
301    /// Effective shell chunk size.
302    pub shell_chunk_size: u32,
303    /// Shell chunk count.
304    pub shell_chunk_count: usize,
305    /// Shell nonce.
306    pub shell_nonce: [u8; SHELL_NONCE_SIZE],
307}
308
309#[cfg(feature = "compression")]
310/// Result of a protect or repack operation.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct ProtectResult {
313    /// Serialized protected artifact bytes.
314    pub artifact: Vec<u8>,
315    /// Parsed keyless metadata for the returned artifact.
316    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
424/// Derive a 256-bit domain key for shell-related operations.
425pub 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
433/// Derive arbitrary bytes for a shell-related domain.
434pub 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
448/// Derive a per-chunk shell seed from the shell key, nonce, chunk index, and purpose.
449pub 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
464/// Compute a truncated outer tag for shell payloads.
465pub 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
472/// Verify a truncated shell tag in constant time.
473pub 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
481/// Apply the fused shell primitive to arbitrary bytes.
482pub 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
505/// Invert the fused shell primitive.
506pub 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
522/// Serialize a fused shell envelope directly.
523pub 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
531/// Parse and invert a serialized fused shell envelope.
532pub fn unfuse_bytes(data: &[u8], master_key: &Key) -> Result<Vec<u8>> {
533    unfuse(&FusedShellEnvelope::from_bytes(data)?, master_key)
534}
535
536/// Inspect a serialized fused shell envelope without a key.
537pub fn inspect_fused(data: &[u8]) -> Result<FusedShellInfo> {
538    Ok(FusedShellEnvelope::from_bytes(data)?.info())
539}
540
541#[cfg(feature = "compression")]
542/// Run the fused-first Voided v2 full flow.
543pub 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")]
601/// Open a serialized fused protected artifact.
602pub 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")]
642/// Inspect a protected artifact without decrypting it.
643pub fn inspect_artifact(artifact: &[u8]) -> Result<ProtectedArtifactInfo> {
644    Ok(ProtectedArtifactEnvelope::from_bytes(artifact)?.info())
645}
646
647#[cfg(feature = "compression")]
648/// Repack an artifact under a new preset or full-flow configuration.
649pub 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}