Skip to main content

shadow_crypt_core/v2/
mod.rs

1//! Version 2 of the encryption protocol.
2//!
3//! Like v1 it uses XChaCha20-Poly1305 with Argon2id key derivation and an
4//! identical header layout (with version byte 2). The difference is that v2
5//! authenticates the header: every AEAD operation binds the fixed header
6//! fields (magic, version, salt, KDF parameters, both nonces) as associated
7//! data, with distinct domain-separation tags for the filename and the
8//! content. This prevents an attacker from swapping the filename and content
9//! ciphertexts within a file or tampering with header fields without
10//! detection.
11//!
12//! This module is deliberately independent of [`crate::v1`]: the two formats
13//! share no code, so changes to one can never silently alter the other.
14
15/// Encryption and decryption primitives (AAD-authenticated).
16pub mod crypt;
17
18/// Encrypted file structures.
19pub mod file;
20
21/// Encrypted file operations.
22pub mod file_ops;
23
24/// File header structures and header binding for AAD.
25pub mod header;
26
27/// File header serialization and deserialization.
28pub mod header_ops;
29
30/// Key derivation parameters.
31pub mod key;
32
33/// Key derivation operations.
34pub mod key_ops;
35
36#[cfg(test)]
37mod tests {
38    use crate::v2::{
39        crypt::{decrypt_bytes, encrypt_bytes},
40        header::{AadPurpose, FileHeader, HeaderBinding},
41        key::KeyDerivationParams,
42    };
43
44    /// The v1 weakness this format fixes: filename and content ciphertexts
45    /// are encrypted under the same key, so without domain separation an
46    /// attacker could swap the (nonce, ciphertext) pairs and both would still
47    /// authenticate. In v2, a ciphertext produced for one purpose must never
48    /// decrypt as the other.
49    #[test]
50    fn swapped_filename_and_content_ciphertexts_fail_authentication() {
51        let key = [9u8; 32];
52        let salt = [1u8; 16];
53        let params = KeyDerivationParams::test_defaults();
54        let content_nonce = [2u8; 24];
55        let filename_nonce = [3u8; 24];
56        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
57
58        let (filename_ct, _) = encrypt_bytes(
59            b"secret-name.txt",
60            &key,
61            &filename_nonce,
62            &binding.aad(AadPurpose::Filename),
63        )
64        .unwrap();
65        let (content_ct, _) = encrypt_bytes(
66            b"file content",
67            &key,
68            &content_nonce,
69            &binding.aad(AadPurpose::Content),
70        )
71        .unwrap();
72
73        // Attacker swaps the pairs: content slot holds the filename pair and
74        // vice versa. The header binding still matches (nonces unchanged as a
75        // set), so only the purpose tag distinguishes the two operations.
76        let swapped_content = decrypt_bytes(
77            &filename_ct,
78            &key,
79            &filename_nonce,
80            &binding.aad(AadPurpose::Content),
81        );
82        let swapped_filename = decrypt_bytes(
83            &content_ct,
84            &key,
85            &content_nonce,
86            &binding.aad(AadPurpose::Filename),
87        );
88
89        assert!(swapped_content.is_err());
90        assert!(swapped_filename.is_err());
91    }
92
93    /// Tampering with any authenticated header field must break decryption.
94    #[test]
95    fn tampered_header_fields_fail_authentication() {
96        let key = [9u8; 32];
97        let salt = [1u8; 16];
98        let params = KeyDerivationParams::test_defaults();
99        let content_nonce = [2u8; 24];
100        let filename_nonce = [3u8; 24];
101        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
102
103        let (content_ct, _) = encrypt_bytes(
104            b"file content",
105            &key,
106            &content_nonce,
107            &binding.aad(AadPurpose::Content),
108        )
109        .unwrap();
110
111        // Downgrade the KDF parameters in the header.
112        let weak_params = KeyDerivationParams::new(8, 1, 1, 32);
113        let tampered = HeaderBinding::new(&salt, &weak_params, &content_nonce, &filename_nonce);
114        assert!(
115            decrypt_bytes(
116                &content_ct,
117                &key,
118                &content_nonce,
119                &tampered.aad(AadPurpose::Content),
120            )
121            .is_err()
122        );
123
124        // Swap in a different salt.
125        let other_salt = [7u8; 16];
126        let tampered = HeaderBinding::new(&other_salt, &params, &content_nonce, &filename_nonce);
127        assert!(
128            decrypt_bytes(
129                &content_ct,
130                &key,
131                &content_nonce,
132                &tampered.aad(AadPurpose::Content),
133            )
134            .is_err()
135        );
136    }
137
138    /// Full round trip through header construction, exactly as the shell does it.
139    #[test]
140    fn header_round_trip_decrypts_with_header_binding() {
141        let key = [4u8; 32];
142        let salt = [1u8; 16];
143        let params = KeyDerivationParams::test_defaults();
144        let content_nonce = [2u8; 24];
145        let filename_nonce = [3u8; 24];
146        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
147
148        let (filename_ct, _) = encrypt_bytes(
149            b"name.txt",
150            &key,
151            &filename_nonce,
152            &binding.aad(AadPurpose::Filename),
153        )
154        .unwrap();
155        let (content_ct, _) = encrypt_bytes(
156            b"hello",
157            &key,
158            &content_nonce,
159            &binding.aad(AadPurpose::Content),
160        )
161        .unwrap();
162
163        let header =
164            FileHeader::new(salt, params, content_nonce, filename_nonce, filename_ct).unwrap();
165        let serialized = crate::v2::header_ops::serialize(&header);
166        let parsed = crate::v2::header_ops::try_deserialize(&serialized).unwrap();
167
168        let (name, _) = decrypt_bytes(
169            &parsed.filename_ciphertext,
170            &key,
171            &parsed.filename_nonce,
172            &parsed.binding().aad(AadPurpose::Filename),
173        )
174        .unwrap();
175        let (content, _) = decrypt_bytes(
176            &content_ct,
177            &key,
178            &parsed.content_nonce,
179            &parsed.binding().aad(AadPurpose::Content),
180        )
181        .unwrap();
182
183        assert_eq!(name.as_slice(), b"name.txt");
184        assert_eq!(content.as_slice(), b"hello");
185    }
186}