Skip to main content

linguasteg_core/
crypto.rs

1use argon2::{Algorithm, Argon2, Params, Version};
2use chacha20poly1305::aead::{Aead, KeyInit};
3use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
4
5pub type CryptoEnvelopeResult<T> = Result<T, CryptoEnvelopeError>;
6
7const ENVELOPE_MAGIC: [u8; 4] = *b"LSTG";
8const ENVELOPE_VERSION_V1: u8 = 1;
9const KDF_ARGON2ID: u8 = 1;
10const AEAD_XCHACHA20POLY1305: u8 = 1;
11const SALT_LEN: usize = 16;
12const NONCE_LEN: usize = 24;
13const KEY_LEN: usize = 32;
14const HEADER_LEN: usize = 13;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct KeyDerivationParams {
18    pub memory_kib: u32,
19    pub iterations: u32,
20    pub parallelism: u32,
21}
22
23impl Default for KeyDerivationParams {
24    fn default() -> Self {
25        Self {
26            memory_kib: 19_456,
27            iterations: 2,
28            parallelism: 1,
29        }
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct CryptoEnvelopeConfig {
35    pub kdf: KeyDerivationParams,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum CryptoEnvelopeError {
40    SecretRequired,
41    RandomnessUnavailable(String),
42    InvalidEnvelope(String),
43    UnsupportedVersion(u8),
44    UnsupportedAlgorithms { kdf: u8, aead: u8 },
45    KeyDerivationFailed(String),
46    EncryptFailed,
47    DecryptFailed,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CryptoEnvelopeMetadata {
52    pub version: u8,
53    pub kdf: u8,
54    pub aead: u8,
55    pub salt_len: u8,
56    pub nonce_len: u8,
57    pub ciphertext_len: u32,
58    pub total_len: usize,
59}
60
61impl CryptoEnvelopeMetadata {
62    pub fn kdf_name(&self) -> &'static str {
63        kdf_name(self.kdf)
64    }
65
66    pub fn aead_name(&self) -> &'static str {
67        aead_name(self.aead)
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum CryptoEnvelopeInspection {
73    NotEnvelope,
74    Metadata(CryptoEnvelopeMetadata),
75    Invalid(String),
76}
77
78impl core::fmt::Display for CryptoEnvelopeError {
79    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        match self {
81            Self::SecretRequired => write!(f, "secret is required"),
82            Self::RandomnessUnavailable(message) => {
83                write!(f, "randomness source is unavailable: {message}")
84            }
85            Self::InvalidEnvelope(message) => write!(f, "invalid crypto envelope: {message}"),
86            Self::UnsupportedVersion(version) => {
87                write!(f, "unsupported crypto envelope version: {version}")
88            }
89            Self::UnsupportedAlgorithms { kdf, aead } => {
90                write!(f, "unsupported crypto algorithms: kdf={kdf}, aead={aead}")
91            }
92            Self::KeyDerivationFailed(message) => {
93                write!(f, "failed to derive encryption key: {message}")
94            }
95            Self::EncryptFailed => write!(f, "failed to encrypt payload"),
96            Self::DecryptFailed => write!(f, "failed to decrypt payload"),
97        }
98    }
99}
100
101impl std::error::Error for CryptoEnvelopeError {}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104struct EnvelopeHeader {
105    version: u8,
106    kdf: u8,
107    aead: u8,
108    salt_len: u8,
109    nonce_len: u8,
110    ciphertext_len: u32,
111}
112
113pub fn seal_payload(payload: &[u8], key_material: &[u8]) -> CryptoEnvelopeResult<Vec<u8>> {
114    seal_payload_with_config(payload, key_material, &CryptoEnvelopeConfig::default())
115}
116
117pub fn seal_payload_with_config(
118    payload: &[u8],
119    key_material: &[u8],
120    config: &CryptoEnvelopeConfig,
121) -> CryptoEnvelopeResult<Vec<u8>> {
122    ensure_key_material_present(key_material)?;
123
124    let kdf_salt = fill_random_bytes(SALT_LEN)?;
125    let aead_nonce = fill_random_bytes(NONCE_LEN)?;
126
127    seal_payload_with_material(payload, key_material, config, &kdf_salt, &aead_nonce)
128}
129
130pub fn open_payload(envelope: &[u8], key_material: &[u8]) -> CryptoEnvelopeResult<Vec<u8>> {
131    open_payload_with_config(envelope, key_material, &CryptoEnvelopeConfig::default())
132}
133
134pub fn open_payload_with_config(
135    envelope: &[u8],
136    key_material: &[u8],
137    config: &CryptoEnvelopeConfig,
138) -> CryptoEnvelopeResult<Vec<u8>> {
139    ensure_key_material_present(key_material)?;
140
141    let header = parse_header(envelope)?;
142    if header.version != ENVELOPE_VERSION_V1 {
143        return Err(CryptoEnvelopeError::UnsupportedVersion(header.version));
144    }
145    if header.kdf != KDF_ARGON2ID || header.aead != AEAD_XCHACHA20POLY1305 {
146        return Err(CryptoEnvelopeError::UnsupportedAlgorithms {
147            kdf: header.kdf,
148            aead: header.aead,
149        });
150    }
151    if usize::from(header.salt_len) != SALT_LEN {
152        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
153            "expected salt length {SALT_LEN}, got {}",
154            header.salt_len
155        )));
156    }
157    if usize::from(header.nonce_len) != NONCE_LEN {
158        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
159            "expected nonce length {NONCE_LEN}, got {}",
160            header.nonce_len
161        )));
162    }
163
164    let body_len = usize::from(header.salt_len)
165        + usize::from(header.nonce_len)
166        + usize::try_from(header.ciphertext_len).map_err(|_| {
167            CryptoEnvelopeError::InvalidEnvelope(
168                "ciphertext length does not fit in usize".to_string(),
169            )
170        })?;
171    let expected_len = HEADER_LEN + body_len;
172    if envelope.len() != expected_len {
173        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
174            "expected total envelope length {expected_len}, got {}",
175            envelope.len()
176        )));
177    }
178
179    let salt_start = HEADER_LEN;
180    let salt_end = salt_start + SALT_LEN;
181    let nonce_end = salt_end + NONCE_LEN;
182
183    let kdf_salt = &envelope[salt_start..salt_end];
184    let aead_nonce = &envelope[salt_end..nonce_end];
185
186    let ciphertext = &envelope[nonce_end..];
187    let key = derive_key(key_material, kdf_salt, &config.kdf)?;
188    let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
189    let plaintext = cipher
190        .decrypt(XNonce::from_slice(aead_nonce), ciphertext)
191        .map_err(|_| CryptoEnvelopeError::DecryptFailed)?;
192
193    Ok(plaintext)
194}
195
196pub fn inspect_envelope(payload: &[u8]) -> CryptoEnvelopeInspection {
197    if payload.len() < ENVELOPE_MAGIC.len() || payload[0..ENVELOPE_MAGIC.len()] != ENVELOPE_MAGIC {
198        return CryptoEnvelopeInspection::NotEnvelope;
199    }
200
201    let header = match parse_header(payload) {
202        Ok(header) => header,
203        Err(error) => return CryptoEnvelopeInspection::Invalid(error.to_string()),
204    };
205
206    let body_len = usize::from(header.salt_len)
207        + usize::from(header.nonce_len)
208        + match usize::try_from(header.ciphertext_len) {
209            Ok(length) => length,
210            Err(_) => {
211                return CryptoEnvelopeInspection::Invalid(
212                    "ciphertext length does not fit in usize".to_string(),
213                );
214            }
215        };
216    let expected_len = HEADER_LEN + body_len;
217    if payload.len() != expected_len {
218        return CryptoEnvelopeInspection::Invalid(format!(
219            "expected total envelope length {expected_len}, got {}",
220            payload.len()
221        ));
222    }
223
224    CryptoEnvelopeInspection::Metadata(CryptoEnvelopeMetadata {
225        version: header.version,
226        kdf: header.kdf,
227        aead: header.aead,
228        salt_len: header.salt_len,
229        nonce_len: header.nonce_len,
230        ciphertext_len: header.ciphertext_len,
231        total_len: expected_len,
232    })
233}
234
235fn kdf_name(id: u8) -> &'static str {
236    match id {
237        KDF_ARGON2ID => "argon2id",
238        _ => "unknown",
239    }
240}
241
242fn aead_name(id: u8) -> &'static str {
243    match id {
244        AEAD_XCHACHA20POLY1305 => "xchacha20poly1305",
245        _ => "unknown",
246    }
247}
248
249fn seal_payload_with_material(
250    payload: &[u8],
251    key_material: &[u8],
252    config: &CryptoEnvelopeConfig,
253    kdf_salt: &[u8],
254    aead_nonce: &[u8],
255) -> CryptoEnvelopeResult<Vec<u8>> {
256    validate_material_lengths(kdf_salt, aead_nonce)?;
257    let key = derive_key(key_material, kdf_salt, &config.kdf)?;
258    let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
259    let ciphertext = cipher
260        .encrypt(XNonce::from_slice(aead_nonce), payload)
261        .map_err(|_| CryptoEnvelopeError::EncryptFailed)?;
262    let ciphertext_len: u32 = ciphertext.len().try_into().map_err(|_| {
263        CryptoEnvelopeError::InvalidEnvelope("ciphertext length exceeds u32::MAX".to_string())
264    })?;
265
266    let mut envelope = Vec::with_capacity(HEADER_LEN + SALT_LEN + NONCE_LEN + ciphertext.len());
267    envelope.extend_from_slice(&ENVELOPE_MAGIC);
268    envelope.push(ENVELOPE_VERSION_V1);
269    envelope.push(KDF_ARGON2ID);
270    envelope.push(AEAD_XCHACHA20POLY1305);
271    envelope.push(SALT_LEN as u8);
272    envelope.push(NONCE_LEN as u8);
273    envelope.extend_from_slice(&ciphertext_len.to_be_bytes());
274    envelope.extend_from_slice(kdf_salt);
275    envelope.extend_from_slice(aead_nonce);
276    envelope.extend_from_slice(&ciphertext);
277
278    Ok(envelope)
279}
280
281fn parse_header(envelope: &[u8]) -> CryptoEnvelopeResult<EnvelopeHeader> {
282    if envelope.len() < HEADER_LEN {
283        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
284            "envelope is too short: {} bytes",
285            envelope.len()
286        )));
287    }
288
289    if envelope[0..4] != ENVELOPE_MAGIC {
290        return Err(CryptoEnvelopeError::InvalidEnvelope(
291            "magic bytes mismatch".to_string(),
292        ));
293    }
294
295    let ciphertext_len =
296        u32::from_be_bytes([envelope[9], envelope[10], envelope[11], envelope[12]]);
297    Ok(EnvelopeHeader {
298        version: envelope[4],
299        kdf: envelope[5],
300        aead: envelope[6],
301        salt_len: envelope[7],
302        nonce_len: envelope[8],
303        ciphertext_len,
304    })
305}
306
307fn derive_key(
308    key_material: &[u8],
309    kdf_salt: &[u8],
310    params: &KeyDerivationParams,
311) -> CryptoEnvelopeResult<[u8; KEY_LEN]> {
312    let argon_params = Params::new(
313        params.memory_kib,
314        params.iterations,
315        params.parallelism,
316        Some(KEY_LEN),
317    )
318    .map_err(|error| CryptoEnvelopeError::KeyDerivationFailed(error.to_string()))?;
319    let mut key = [0u8; KEY_LEN];
320    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params);
321    argon
322        .hash_password_into(key_material, kdf_salt, &mut key)
323        .map_err(|error| CryptoEnvelopeError::KeyDerivationFailed(error.to_string()))?;
324    Ok(key)
325}
326
327fn fill_random(buf: &mut [u8]) -> CryptoEnvelopeResult<()> {
328    getrandom::getrandom(buf)
329        .map_err(|error| CryptoEnvelopeError::RandomnessUnavailable(error.to_string()))
330}
331
332fn fill_random_bytes(length: usize) -> CryptoEnvelopeResult<Vec<u8>> {
333    let mut bytes = vec![0u8; length];
334    fill_random(&mut bytes)?;
335    Ok(bytes)
336}
337
338fn validate_material_lengths(kdf_salt: &[u8], aead_nonce: &[u8]) -> CryptoEnvelopeResult<()> {
339    if kdf_salt.len() != SALT_LEN {
340        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
341            "expected salt length {SALT_LEN}, got {}",
342            kdf_salt.len()
343        )));
344    }
345    if aead_nonce.len() != NONCE_LEN {
346        return Err(CryptoEnvelopeError::InvalidEnvelope(format!(
347            "expected nonce length {NONCE_LEN}, got {}",
348            aead_nonce.len()
349        )));
350    }
351    Ok(())
352}
353
354fn ensure_key_material_present(key_material: &[u8]) -> CryptoEnvelopeResult<()> {
355    if key_material.is_empty() {
356        return Err(CryptoEnvelopeError::SecretRequired);
357    }
358    Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363    use super::{
364        CryptoEnvelopeConfig, CryptoEnvelopeError, CryptoEnvelopeInspection, NONCE_LEN, SALT_LEN,
365        inspect_envelope, open_payload, parse_header, seal_payload, seal_payload_with_config,
366    };
367
368    #[test]
369    fn seal_and_open_roundtrip_preserves_payload() {
370        let payload = b"salam donya";
371        let secret = b"correct horse battery staple";
372
373        let envelope = seal_payload(payload, secret).expect("seal should succeed");
374        let restored = open_payload(&envelope, secret).expect("open should succeed");
375
376        assert_eq!(restored, payload);
377    }
378
379    #[test]
380    fn open_fails_when_secret_is_wrong() {
381        let payload = b"private payload";
382        let envelope = seal_payload(payload, b"right-secret").expect("seal should succeed");
383
384        let result = open_payload(&envelope, b"wrong-secret");
385        assert!(matches!(result, Err(CryptoEnvelopeError::DecryptFailed)));
386    }
387
388    #[test]
389    fn open_fails_for_tampered_ciphertext() {
390        let payload = b"sensitive";
391        let secret = b"top-secret";
392        let mut envelope = seal_payload(payload, secret).expect("seal should succeed");
393        let last = envelope.len() - 1;
394        envelope[last] ^= 0x01;
395
396        let result = open_payload(&envelope, secret);
397        assert!(matches!(result, Err(CryptoEnvelopeError::DecryptFailed)));
398    }
399
400    #[test]
401    fn open_rejects_unsupported_version() {
402        let payload = b"payload";
403        let secret = b"secret";
404        let mut envelope = seal_payload(payload, secret).expect("seal should succeed");
405        envelope[4] = 9;
406
407        let result = open_payload(&envelope, secret);
408        assert!(matches!(
409            result,
410            Err(CryptoEnvelopeError::UnsupportedVersion(9))
411        ));
412    }
413
414    #[test]
415    fn open_rejects_empty_secret() {
416        let result = seal_payload(b"payload", b"");
417        assert!(matches!(result, Err(CryptoEnvelopeError::SecretRequired)));
418    }
419
420    #[test]
421    fn envelope_header_is_versioned_and_length_prefixed() {
422        let payload = b"abc";
423        let secret = b"secret";
424        let config = CryptoEnvelopeConfig::default();
425        let envelope = seal_payload_with_config(payload, secret, &config)
426            .expect("seal with material should succeed");
427
428        let header = parse_header(&envelope).expect("header should parse");
429        assert_eq!(header.version, 1);
430        assert_eq!(header.kdf, 1);
431        assert_eq!(header.aead, 1);
432        assert_eq!(usize::from(header.salt_len), SALT_LEN);
433        assert_eq!(usize::from(header.nonce_len), NONCE_LEN);
434    }
435
436    #[test]
437    fn open_rejects_invalid_magic() {
438        let payload = b"payload";
439        let secret = b"secret";
440        let mut envelope = seal_payload(payload, secret).expect("seal should succeed");
441        envelope[0] = b'X';
442
443        let result = open_payload(&envelope, secret);
444        assert!(matches!(
445            result,
446            Err(CryptoEnvelopeError::InvalidEnvelope(_))
447        ));
448    }
449
450    #[test]
451    fn inspect_envelope_reports_metadata_for_valid_envelope() {
452        let envelope = seal_payload(b"payload", b"secret").expect("seal should succeed");
453        let inspection = inspect_envelope(&envelope);
454        match inspection {
455            CryptoEnvelopeInspection::Metadata(metadata) => {
456                assert_eq!(metadata.version, 1);
457                assert_eq!(metadata.kdf_name(), "argon2id");
458                assert_eq!(metadata.aead_name(), "xchacha20poly1305");
459                assert_eq!(metadata.total_len, envelope.len());
460            }
461            _ => panic!("expected valid metadata inspection"),
462        }
463    }
464
465    #[test]
466    fn inspect_envelope_reports_not_envelope_for_plain_payload() {
467        let inspection = inspect_envelope(b"plain-text");
468        assert!(matches!(inspection, CryptoEnvelopeInspection::NotEnvelope));
469    }
470}