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 format is legacy: new files are always written as v3, and v2
13//! support exists to decrypt and list existing files. The intended entry
14//! points are [`file::EncryptedFile::seal`] (kept for round-trip tests) and
15//! [`file::EncryptedFile::decrypt`], which own the AEAD choreography
16//! (nonce/ciphertext pairing, domain separation, header binding); the
17//! submodules expose the underlying pieces.
18//!
19//! This module is deliberately independent of [`crate::v1`]: the two formats
20//! share no code, so changes to one can never silently alter the other.
21
22use crate::algorithm::Algorithm;
23
24/// Encryption and decryption primitives (AAD-authenticated).
25pub mod crypt;
26
27/// Encrypted file structures and whole-file seal/decrypt operations.
28pub mod file;
29
30/// File header structures, serialization, and header binding for AAD.
31pub mod header;
32
33/// Key derivation parameters and operations.
34pub mod key;
35
36/// The AEAD algorithm used by every v2 file.
37pub const ALGORITHM: Algorithm = Algorithm::XChaCha20Poly1305;
38
39#[cfg(test)]
40mod tests {
41    use crate::{
42        file::PlaintextFile,
43        memory::{SecureBytes, SecureString},
44        v2::{
45            crypt::{decrypt_bytes, encrypt_bytes},
46            file::EncryptedFile,
47            header::{AadPurpose, HeaderBinding},
48            key::KeyDerivationParams,
49        },
50    };
51
52    /// The v1 weakness this format fixes: filename and content ciphertexts
53    /// are encrypted under the same key, so without domain separation an
54    /// attacker could swap the (nonce, ciphertext) pairs and both would still
55    /// authenticate. In v2, a ciphertext produced for one purpose must never
56    /// decrypt as the other.
57    #[test]
58    fn swapped_filename_and_content_ciphertexts_fail_authentication() {
59        let key = [9u8; 32];
60        let salt = [1u8; 16];
61        let params = KeyDerivationParams::test_defaults();
62        let content_nonce = [2u8; 24];
63        let filename_nonce = [3u8; 24];
64        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
65
66        let (filename_ct, _) = encrypt_bytes(
67            b"secret-name.txt",
68            &key,
69            &filename_nonce,
70            &binding.aad(AadPurpose::Filename),
71        )
72        .unwrap();
73        let (content_ct, _) = encrypt_bytes(
74            b"file content",
75            &key,
76            &content_nonce,
77            &binding.aad(AadPurpose::Content),
78        )
79        .unwrap();
80
81        // Attacker swaps the pairs: content slot holds the filename pair and
82        // vice versa. The header binding still matches (nonces unchanged as a
83        // set), so only the purpose tag distinguishes the two operations.
84        let swapped_content = decrypt_bytes(
85            &filename_ct,
86            &key,
87            &filename_nonce,
88            &binding.aad(AadPurpose::Content),
89        );
90        let swapped_filename = decrypt_bytes(
91            &content_ct,
92            &key,
93            &content_nonce,
94            &binding.aad(AadPurpose::Filename),
95        );
96
97        assert!(swapped_content.is_err());
98        assert!(swapped_filename.is_err());
99    }
100
101    /// Tampering with any authenticated header field must break decryption.
102    #[test]
103    fn tampered_header_fields_fail_authentication() {
104        let key = [9u8; 32];
105        let salt = [1u8; 16];
106        let params = KeyDerivationParams::test_defaults();
107        let content_nonce = [2u8; 24];
108        let filename_nonce = [3u8; 24];
109        let binding = HeaderBinding::new(&salt, &params, &content_nonce, &filename_nonce);
110
111        let (content_ct, _) = encrypt_bytes(
112            b"file content",
113            &key,
114            &content_nonce,
115            &binding.aad(AadPurpose::Content),
116        )
117        .unwrap();
118
119        // Downgrade the KDF parameters in the header.
120        let weak_params = KeyDerivationParams::new(8, 1, 1, 32);
121        let tampered = HeaderBinding::new(&salt, &weak_params, &content_nonce, &filename_nonce);
122        assert!(
123            decrypt_bytes(
124                &content_ct,
125                &key,
126                &content_nonce,
127                &tampered.aad(AadPurpose::Content),
128            )
129            .is_err()
130        );
131
132        // Swap in a different salt.
133        let other_salt = [7u8; 16];
134        let tampered = HeaderBinding::new(&other_salt, &params, &content_nonce, &filename_nonce);
135        assert!(
136            decrypt_bytes(
137                &content_ct,
138                &key,
139                &content_nonce,
140                &tampered.aad(AadPurpose::Content),
141            )
142            .is_err()
143        );
144    }
145
146    /// Full round trip through the seal/decrypt façade, including
147    /// serialization to raw bytes and back.
148    #[test]
149    fn seal_decrypt_round_trip_via_bytes() {
150        let salt = [1u8; 16];
151        let params = KeyDerivationParams::test_defaults();
152        let (key, _) = params.derive_key(b"password", &salt).unwrap();
153
154        let plaintext_file = PlaintextFile::new(
155            SecureString::new("name.txt".to_string()),
156            SecureBytes::new(b"hello".to_vec()),
157        );
158
159        let sealed =
160            EncryptedFile::seal(&plaintext_file, &key, params, salt, [2u8; 24], [3u8; 24]).unwrap();
161
162        let parsed = EncryptedFile::from_bytes(&sealed.to_bytes()).unwrap();
163        let decrypted = parsed.decrypt(&key).unwrap();
164
165        assert_eq!(decrypted.filename().as_str(), "name.txt");
166        assert_eq!(decrypted.content().as_slice(), b"hello");
167    }
168
169    /// Decrypting with a key derived from the wrong password must fail.
170    #[test]
171    fn seal_decrypt_with_wrong_key_fails() {
172        let salt = [1u8; 16];
173        let params = KeyDerivationParams::test_defaults();
174        let (key, _) = params.derive_key(b"password", &salt).unwrap();
175        let (wrong_key, _) = params.derive_key(b"wrong", &salt).unwrap();
176
177        let plaintext_file = PlaintextFile::new(
178            SecureString::new("name.txt".to_string()),
179            SecureBytes::new(b"hello".to_vec()),
180        );
181
182        let sealed =
183            EncryptedFile::seal(&plaintext_file, &key, params, salt, [2u8; 24], [3u8; 24]).unwrap();
184
185        assert!(sealed.decrypt(&wrong_key).is_err());
186    }
187}