steelsafe 0.2.0

Simple, personal TUI password manager
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
//! Key derivation, encryption, and authentication.

use std::iter;
use serde::Serialize;
use chrono::{DateTime, Utc};
use rand::seq::IndexedRandom;
use zeroize::Zeroizing;
use block_padding::{RawPadding, Iso7816};
use crypto_common::typenum::Unsigned;
use argon2::Argon2;
use chacha20poly1305::{XChaCha20Poly1305, KeyInit, aead::{Aead, Payload, KeySizeUser}};
use crate::Result;


/// The length of the per-item password salt, in bytes.
pub use argon2::RECOMMENDED_SALT_LEN;

/// The length of the per-item authentication nonce, in bytes.
pub const NONCE_LEN: usize = 24;

/// The length of the padding block size, in bytes. The plaintext secret will be
/// padded before encryption, so that its length is a multiple of this block size.
pub const PADDING_BLOCK_SIZE: usize = 256;

/// The set of characters that will be sampled for generating a strong, random password.
/// These are ASCII-only letters, digits, and printable punctuation characters easily
/// available on a US English keyboard and should readily be accepted by most systems.
pub const PASSWORD_CHARSET: &'static [u8] =
    b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,;:!?-+*/%=_@#$^&~()[]{}";

/// The length of randomly generated passwords. This provides log_2(87^40) ~= 257 bits of
/// entropy, or just over 32 bytes, requiring around 10^77 guesses on average using brute
/// force. This should satisfy even the most stringent requirements.
pub const PASSWORD_LEN: usize = 40;

/// The pieces of data that are not encrypted but still validated using the
/// specified encryption password, for tamper detection.
///
/// Fields are in alphabetical order, so that round-tripping through `Value`
/// results in bitwise-identical JSON. (This is a precautionary measure.)
#[derive(Clone, Copy, Debug, Serialize)]
struct AdditionalData<'a> {
    account: Option<&'a str>,
    label: &'a str,
    last_modified_at: DateTime<Utc>,
}

/// The result of encrypting and authenticating the secret, and authenticating
/// the additional data, using the specified password. The salt for the Key
/// Derivation Function and the nonce for the authentication are generated
/// _inside_ the encryption function, so that the API ensures fresh,
/// cryptographically strong random values, so accidental re-use is prevented.
/// This means that the encryption function needs to return these as well.
#[derive(Clone, Debug)]
pub struct EncryptionOutput {
    /// The already-encrypted and authenticated secret.
    pub encrypted_secret: Vec<u8>,
    /// The randomly-generated salt, used for seeding the KDF.
    pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
    /// The randomly-generated nonce, used for initializing the AEAD hash.
    pub auth_nonce: [u8; NONCE_LEN],
}

/// The plain old data input for encryption, except for the password.
#[derive(Clone, Copy, Debug)]
pub struct EncryptionInput<'a> {
    pub plaintext_secret: &'a [u8],
    pub label: &'a str,
    pub account: Option<&'a str>,
    pub last_modified_at: DateTime<Utc>,
}

impl EncryptionInput<'_> {
    /// Encrypts and authenticates the secret, and authenticates the additional data,
    /// using a key derived from the `encryption_password`.
    pub fn encrypt_and_authenticate(self, encryption_password: &[u8]) -> Result<EncryptionOutput> {
        // Pad the secret to a multiple of the block size.
        // Directly extending the String could re-allocate, which would leave
        // the contents of the old allocation in the memory, without zeroizing it.
        // To prevent this, what we do instead is pre-allocate a buffer of the
        // required size, then copy the secret over, and perform the padding in
        // the new buffer.
        let unpadded_secret = self.plaintext_secret;
        let total_len = (unpadded_secret.len() / PADDING_BLOCK_SIZE + 1) * PADDING_BLOCK_SIZE;
        let mut padded_secret = Zeroizing::new(vec![0x00_u8; total_len]);

        padded_secret[..unpadded_secret.len()].copy_from_slice(unpadded_secret);
        Iso7816::raw_pad(padded_secret.as_mut_slice(), unpadded_secret.len());

        // Create the additional authenticated data.
        let additional_data = AdditionalData {
            account: self.account,
            label: self.label,
            last_modified_at: self.last_modified_at,
        };
        let additional_data_str = serde_json::to_string(&additional_data)?;

        // Generate random salt and nonce. `rand::random()` uses a CSPRNG.
        let kdf_salt: [u8; RECOMMENDED_SALT_LEN] = rand::random();
        let auth_nonce: [u8; NONCE_LEN] = rand::random();

        // Create KDF context.
        // This uses recommended parameters (19 MB memory, 2 rounds, 1 degree of parallelism).
        let hasher = Argon2::default();

        // The actual encryption key is cleared (overwritten with all 0s) upon drop.
        let mut key = Zeroizing::new([0_u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
        hasher.hash_password_into(encryption_password, &kdf_salt, &mut *key)?;

        // Create encryption and authentication context.
        let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;

        // Actually perform the encryption and authentication.
        let payload = Payload {
            msg: padded_secret.as_slice(),
            aad: additional_data_str.as_bytes(),
        };
        let encrypted_secret = aead.encrypt(<_>::from(&auth_nonce), payload)?;

        Ok(EncryptionOutput {
            encrypted_secret,
            kdf_salt,
            auth_nonce,
        })
    }
}

/// Plain old data input for decrypting and verifying the secret, and
/// verifying the authenticity  of the non-encrypted additional data.
#[derive(Clone, Copy, Debug)]
pub struct DecryptionInput<'a> {
    pub encrypted_secret: &'a [u8],
    pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
    pub auth_nonce: [u8; NONCE_LEN],
    pub label: &'a str,
    pub account: Option<&'a str>,
    pub last_modified_at: DateTime<Utc>,
}

