Skip to main content

dig_keystore/
password.rs

1//! Password wrapper with `Zeroizing` memory hygiene.
2//!
3//! # Why a dedicated type
4//!
5//! A plain `Vec<u8>` or `String` works as a password carrier but leaves two
6//! specific rough edges:
7//!
8//! 1. **No wipe on drop.** Default `Vec::drop` returns memory to the allocator
9//!    without zeroing. The allocator is free to hand the bytes back unchanged
10//!    to the next allocation, making the password contents recoverable from
11//!    the heap after `Password` is dropped.
12//! 2. **Debug leaks.** `#[derive(Debug)]` on a containing struct would print
13//!    the password bytes to logs. Easy to not notice until your password hits
14//!    the first `tracing::info!` call.
15//!
16//! `Password` solves both:
17//!
18//! - Internally stores `Zeroizing<Vec<u8>>` from the
19//!   [`zeroize`](https://crates.io/crates/zeroize) crate, which wipes the
20//!   buffer on drop using a volatile-write heuristic.
21//! - Has a custom [`Debug`] impl that prints only the byte length.
22//!
23//! # Caveats
24//!
25//! `Zeroizing` is best-effort — a sufficiently motivated optimiser or an OS
26//! page swap can still let the bytes escape. For high-value keys on untrusted
27//! hosts, consider running under `mlock` / `VirtualLock` and disabling swap.
28//!
29//! # References
30//!
31//! - [RustSec Advisory RUSTSEC-2019-0019](https://rustsec.org/advisories/RUSTSEC-2019-0019.html)
32//!   — motivation for the `zeroize` crate.
33//! - [`zeroize` crate](https://docs.rs/zeroize) — the wipe heuristic.
34
35use zeroize::Zeroizing;
36
37/// A password used to unlock a [`crate::Keystore`].
38///
39/// The password is stored in a `Zeroizing<Vec<u8>>`; the underlying memory is
40/// wiped when the `Password` is dropped. `Password` is [`Clone`] so callers may
41/// retain a copy for later use (e.g., re-locking with the same password);
42/// both copies are zeroized on drop.
43///
44/// # Construction
45///
46/// Use any of the `From` impls:
47///
48/// ```
49/// use dig_keystore::Password;
50///
51/// let from_str:     Password = Password::from("abc");
52/// let from_string:  Password = Password::from(String::from("abc"));
53/// let from_slice:   Password = Password::from(b"abc".as_slice());
54/// let from_vec:     Password = Password::from(b"abc".to_vec());
55/// let from_new:     Password = Password::new(b"abc");
56/// # drop((from_str, from_string, from_slice, from_vec, from_new));
57/// ```
58///
59/// # UTF-8 vs arbitrary bytes
60///
61/// `Password` accepts arbitrary byte sequences. Argon2id hashes raw bytes, so
62/// non-UTF-8 passwords work. The optional `password-strength` feature (which
63/// wires [`zxcvbn`](https://docs.rs/zxcvbn)) requires UTF-8 and falls back to
64/// an empty-string score for non-UTF-8 bytes.
65#[derive(Clone)]
66pub struct Password(Zeroizing<Vec<u8>>);
67
68impl Password {
69    /// Wrap any byte sequence as a `Password`.
70    ///
71    /// Copies the input bytes into a freshly-allocated zeroizing buffer. The
72    /// caller's original bytes are not wiped — callers managing particularly
73    /// sensitive memory should zeroize the source themselves after passing it
74    /// to `Password::new`.
75    pub fn new(bytes: impl AsRef<[u8]>) -> Self {
76        Self(Zeroizing::new(bytes.as_ref().to_vec()))
77    }
78
79    /// Borrow the raw password bytes. The returned slice is valid only while
80    /// the `Password` is alive.
81    ///
82    /// Used internally by [`crate::kdf`] to feed Argon2id. External callers
83    /// generally should not need this — prefer handing the `Password` to a
84    /// [`Keystore`](crate::Keystore) method.
85    pub fn as_bytes(&self) -> &[u8] {
86        &self.0
87    }
88
89    /// Length in bytes.
90    pub fn len(&self) -> usize {
91        self.0.len()
92    }
93
94    /// Whether the password is zero bytes long.
95    ///
96    /// Empty passwords are permitted by the library (Argon2id will hash them),
97    /// but they are trivially brute-forced. Treat as an operator error.
98    pub fn is_empty(&self) -> bool {
99        self.0.is_empty()
100    }
101
102    /// Return a [`zxcvbn`](https://docs.rs/zxcvbn) strength estimate for CLI
103    /// helpers.
104    ///
105    /// Returns an entropy score 0–4 with feedback suggestions. This is a very
106    /// rough estimate designed for UX prompts, not a cryptographic guarantee.
107    /// Non-UTF-8 passwords are scored as the empty string (conservative).
108    ///
109    /// Only available with the `password-strength` feature.
110    #[cfg(feature = "password-strength")]
111    pub fn strength(&self) -> zxcvbn::Entropy {
112        let s = std::str::from_utf8(&self.0).unwrap_or("");
113        zxcvbn::zxcvbn(s, &[])
114    }
115}
116
117// ----- From impls -----
118
119impl From<&str> for Password {
120    fn from(s: &str) -> Self {
121        Self::new(s.as_bytes())
122    }
123}
124
125impl From<String> for Password {
126    /// Consumes the `String`, so the UTF-8 buffer is transferred into the
127    /// zeroizing buffer with a single allocation and no leftover copy.
128    fn from(s: String) -> Self {
129        Self(Zeroizing::new(s.into_bytes()))
130    }
131}
132
133impl From<&[u8]> for Password {
134    fn from(bytes: &[u8]) -> Self {
135        Self::new(bytes)
136    }
137}
138
139impl From<Vec<u8>> for Password {
140    /// Consumes the `Vec<u8>`, transferring ownership into the zeroizing
141    /// buffer. Preferred over `Password::new(&vec)` because it avoids the
142    /// double-allocation.
143    fn from(bytes: Vec<u8>) -> Self {
144        Self(Zeroizing::new(bytes))
145    }
146}
147
148impl std::fmt::Debug for Password {
149    /// Never leaks password contents. Prints `Password(<N bytes>)`.
150    ///
151    /// A common mistake is to derive `Debug` on an outer struct that contains
152    /// a `Password`, then log the struct and accidentally print the password.
153    /// This custom `Debug` impl makes that mistake benign.
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "Password({} bytes)", self.0.len())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// **Proves:** `Password::from("hello")` round-trips through `as_bytes()`,
164    /// and the `len` / `is_empty` accessors agree with the contained bytes.
165    ///
166    /// **Why it matters:** Establishes the basic invariant that `Password`
167    /// does not transform its input (no normalization, no trimming, no
168    /// case-folding). The KDF sees bytes identical to what the caller
169    /// passed.
170    ///
171    /// **Catches:** accidental UTF-8 normalization (NFC/NFD) that would
172    /// change the hashed bytes and break keystores across locales.
173    #[test]
174    fn basic_construction() {
175        let p = Password::from("hello");
176        assert_eq!(p.as_bytes(), b"hello");
177        assert_eq!(p.len(), 5);
178        assert!(!p.is_empty());
179    }
180
181    /// **Proves:** the custom `Debug` impl prints the byte length but never
182    /// the password content.
183    ///
184    /// **Why it matters:** Operators routinely `println!("{:?}", &some_struct)`
185    /// or `tracing::info!(?ctx, "starting")`. If that struct contains a
186    /// `Password` and our `Debug` impl leaked the content, passwords would
187    /// surface in logs. This test pins the no-leak property.
188    ///
189    /// **Catches:** accidentally deriving `Debug` on `Password`, or
190    /// formatting the contents with `{:?}` / `{}` inside the custom impl.
191    #[test]
192    fn debug_does_not_leak() {
193        let p = Password::from("supersecret");
194        let s = format!("{:?}", p);
195        assert!(!s.contains("supersecret"));
196        assert!(s.contains("11 bytes"));
197    }
198
199    /// **Proves:** an empty password is a valid construction — `Password::from("")`
200    /// succeeds, `len()` is 0, `is_empty()` is `true`.
201    ///
202    /// **Why it matters:** The library accepts empty passwords (Argon2id
203    /// handles them, keystores can be created with them) even though
204    /// they are a terrible idea. Higher-level CLIs should reject empty
205    /// passwords; this layer must not panic when presented with one.
206    ///
207    /// **Catches:** an overzealous `assert!(!bytes.is_empty())` added at
208    /// construction time that would panic on empty input.
209    #[test]
210    fn empty_password_allowed() {
211        let p = Password::from("");
212        assert!(p.is_empty());
213        assert_eq!(p.len(), 0);
214    }
215
216    /// **Proves:** `Password::clone()` produces an independent copy whose
217    /// bytes match the original.
218    ///
219    /// **Why it matters:** `Password` is `Clone` so callers can retain a
220    /// copy to later re-encrypt or verify — e.g., `change_password(old, new)`
221    /// borrows both by value. Both clones must wipe on drop; here we just
222    /// check they both have valid content.
223    ///
224    /// **Catches:** a regression to a `Clone` impl that shares storage
225    /// (e.g., `Arc`-based) whose drop would wipe *both* copies prematurely.
226    #[test]
227    fn clone_independent() {
228        let p1 = Password::from("abc");
229        let p2 = p1.clone();
230        assert_eq!(p1.as_bytes(), p2.as_bytes());
231    }
232
233    /// **Proves:** the four `From` constructors (`&str`, `String`, `&[u8]`,
234    /// `Vec<u8>`) all produce a `Password` whose bytes equal the input — the
235    /// owning variants (`String`, `Vec<u8>`) preserve the bytes exactly while
236    /// transferring ownership into the zeroizing buffer.
237    ///
238    /// **Why it matters:** Callers reach for whichever conversion their source
239    /// type makes convenient (a CLI prompt yields `String`; a key-file read
240    /// yields `Vec<u8>`). All paths must hash to the same bytes, otherwise a
241    /// password set via one constructor could fail to unlock a keystore that was
242    /// created via another. The `String`/`Vec` impls take a separate
243    /// single-allocation code path (no intermediate copy) that the borrowing
244    /// impls don't exercise.
245    ///
246    /// **Catches:** an owning `From` that truncates, re-encodes, or otherwise
247    /// transforms the moved buffer instead of wrapping it verbatim.
248    #[test]
249    fn all_from_impls_preserve_bytes() {
250        let expected = b"correct horse";
251        assert_eq!(Password::from("correct horse").as_bytes(), expected);
252        assert_eq!(
253            Password::from(String::from("correct horse")).as_bytes(),
254            expected
255        );
256        assert_eq!(
257            Password::from(b"correct horse".as_slice()).as_bytes(),
258            expected
259        );
260        assert_eq!(
261            Password::from(b"correct horse".to_vec()).as_bytes(),
262            expected
263        );
264
265        // Non-UTF-8 bytes survive the byte-oriented constructors unchanged.
266        let raw = vec![0xFF, 0x00, 0xFE];
267        assert_eq!(Password::from(raw.clone()).as_bytes(), raw.as_slice());
268        assert_eq!(Password::from(raw.as_slice()).as_bytes(), raw.as_slice());
269    }
270
271    /// **Proves:** with the `password-strength` feature enabled, `strength()`
272    /// scores a weak password lower than a strong one, and a non-UTF-8 password
273    /// is scored conservatively (as the empty string) rather than panicking.
274    ///
275    /// **Why it matters:** `strength()` backs CLI "your password is weak"
276    /// prompts. It must (a) actually discriminate weak from strong, and (b) never
277    /// panic on arbitrary bytes — `Password` accepts non-UTF-8 input, and the
278    /// strength helper has to degrade gracefully (it lossily decodes to `""`).
279    ///
280    /// **Catches:** a regression that `unwrap()`s the UTF-8 decode (panicking on
281    /// non-UTF-8 bytes), or one that returns a constant score regardless of input.
282    #[cfg(feature = "password-strength")]
283    #[test]
284    fn strength_discriminates_and_tolerates_non_utf8() {
285        let weak = Password::from("aaaaaaaa");
286        let strong = Password::from("9!Kp$3vQ_z@Wm2#L");
287        assert!(weak.strength().score() < strong.strength().score());
288
289        // Invalid UTF-8 must not panic; it is scored as the empty string.
290        let non_utf8 = Password::from(vec![0xFF, 0xFE, 0xFD]);
291        let _ = non_utf8.strength(); // reaching here without a panic is the assertion
292    }
293}