Skip to main content

shadow_crypt_core/v3/
mod.rs

1//! Version 3 of the encryption protocol.
2//!
3//! Like v2 it uses XChaCha20-Poly1305 with Argon2id key derivation and
4//! authenticates the fixed header fields as associated data, with distinct
5//! domain-separation tags. Two things change:
6//!
7//! - **Streaming content.** The content is encrypted as a sequence of AEAD
8//!   chunks (see [`stream`]) instead of one message, so files of any size
9//!   can be processed with bounded memory. The per-chunk nonce carries a
10//!   counter and a final-chunk flag, making reordering, truncation, and
11//!   extension of the stream fail authentication.
12//! - **Metadata envelope.** The header stores one encrypted envelope (see
13//!   [`metadata`]) carrying the original filename plus optional mtime and
14//!   Unix mode, instead of a bare filename ciphertext.
15//!
16//! The intended entry points are [`stream::StreamSealer::begin`] /
17//! [`stream::StreamOpener`] for streaming, and [`file::EncryptedFile`] for
18//! whole-bytes use.
19//!
20//! This module is deliberately independent of [`crate::v1`] and
21//! [`crate::v2`]: the formats share no code, so changes to one can never
22//! silently alter another.
23
24use crate::algorithm::Algorithm;
25
26/// Encryption and decryption primitives (AAD-authenticated).
27pub mod crypt;
28
29/// Encrypted file structures and whole-bytes seal/decrypt operations.
30pub mod file;
31
32/// File header structures, serialization, and header binding for AAD.
33pub mod header;
34
35/// Key derivation parameters and operations.
36pub mod key;
37
38/// Plaintext layout of the encrypted metadata envelope.
39pub mod metadata;
40
41/// Chunked (streaming) content encryption.
42pub mod stream;
43
44/// The AEAD algorithm used by every v3 file.
45pub const ALGORITHM: Algorithm = Algorithm::XChaCha20Poly1305;
46
47#[cfg(test)]
48mod tests {
49    use std::time::{Duration, UNIX_EPOCH};
50
51    use crate::{
52        file::FileMetadata,
53        memory::{SecureKey, SecureString},
54        v3::{file::EncryptedFile, key::KeyDerivationParams},
55    };
56
57    fn test_metadata() -> FileMetadata {
58        FileMetadata::new(
59            SecureString::new("name.txt".to_string()),
60            Some(UNIX_EPOCH + Duration::new(1_700_000_000, 500)),
61            Some(0o640),
62        )
63    }
64
65    fn derive_test_key(password: &[u8], salt: &[u8; 16]) -> SecureKey {
66        let (key, _) = KeyDerivationParams::test_defaults()
67            .derive_key(password, salt)
68            .unwrap();
69        key
70    }
71
72    /// Full round trip through the seal/decrypt façade, including
73    /// serialization to raw bytes and back.
74    #[test]
75    fn seal_decrypt_round_trip_via_bytes() {
76        let salt = [1u8; 16];
77        let key = derive_test_key(b"password", &salt);
78
79        let sealed = EncryptedFile::seal(
80            &test_metadata(),
81            b"hello streaming world",
82            &key,
83            KeyDerivationParams::test_defaults(),
84            salt,
85            [2u8; 16],
86            [3u8; 24],
87        )
88        .unwrap();
89
90        let parsed = EncryptedFile::from_bytes(&sealed.to_bytes()).unwrap();
91        let decrypted = parsed.decrypt(&key).unwrap();
92
93        assert_eq!(decrypted.filename().as_str(), "name.txt");
94        assert_eq!(decrypted.content().as_slice(), b"hello streaming world");
95
96        // The metadata round-trips through the header on its own.
97        let metadata = parsed.header().decrypt_metadata(&key).unwrap();
98        assert_eq!(metadata.mtime(), test_metadata().mtime());
99        assert_eq!(metadata.mode(), Some(0o640));
100    }
101
102    #[test]
103    fn empty_content_round_trip() {
104        let salt = [1u8; 16];
105        let key = derive_test_key(b"password", &salt);
106
107        let sealed = EncryptedFile::seal(
108            &test_metadata(),
109            b"",
110            &key,
111            KeyDerivationParams::test_defaults(),
112            salt,
113            [2u8; 16],
114            [3u8; 24],
115        )
116        .unwrap();
117
118        let decrypted = EncryptedFile::from_bytes(&sealed.to_bytes())
119            .unwrap()
120            .decrypt(&key)
121            .unwrap();
122        assert!(decrypted.content().as_slice().is_empty());
123    }
124
125    #[test]
126    fn seal_decrypt_with_wrong_key_fails() {
127        let salt = [1u8; 16];
128        let key = derive_test_key(b"password", &salt);
129        let wrong_key = derive_test_key(b"wrong", &salt);
130
131        let sealed = EncryptedFile::seal(
132            &test_metadata(),
133            b"content",
134            &key,
135            KeyDerivationParams::test_defaults(),
136            salt,
137            [2u8; 16],
138            [3u8; 24],
139        )
140        .unwrap();
141
142        assert!(sealed.decrypt(&wrong_key).is_err());
143    }
144
145    /// Truncating whole trailing chunks (not just corrupting bytes) must be
146    /// detected via the final-chunk flag.
147    #[test]
148    fn truncated_content_fails() {
149        let salt = [1u8; 16];
150        let key = derive_test_key(b"password", &salt);
151
152        let sealed = EncryptedFile::seal(
153            &test_metadata(),
154            b"some content",
155            &key,
156            KeyDerivationParams::test_defaults(),
157            salt,
158            [2u8; 16],
159            [3u8; 24],
160        )
161        .unwrap();
162
163        let mut bytes = sealed.to_bytes();
164        // Strip the entire content stream, leaving only the valid header.
165        bytes.truncate(sealed.header().header_length());
166        let truncated = EncryptedFile::from_bytes(&bytes).unwrap();
167        assert!(truncated.decrypt(&key).is_err());
168    }
169}