keynest 0.4.3

Simple, offline, cross-platform secrets manager written in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! TLV-based v2 file format for the keystore.
//!
//! V2 uses TLV (Type-Length-Value) encoding for extensibility.

use super::tlv;
use super::{Header, KeystoreFile, MAGIC, MAGIC_LEN, VER_LEN};
use crate::{
    KdfParams,
    crypto::{SALT_LEN, algorithm::Algorithm},
};
use anyhow::{Result, bail};

/// V2 file format version.
pub const VERSION_V2: u8 = 2;
/// Size of the AEAD authentication tag (Poly1305).
const AEAD_TAG_LEN: usize = 16;
/// Maximum allowed ciphertext size to prevent memory exhaustion attacks.
const MAX_CIPHERTEXT: usize = 16 * 1024 * 1024; // 16 MiB max

/// TLV type identifiers for v2 format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum TlvType {
    /// KDF parameters (memory, time, parallelism)
    Kdf,
    /// Algorithm for encryption
    Algorithm,
    /// Salt for key derivation
    Salt,
    /// Nonce for encryption
    Nonce,
    /// Encrypted ciphertext
    Ciphertext,
    /// Unknown type (for forward compatibility)
    Unknown(u8),
}

impl From<u8> for TlvType {
    fn from(v: u8) -> Self {
        match v {
            1 => Self::Kdf,
            2 => Self::Salt,
            3 => Self::Nonce,
            4 => Self::Ciphertext,
            5 => Self::Algorithm,
            x => Self::Unknown(x),
        }
    }
}

impl From<TlvType> for u8 {
    fn from(t: TlvType) -> u8 {
        match t {
            TlvType::Kdf => 1,
            TlvType::Salt => 2,
            TlvType::Nonce => 3,
            TlvType::Ciphertext => 4,
            TlvType::Algorithm => 5,
            TlvType::Unknown(x) => x,
        }
    }
}

/// Parses a v2 keystore file.
///
/// # Errors
///
/// Returns an error if the file is malformed or required fields are missing.
pub fn parse(data: &[u8]) -> Result<KeystoreFile> {
    if data.len() < MAGIC_LEN + VER_LEN {
        bail!("file too short");
    }

    let tlv_data = &data[MAGIC_LEN + VER_LEN..];
    let tlvs = tlv::decode_all(tlv_data)?;

    let mut kdf: Option<KdfParams> = None;
    let mut algorithm: Option<Algorithm> = None;
    let mut salt: Option<Vec<u8>> = None;
    let mut nonce: Option<Vec<u8>> = None;
    let mut ciphertext: Option<Vec<u8>> = None;

    for t in tlvs {
        match TlvType::from(t.ty()) {
            TlvType::Kdf => {
                if kdf.is_some() {
                    bail!("duplicate KDF field");
                }
                if t.value().len() != 12 {
                    bail!("invalid kdf tlv length");
                }

                let mem = u32::from_le_bytes(t.value()[0..4].try_into()?);
                let time = u32::from_le_bytes(t.value()[4..8].try_into()?);
                let par = u32::from_le_bytes(t.value()[8..12].try_into()?);

                kdf = Some(KdfParams::new(mem, time, par)?);
            }
            TlvType::Algorithm => {
                if algorithm.is_some() {
                    bail!("duplicate algorithm field");
                }

                if t.value().len() != 1 {
                    bail!("invalid algorithm length");
                }

                let id = t.value()[0];
                algorithm = Some(Algorithm::try_from(id)?);
            }
            TlvType::Salt => {
                if salt.is_some() {
                    bail!("duplicate salt field");
                }
                salt = Some(t.value().to_vec());
            }
            TlvType::Nonce => {
                if nonce.is_some() {
                    bail!("duplicate nonce field");
                }
                nonce = Some(t.value().to_vec());
            }
            TlvType::Ciphertext => {
                if ciphertext.is_some() {
                    bail!("duplicate ciphertext field");
                }
                ciphertext = Some(t.value().to_vec());
            }
            TlvType::Unknown(_) => {
                // forward compatibility:
                // ignore unknown TLVs
            }
        }
    }

    let kdf = kdf.ok_or_else(|| anyhow::anyhow!("missing kdf"))?;
    let algorithm = algorithm.ok_or_else(|| anyhow::anyhow!("missing algorithm"))?;
    let salt = salt.ok_or_else(|| anyhow::anyhow!("missing salt"))?;
    let nonce = nonce.ok_or_else(|| anyhow::anyhow!("missing nonce"))?;
    let ciphertext = ciphertext.ok_or_else(|| anyhow::anyhow!("missing ciphertext"))?;

    if salt.len() != SALT_LEN {
        bail!("invalid salt length");
    }

    if nonce.len() != algorithm.nonce_len() {
        bail!("invalid nonce length for algorithm");
    }

    if ciphertext.len() < AEAD_TAG_LEN {
        bail!("ciphertext too short");
    }

    if ciphertext.len() > MAX_CIPHERTEXT {
        bail!("ciphertext too large");
    }

    let header = Header::new(kdf, algorithm, salt, nonce);
    Ok(KeystoreFile::new(header, ciphertext))
}

