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}