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}