/// Serializes a KeystoreFile to v2 format bytes using TLV encoding.
///
/// # Errors
///
/// Returns an error if the version is not v2.
pub fn serialize(file: &KeystoreFile) -> Result<Vec<u8>> {
    if file.version() != VERSION_V2 {
        bail!("wrong version for v2 serializer");
    }

    let mut buf = Vec::new();

    // header
    buf.extend_from_slice(MAGIC);
    buf.push(VERSION_V2);

    // TLVs
    let mut kdf_bytes = Vec::with_capacity(12);
    kdf_bytes.extend_from_slice(&file.kdf().mem_cost_kib().to_le_bytes());
    kdf_bytes.extend_from_slice(&file.kdf().time_cost().to_le_bytes());
    kdf_bytes.extend_from_slice(&file.kdf().parallelism().to_le_bytes());

    let algo_id: u8 = file.algorithm().into();

    tlv::encode(TlvType::Kdf.into(), &kdf_bytes, &mut buf);
    tlv::encode(TlvType::Algorithm.into(), &[algo_id], &mut buf);
    tlv::encode(TlvType::Salt.into(), file.salt(), &mut buf);
    tlv::encode(TlvType::Nonce.into(), file.nonce(), &mut buf);
    tlv::encode(TlvType::Ciphertext.into(), file.ciphertext(), &mut buf);

    Ok(buf)
}