impl DecryptionInput<'_> {
    /// Decrypts and verifies the secret, and verifies the additional data,
    /// using a key derived from the `decryption_password`.
    pub fn decrypt_and_verify(self, decryption_password: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
        // Re-create the additional authenticated data. This helps detect when
        // the displayed label or account have been tampered with in the database.
        // This **must** be bitwise identical to the data used during encryption.
        let additional_data = AdditionalData {
            account: self.account,
            label: self.label,
            last_modified_at: self.last_modified_at,
        };
        let additional_data_str = serde_json::to_string(&additional_data)?;

        // Create KDF context.
        // This MUST use the same parameters as hashing during encryption.
        let hasher = Argon2::default();

        // The actual encryption key is cleared (overwritten with all 0s) upon drop.
        let mut key = Zeroizing::new([0_u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
        hasher.hash_password_into(decryption_password, &self.kdf_salt, &mut *key)?;

        // Create decryption and verification context.
        let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;

        // Actually perform the decryption and verification.
        let payload = Payload {
            msg: self.encrypted_secret,
            aad: additional_data_str.as_bytes(),
        };
        let plaintext_secret = aead.decrypt(<_>::from(&self.auth_nonce), payload)?;
        let mut plaintext_secret = Zeroizing::new(plaintext_secret);

        // Un-pad the decrypted plaintext
        let unpadded_len = Iso7816::raw_unpad(plaintext_secret.as_slice())?.len();
        plaintext_secret.truncate(unpadded_len);

        Ok(plaintext_secret)
    }
}

/// Randomly generates a cryptographically strong (unpredictable) password.
pub fn generate_password() -> Zeroizing<String> {
    // `thread_rng()` returns a CSPRNG.
    let mut rng = rand::rng();

    iter::from_fn(|| PASSWORD_CHARSET.choose(&mut rng))
        .copied()
        .map(char::from)
        .take(PASSWORD_LEN)
        .collect::<String>()
        .into()
}

#[cfg(test)]
mod tests {
    use chrono::{Utc, Days};
    use rand::{Rng, RngCore, distributions::{Standard, DistString}};
    use zxcvbn::{zxcvbn, Score};
    use crate::error::{Error, Result};
    use super::{EncryptionInput, DecryptionInput, PADDING_BLOCK_SIZE, PASSWORD_LEN};


    #[test]
    fn correct_encryption_and_decryption_succeeds() -> Result<()> {
        let timestamp = Utc::now();
        let mut rng = rand::thread_rng();
        let p0 = vec![]; // empty payload edge case
        let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
        let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
        let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];

        rng.fill_bytes(&mut p1);
        rng.fill_bytes(&mut p2);
        rng.fill_bytes(&mut p3);

        for payload in [p0, p1, p2, p3] {
            let password_len: usize = rng.gen_range(8..64);
            let password = Standard.sample_string(&mut rng, password_len);
            let encryption_input = EncryptionInput {
                plaintext_secret: payload.as_slice(),
                label: "the precise label does not matter",
                account: Some("my uninteresting account name"),
                last_modified_at: timestamp,
            };

            let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
            let decryption_input = DecryptionInput {
                encrypted_secret: output.encrypted_secret.as_slice(),
                kdf_salt: output.kdf_salt,
                auth_nonce: output.auth_nonce,
                label: encryption_input.label,
                account: encryption_input.account,
                last_modified_at: timestamp,
            };
            let decrypted_secret = decryption_input.decrypt_and_verify(password.as_bytes())?;

            assert_eq!(decrypted_secret.as_slice(), payload.as_slice());
        }

        Ok(())
    }

    #[test]
    fn incorrect_password_fails_decryption() -> Result<()> {
        let timestamp = Utc::now();
        let mut rng = rand::thread_rng();
        let p0 = vec![]; // empty payload edge case
        let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
        let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
        let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];

        rng.fill_bytes(&mut p1);
        rng.fill_bytes(&mut p2);
        rng.fill_bytes(&mut p3);

        for payload in [p0, p1, p2, p3] {
            let password_len: usize = rng.gen_range(8..64);
            let password = Standard.sample_string(&mut rng, password_len);
            let encryption_input = EncryptionInput {
                plaintext_secret: payload.as_slice(),
                label: "the precise label does not matter",
                account: Some("my uninteresting account name"),
                last_modified_at: timestamp,
            };

            let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
            let decryption_input = DecryptionInput {
                encrypted_secret: output.encrypted_secret.as_slice(),
                kdf_salt: output.kdf_salt,
                auth_nonce: output.auth_nonce,
                label: encryption_input.label,
                account: encryption_input.account,
                last_modified_at: timestamp,
            };

            let wrong_password = b"this is NOT the right password!";
            let result = decryption_input.decrypt_and_verify(wrong_password);

            assert!(
                matches!(
                    result,
                    Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
                ),
                "unexpected result: {:#?}",
                result,
            );
        }

        Ok(())
    }

    #[test]
    fn altered_additional_data_fails_verification() -> Result<()> {
        let timestamp = Utc::now();
        let mut rng = rand::thread_rng();
        let p0 = vec![]; // empty payload edge case
        let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
        let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
        let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];

        rng.fill_bytes(&mut p1);
        rng.fill_bytes(&mut p2);
        rng.fill_bytes(&mut p3);

        for payload in [p0, p1, p2, p3] {
            let password_len: usize = rng.gen_range(8..64);
            let password = Standard.sample_string(&mut rng, password_len);
            let encryption_input = EncryptionInput {
                plaintext_secret: payload.as_slice(),
                label: "the precise label does not matter",
                account: Some("my uninteresting account name"),
                last_modified_at: timestamp,
            };

            let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;

            // Case #1: the account is altered (None instead of Some)
            {
                let decryption_input = DecryptionInput {
                    encrypted_secret: output.encrypted_secret.as_slice(),
                    kdf_salt: output.kdf_salt,
                    auth_nonce: output.auth_nonce,
                    label: encryption_input.label,
                    account: None,
                    last_modified_at: timestamp,
                };

                let result = decryption_input.decrypt_and_verify(password.as_bytes());

                assert!(
                    matches!(
                        result,
                        Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
                    ),
                    "unexpected result: {:#?}",
                    result,
                );
            }

            // Case #2: the label is (slightly) altered
            {
                let decryption_input = DecryptionInput {
                    encrypted_secret: output.encrypted_secret.as_slice(),
                    kdf_salt: output.kdf_salt,
                    auth_nonce: output.auth_nonce,
                    label: &encryption_input.label[1..],
                    account: encryption_input.account,
                    last_modified_at: timestamp,
                };

                let result = decryption_input.decrypt_and_verify(password.as_bytes());

                assert!(
                    matches!(
                        result,
                        Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
                    ),
                    "unexpected result: {:#?}",
                    result,
                );
            }

            // Case #2: the last modification date is tampered with
            {
                let decryption_input = DecryptionInput {
                    encrypted_secret: output.encrypted_secret.as_slice(),
                    kdf_salt: output.kdf_salt,
                    auth_nonce: output.auth_nonce,
                    label: encryption_input.label,
                    account: encryption_input.account,
                    last_modified_at: timestamp.checked_sub_days(Days::new(1)).unwrap(),
                };

                let result = decryption_input.decrypt_and_verify(password.as_bytes());

                assert!(
                    matches!(
                        result,
                        Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
                    ),
                    "unexpected result: {:#?}",
                    result,
                );
            }
        }

        Ok(())
    }

    #[test]
    fn generated_password_is_strong() {
        for _ in 0..1024 {
            let password = super::generate_password();

            assert_eq!(password.len(), PASSWORD_LEN);

            let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
            let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
            let has_digit = password.chars().any(|c| c.is_ascii_digit());
            let has_punct = password.chars().any(|c| c.is_ascii_punctuation());

            let char_class_count =
                u32::from(has_lower) + u32::from(has_upper) + u32::from(has_digit) + u32::from(has_punct);

            // Digits are not always found because their probability is relatively low,
            // so assert that at least a reasonable variety of characters is exhibited.
            assert!(char_class_count >= 3);

            // Ensure that all characters are from the specified set.
            assert!(
                password.chars().all(|c| {
                    !c.is_ascii_control() && (
                        c.is_ascii_lowercase() ||
                        c.is_ascii_uppercase() ||
                        c.is_ascii_digit() ||
                        c.is_ascii_punctuation()
                    )
                })
            );

            // Evaluate password using the `zxcvbn` algorithm. It should never get anything
            // but the maximal score, and it should not trigger any warnings/suggestions.
            let entropy = zxcvbn(password.as_str(), &[]);
            assert_eq!(entropy.score(), Score::Four);
            assert!(entropy.feedback().is_none());
        }
    }
}