Skip to main content

shadow_crypt_core/v2/
header.rs

1use crate::{errors::HeaderError, v2::key::KeyDerivationParams};
2
3/// Complete v2 file header.
4///
5/// The layout matches v1 byte for byte, but the version byte is 2 and the
6/// fixed fields are authenticated: they are bound as associated data to both
7/// AEAD operations via [`HeaderBinding`].
8#[derive(Debug, Clone)]
9pub struct FileHeader {
10    pub magic: [u8; 6],                  // 6 bytes: "SHADOW"
11    pub version: u8,                     // 1 byte: Version number (2)
12    pub header_length: u32,              // 4 byte: Total header size
13    pub salt: [u8; 16],                  // 16 bytes: Argon2id salt
14    pub kdf_memory: u32,                 // 4 bytes: Argon2id memory parameter
15    pub kdf_iterations: u32,             // 4 bytes: Argon2id iterations parameter
16    pub kdf_parallelism: u32,            // 4 bytes: Argon2id parallelism parameter
17    pub kdf_key_length: u8,              // 1 byte: XChaCha20 key length
18    pub content_nonce: [u8; 24],         // 24 bytes: XChaCha20 nonce
19    pub filename_nonce: [u8; 24],        // 24 bytes: XChaCha20 nonce for filename
20    pub filename_ciphertext_length: u16, // 2 bytes: Length of encrypted filename ciphertext
21    pub filename_ciphertext: Vec<u8>,    // Encrypted filename ciphertext (variable length)
22}
23
24pub const MAGIC: [u8; 6] = *b"SHADOW";
25pub const VERSION: u8 = 2;
26
27impl FileHeader {
28    /// Builds a v2 header. Fails with [`HeaderError::FilenameTooLong`] if the
29    /// filename ciphertext does not fit the u16 length field, instead of
30    /// silently truncating.
31    pub fn new(
32        salt: [u8; 16],
33        kdf_params: KeyDerivationParams,
34        content_nonce: [u8; 24],
35        filename_nonce: [u8; 24],
36        filename_ciphertext: Vec<u8>,
37    ) -> Result<Self, HeaderError> {
38        let filename_ciphertext_length: u16 = filename_ciphertext
39            .len()
40            .try_into()
41            .map_err(|_| HeaderError::FilenameTooLong)?;
42        let size = Self::min_length() + filename_ciphertext.len();
43
44        Ok(FileHeader {
45            magic: MAGIC,
46            version: VERSION,
47            header_length: size as u32,
48            salt,
49            kdf_memory: kdf_params.memory_cost,
50            kdf_iterations: kdf_params.time_cost,
51            kdf_parallelism: kdf_params.parallelism,
52            kdf_key_length: kdf_params.key_size,
53            content_nonce,
54            filename_nonce,
55            filename_ciphertext_length,
56            filename_ciphertext,
57        })
58    }
59
60    /// Minimum length of the header without the variable-length filename ciphertext.
61    /// Changing the fixed fields above requires updating this value.
62    pub fn min_length() -> usize {
63        6  // magic ("SHADOW")
64        + 1  // version (u8)
65        + 4  // header_length (u32)
66        + 16 // salt ([u8; 16])
67        + 4  // kdf_memory (u32)
68        + 4  // kdf_iterations (u32)
69        + 4  // kdf_parallelism (u32)
70        + 1  // kdf_key_length (u8)
71        + 24 // content_nonce ([u8; 24])
72        + 24 // filename_nonce ([u8; 24])
73        + 2 // filename_ciphertext_length (u16)
74    }
75
76    /// The header binding used as associated data for this header's AEAD
77    /// operations.
78    pub fn binding(&self) -> HeaderBinding<'_> {
79        HeaderBinding {
80            salt: &self.salt,
81            kdf_memory: self.kdf_memory,
82            kdf_iterations: self.kdf_iterations,
83            kdf_parallelism: self.kdf_parallelism,
84            kdf_key_length: self.kdf_key_length,
85            content_nonce: &self.content_nonce,
86            filename_nonce: &self.filename_nonce,
87        }
88    }
89}
90
91/// Which ciphertext an AEAD operation belongs to.
92///
93/// The purpose is mixed into the associated data, giving the filename and
94/// content ciphertexts distinct domains: a ciphertext produced for one
95/// purpose can never authenticate for the other, even under the same key.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum AadPurpose {
98    Filename,
99    Content,
100}
101
102impl AadPurpose {
103    fn domain_tag(self) -> &'static [u8] {
104        match self {
105            AadPurpose::Filename => b"shadow-crypt/v2/filename",
106            AadPurpose::Content => b"shadow-crypt/v2/content",
107        }
108    }
109}
110
111/// The fixed header fields bound as associated data to every v2 AEAD
112/// operation.
113///
114/// This exists separately from [`FileHeader`] because on encryption the
115/// associated data is needed *before* the header can be built (the header
116/// contains the filename ciphertext, which is itself AEAD-encrypted under
117/// this binding).
118///
119/// The variable-length fields (`header_length`, `filename_ciphertext_length`,
120/// and the filename ciphertext itself) are deliberately excluded: they depend
121/// on the filename encryption output, and tampering with them is already
122/// detected — a shifted length changes which bytes are interpreted as
123/// ciphertext, which fails authentication.
124#[derive(Debug, Clone, Copy)]
125pub struct HeaderBinding<'a> {
126    pub salt: &'a [u8; 16],
127    pub kdf_memory: u32,
128    pub kdf_iterations: u32,
129    pub kdf_parallelism: u32,
130    pub kdf_key_length: u8,
131    pub content_nonce: &'a [u8; 24],
132    pub filename_nonce: &'a [u8; 24],
133}
134
135impl<'a> HeaderBinding<'a> {
136    pub fn new(
137        salt: &'a [u8; 16],
138        kdf_params: &KeyDerivationParams,
139        content_nonce: &'a [u8; 24],
140        filename_nonce: &'a [u8; 24],
141    ) -> Self {
142        Self {
143            salt,
144            kdf_memory: kdf_params.memory_cost,
145            kdf_iterations: kdf_params.time_cost,
146            kdf_parallelism: kdf_params.parallelism,
147            kdf_key_length: kdf_params.key_size,
148            content_nonce,
149            filename_nonce,
150        }
151    }
152
153    /// Serializes the binding into the associated data for one AEAD
154    /// operation. Field order mirrors the header serialization.
155    pub fn aad(&self, purpose: AadPurpose) -> Vec<u8> {
156        let mut aad = Vec::with_capacity(FileHeader::min_length() + 24);
157        aad.extend_from_slice(&MAGIC);
158        aad.push(VERSION);
159        aad.extend_from_slice(self.salt);
160        aad.extend_from_slice(&self.kdf_memory.to_le_bytes());
161        aad.extend_from_slice(&self.kdf_iterations.to_le_bytes());
162        aad.extend_from_slice(&self.kdf_parallelism.to_le_bytes());
163        aad.push(self.kdf_key_length);
164        aad.extend_from_slice(self.content_nonce);
165        aad.extend_from_slice(self.filename_nonce);
166        aad.extend_from_slice(purpose.domain_tag());
167        aad
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::profile;
175
176    fn get_test_params() -> KeyDerivationParams {
177        KeyDerivationParams::from(profile::SecurityProfile::Test)
178    }
179
180    #[test]
181    fn default_values_are_correct() {
182        let header = FileHeader::new(
183            [0u8; 16],
184            get_test_params(),
185            [0u8; 24],
186            [0u8; 24],
187            vec![1, 2, 3, 4],
188        )
189        .unwrap();
190
191        assert_eq!(&header.magic, b"SHADOW");
192        assert_eq!(header.version, 2);
193    }
194
195    #[test]
196    fn header_size_is_calculated_correctly() {
197        let filename_ciphertext = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
198        let header = FileHeader::new(
199            [0u8; 16],
200            get_test_params(),
201            [0u8; 24],
202            [0u8; 24],
203            filename_ciphertext.clone(),
204        )
205        .unwrap();
206
207        let expected_size: u32 = 90 + filename_ciphertext.len() as u32;
208        assert_eq!(header.header_length, expected_size);
209    }
210
211    #[test]
212    fn oversized_filename_ciphertext_is_rejected() {
213        let filename_ciphertext = vec![0u8; u16::MAX as usize + 1];
214        let result = FileHeader::new(
215            [0u8; 16],
216            get_test_params(),
217            [0u8; 24],
218            [0u8; 24],
219            filename_ciphertext,
220        );
221        assert!(matches!(result, Err(HeaderError::FilenameTooLong)));
222    }
223
224    #[test]
225    fn max_length_filename_ciphertext_is_accepted() {
226        let filename_ciphertext = vec![0u8; u16::MAX as usize];
227        let header = FileHeader::new(
228            [0u8; 16],
229            get_test_params(),
230            [0u8; 24],
231            [0u8; 24],
232            filename_ciphertext,
233        )
234        .unwrap();
235        assert_eq!(header.filename_ciphertext_length, u16::MAX);
236    }
237
238    #[test]
239    fn aad_differs_by_purpose() {
240        let salt = [1u8; 16];
241        let params = get_test_params();
242        let content_nonce = [2u8; 24];
243        let filename_nonce = [3u8; 24];
244        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
245
246        assert_ne!(
247            binding.aad(AadPurpose::Filename),
248            binding.aad(AadPurpose::Content)
249        );
250    }
251
252    #[test]
253    fn header_binding_matches_standalone_binding() {
254        let salt = [1u8; 16];
255        let params = get_test_params();
256        let content_nonce = [2u8; 24];
257        let filename_nonce = [3u8; 24];
258
259        let standalone = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
260        let header =
261            FileHeader::new(salt, params, content_nonce, filename_nonce, vec![1, 2, 3]).unwrap();
262
263        assert_eq!(
264            standalone.aad(AadPurpose::Content),
265            header.binding().aad(AadPurpose::Content)
266        );
267        assert_eq!(
268            standalone.aad(AadPurpose::Filename),
269            header.binding().aad(AadPurpose::Filename)
270        );
271    }
272}