/// Builds AAD from header data for authenticated encryption.
///
/// Note: Nonce is NOT included in AAD because it's generated during encryption.
/// The AAD protects KDF params, algorithm, and salt from being modified.
pub(crate) fn build_header_aad(header: &Header) -> Vec<u8> {
    let mut aad = Vec::new();

    aad.extend_from_slice(MAGIC);
    aad.push(VERSION_V2);

    let mut kdf_bytes = Vec::with_capacity(12);
    kdf_bytes.extend_from_slice(&header.kdf().mem_cost_kib().to_le_bytes());
    kdf_bytes.extend_from_slice(&header.kdf().time_cost().to_le_bytes());
    kdf_bytes.extend_from_slice(&header.kdf().parallelism().to_le_bytes());

    let algo_id: u8 = header.algorithm().into();

    tlv::encode(TlvType::Kdf.into(), &kdf_bytes, &mut aad);
    tlv::encode(TlvType::Algorithm.into(), &[algo_id], &mut aad);
    tlv::encode(TlvType::Salt.into(), header.salt(), &mut aad);

    aad
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::crypto::KdfParams;
    use crate::format::{Header, KeystoreFile, MAGIC, parse, serialize};

    #[test]
    fn v2_roundtrip() {
        let header = Header::new(
            KdfParams::new(65536, 3, 2).unwrap(),
            Algorithm::XChaCha20Poly1305,
            vec![1u8; 16],
            vec![2u8; 24],
        );
        let file = KeystoreFile::new(header, vec![3u8; 32]);

        let bytes = serialize(&file).unwrap();

        // Verify magic and version
        assert_eq!(&bytes[..4], MAGIC);
        assert_eq!(bytes[4], VERSION_V2);

        let parsed = parse(&bytes).unwrap();
        assert_eq!(parsed.version(), VERSION_V2);
        assert_eq!(parsed.kdf().mem_cost_kib(), 65536);
        assert_eq!(parsed.kdf().time_cost(), 3);
        assert_eq!(parsed.kdf().parallelism(), 2);
        assert_eq!(parsed.algorithm(), Algorithm::XChaCha20Poly1305);
    }

    #[test]
    fn v2_ignores_unknown_tlv() {
        // Manually construct a v2 file with an unknown TLV (type 99)
        let mut bytes = Vec::new();
        bytes.extend_from_slice(MAGIC);
        bytes.push(VERSION_V2);

        // Add valid KDF TLV first
        let mut kdf_data = vec![0u8; 12];
        kdf_data[..4].copy_from_slice(&65536u32.to_le_bytes()); // mem
        kdf_data[4..8].copy_from_slice(&3u32.to_le_bytes()); // time
        kdf_data[8..12].copy_from_slice(&1u32.to_le_bytes()); // parallelism
        bytes.push(1); // type = Kdf
        bytes.extend_from_slice(&12u16.to_le_bytes());
        bytes.extend_from_slice(&kdf_data);

        // Add Algorithm TLV (type 5)
        bytes.push(5); // type = Algorithm
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.push(1); // XChaCha20Poly1305

        // Add unknown TLV type 99 with length 3
        bytes.push(99); // type
        bytes.extend_from_slice(&3u16.to_le_bytes()); // length
        bytes.extend_from_slice(b"abc"); // value

        // Add required Salt TLV
        bytes.push(2); // type = Salt
        bytes.extend_from_slice(&16u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 16]);

        // Add required Nonce TLV
        bytes.push(3); // type = Nonce
        bytes.extend_from_slice(&24u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 24]);

        // Add required Ciphertext TLV (minimum 16 bytes for AEAD tag)
        bytes.push(4); // type = Ciphertext
        bytes.extend_from_slice(&16u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 16]);

        // Should still parse successfully, ignoring unknown TLV
        let result = parse(&bytes);
        eprintln!("Error: {:?}", result);
        assert!(result.is_ok());
    }

    #[test]
    fn v2_missing_kdf_fails() {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(MAGIC);
        bytes.push(VERSION_V2);

        // Algorithm TLV (has algorithm, but missing kdf)
        bytes.push(5); // type = Algorithm
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.push(1); // XChaCha20Poly1305

        // Salt TLV
        bytes.push(2); // type = Salt
        bytes.extend_from_slice(&16u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 16]);

        // Nonce TLV
        bytes.push(3); // type = Nonce
        bytes.extend_from_slice(&24u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 24]);

        // Ciphertext TLV
        bytes.push(4); // type = Ciphertext
        bytes.extend_from_slice(&10u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 10]);

        let result = parse(&bytes);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("missing kdf"));
    }

    #[test]
    fn v2_empty_ciphertext() {
        let header = Header::new(
            KdfParams::default(),
            Algorithm::XChaCha20Poly1305,
            vec![1u8; 16],
            vec![2u8; 24],
        );
        // Minimum ciphertext is 16 bytes (AEAD tag)
        let file = KeystoreFile::new(header, vec![0u8; 16]);

        let bytes = serialize(&file).unwrap();
        let parsed = parse(&bytes).unwrap();

        assert_eq!(parsed.ciphertext().len(), 16);
        assert_eq!(parsed.algorithm(), Algorithm::XChaCha20Poly1305);
    }

    #[test]
    fn v2_missing_algorithm_fails() {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(MAGIC);
        bytes.push(VERSION_V2);

        // KDF TLV (valid params)
        let mut kdf_data = vec![0u8; 12];
        kdf_data[..4].copy_from_slice(&65536u32.to_le_bytes()); // mem = 64 MiB
        kdf_data[4..8].copy_from_slice(&3u32.to_le_bytes()); // time = 3
        kdf_data[8..12].copy_from_slice(&1u32.to_le_bytes()); // parallelism = 1
        bytes.push(1);
        bytes.extend_from_slice(&12u16.to_le_bytes());
        bytes.extend_from_slice(&kdf_data);

        // Salt TLV
        bytes.push(2);
        bytes.extend_from_slice(&16u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 16]);

        // Nonce TLV
        bytes.push(3);
        bytes.extend_from_slice(&24u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 24]);

        // Ciphertext TLV
        bytes.push(4);
        bytes.extend_from_slice(&5u16.to_le_bytes());
        bytes.extend_from_slice(b"hello");

        let result = parse(&bytes);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        eprintln!("Error: {}", err);
        assert!(err.contains("algorithm"));
    }

    #[test]
    fn v2_invalid_algorithm_fails() {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(MAGIC);
        bytes.push(VERSION_V2);

        // KDF TLV (valid params)
        let mut kdf_data = vec![0u8; 12];
        kdf_data[..4].copy_from_slice(&65536u32.to_le_bytes()); // mem = 64 MiB
        kdf_data[4..8].copy_from_slice(&3u32.to_le_bytes()); // time = 3
        kdf_data[8..12].copy_from_slice(&1u32.to_le_bytes()); // parallelism = 1
        bytes.push(1);
        bytes.extend_from_slice(&12u16.to_le_bytes());
        bytes.extend_from_slice(&kdf_data);

        // Algorithm TLV with invalid ID (99)
        bytes.push(5);
        bytes.extend_from_slice(&1u16.to_le_bytes());
        bytes.push(99); // invalid algorithm

        // Salt TLV
        bytes.push(2);
        bytes.extend_from_slice(&16u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 16]);

        // Nonce TLV
        bytes.push(3);
        bytes.extend_from_slice(&24u16.to_le_bytes());
        bytes.extend_from_slice(&[0u8; 24]);

        // Ciphertext TLV
        bytes.push(4);
        bytes.extend_from_slice(&5u16.to_le_bytes());
        bytes.extend_from_slice(b"hello");

        let result = parse(&bytes);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        eprintln!("Error: {}", err);
        assert!(err.contains("unsupported algorithm"));
    }

    #[test]
    fn aad_authentication_works() {
        use crate::crypto::derive_key;
        use crate::format::{Header, parse, serialize};

        // Create and encrypt with more data
        let kdf = KdfParams::new(65536, 3, 2).unwrap();
        let salt = vec![1u8; 16];
        let key = derive_key("password", &salt, kdf).unwrap();

        let plaintext = b"this is some secret data that is long enough";

        let (header, ciphertext) =
            Header::encrypt_store(kdf, Algorithm::XChaCha20Poly1305, salt, &key, plaintext)
                .unwrap();

        let file = KeystoreFile::new(header, ciphertext);
        let bytes = serialize(&file).unwrap();

        // Verify decryption works
        let parsed = parse(&bytes).unwrap();
        let key2 = derive_key("password", parsed.salt(), *parsed.kdf()).unwrap();
        let decrypted = parsed.decrypt(&key2).unwrap();
        assert_eq!(*decrypted, plaintext);

        // Tamper with ciphertext - should fail
        let mut tampered_bytes = bytes.clone();
        if tampered_bytes.len() > 50 {
            tampered_bytes[50] ^= 0xFF; // flip some bits in ciphertext
        }
        let parsed = parse(&tampered_bytes).unwrap();
        let key3 = derive_key("password", parsed.salt(), *parsed.kdf()).unwrap();
        let result = parsed.decrypt(&key3);
        assert!(
            result.is_err(),
            "decryption should fail with tampered ciphertext"
        );
    }
}