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}