steid

@jamesgill /

steid/src/domain/password_reset.rs
Rust8.2 KBCode·Blame·History·Raw
1//! One-shot password reset tokens — the credential that stands in for a forgotten
2//! password.
3//!
4//! Modelled on [`PersonalAccessToken`](super::PersonalAccessToken) and deliberately
5//! stricter: a reset token is hashed at rest, lives half an hour, and works once. A
6//! personal access token is a credential someone chose to keep; this one is a way back
7//! in that must stop being one the moment it has been used.
8
9use std::{
10 fmt,
11 time::{Duration, SystemTime},
12};
13
14use sha2::{Digest, Sha256};
15use subtle::ConstantTimeEq;
16
17use super::{ResetId, UserId};
18
19/// A reset token in the clear.
20///
21/// Exists for exactly as long as it takes to put it in a link. Nothing stores this —
22/// the database holds only a [`ResetHash`] — so a link that is lost is gone, and the
23/// only way back is to ask for another.
24///
25/// No product prefix, unlike a personal access token: this value is a URL path segment
26/// that a person reads off a mail and sometimes retypes, and the leaked-secret scanning
27/// a prefix buys is worth much less for a credential that dies in thirty minutes.
28#[derive(Clone)]
29pub struct ResetSecret(String);
30
31impl ResetSecret {
32 /// Bytes of entropy, matching [`TokenSecret`](super::TokenSecret) and
33 /// [`SetupToken`](super::SetupToken).
34 const BYTES: usize = 32;
35
36 /// Mints a fresh token from the OS random source.
37 pub fn generate() -> Self {
38 use rand::Rng;
39
40 let mut bytes = [0u8; Self::BYTES];
41 rand::rng().fill_bytes(&mut bytes);
42
43 Self(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
44 }
45
46 /// Wraps a value out of a URL, which may be anything at all.
47 pub fn from_presented(value: impl Into<String>) -> Self {
48 Self(value.into())
49 }
50
51 /// The token's hash, as stored.
52 pub fn hash(&self) -> ResetHash {
53 ResetHash::of(&self.0)
54 }
55
56 /// The token itself, to be put in exactly one link and then forgotten.
57 pub fn reveal(&self) -> &str {
58 &self.0
59 }
60}
61
62/// Redacted, so a reset token cannot reach a log line except through
63/// [`reveal`](Self::reveal) — which only the link builder calls.
64impl fmt::Debug for ResetSecret {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str("ResetSecret(redacted)")
67 }
68}
69
70/// The SHA-256 of a reset token, hex-encoded.
71///
72/// SHA-256 rather than Argon2 for the reason
73/// [`TokenHash`](super::TokenHash) gives: the value is 256 bits from the OS random
74/// source, so there is no dictionary for a slow hash to defend against.
75#[derive(Clone, PartialEq, Eq, Hash)]
76pub struct ResetHash(String);
77
78impl ResetHash {
79 /// Hashes a token.
80 pub fn of(plaintext: &str) -> Self {
81 let digest = Sha256::digest(plaintext.as_bytes());
82
83 Self(digest.iter().map(|byte| format!("{byte:02x}")).collect())
84 }
85
86 /// Wraps an already-computed hash, typically one loaded from the database.
87 pub fn from_trusted(value: impl Into<String>) -> Self {
88 Self(value.into())
89 }
90
91 pub fn as_str(&self) -> &str {
92 &self.0
93 }
94
95 /// Whether two hashes are the same, compared in constant time.
96 pub fn matches(&self, other: &Self) -> bool {
97 self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
98 }
99}
100
101/// Redacted for the same reason [`ResetSecret`] is.
102impl fmt::Debug for ResetHash {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 f.write_str("ResetHash(redacted)")
105 }
106}
107
108/// A password reset, as recorded.
109///
110/// Rows are kept after use rather than deleted, unlike a revoked access token: `used_at`
111/// is what makes a replayed link answer "this link has been used" instead of "no such
112/// link", and it is the only trace that a reset happened at all.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct PasswordReset {
115 pub id: ResetId,
116 pub user_id: UserId,
117 pub token_hash: ResetHash,
118 pub created_at: SystemTime,
119 pub expires_at: SystemTime,
120 /// When the token was spent, or `None` for one still live.
121 pub used_at: Option<SystemTime>,
122}
123
124impl PasswordReset {
125 /// How long a freshly minted link lasts.
126 ///
127 /// Thirty minutes: long enough to read a mail that took a few minutes to arrive,
128 /// short enough that a link left in an inbox, a browser history or a mail server's
129 /// logs is dead by the time anyone finds it.
130 pub const LIFETIME: Duration = Duration::from_secs(30 * 60);
131
132 /// Mints a record for a freshly generated secret.
133 pub fn new(id: ResetId, user_id: UserId, secret: &ResetSecret, now: SystemTime) -> Self {
134 Self {
135 id,
136 user_id,
137 token_hash: secret.hash(),
138 created_at: now,
139 expires_at: now + Self::LIFETIME,
140 used_at: None,
141 }
142 }
143
144 /// Rebuilds a record from storage without revalidating it.
145 pub fn from_trusted(
146 id: ResetId,
147 user_id: UserId,
148 token_hash: ResetHash,
149 created_at: SystemTime,
150 expires_at: SystemTime,
151 used_at: Option<SystemTime>,
152 ) -> Self {
153 Self {
154 id,
155 user_id,
156 token_hash,
157 created_at,
158 expires_at,
159 used_at,
160 }
161 }
162
163 /// Whether this token can still be spent: unused, and not yet expired.
164 ///
165 /// Asked here rather than in the use case so that the form and the submission cannot
166 /// end up disagreeing about what "live" means.
167 pub fn is_live(&self, now: SystemTime) -> bool {
168 self.used_at.is_none() && now < self.expires_at
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn at(seconds: u64) -> SystemTime {
177 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
178 }
179
180 fn reset(now: SystemTime) -> (PasswordReset, ResetSecret) {
181 let secret = ResetSecret::generate();
182 let record = PasswordReset::new(ResetId::generate(), UserId::generate(), &secret, now);
183
184 (record, secret)
185 }
186
187 #[test]
188 fn a_generated_secret_is_full_width_hex() {
189 let secret = ResetSecret::generate();
190
191 assert_eq!(secret.reveal().len(), ResetSecret::BYTES * 2);
192 assert!(secret.reveal().chars().all(|c| c.is_ascii_hexdigit()));
193 }
194
195 #[test]
196 fn secrets_are_unique_per_generation() {
197 assert_ne!(
198 ResetSecret::generate().reveal(),
199 ResetSecret::generate().reveal()
200 );
201 }
202
203 #[test]
204 fn a_presented_value_hashes_the_same_as_the_token_it_copies() {
205 // This is the whole lookup: what arrives in the URL is hashed and compared
206 // against what was stored.
207 let secret = ResetSecret::generate();
208 let presented = ResetSecret::from_presented(secret.reveal());
209
210 assert!(presented.hash().matches(&secret.hash()));
211 }
212
213 #[test]
214 fn different_secrets_hash_differently() {
215 assert!(
216 !ResetSecret::generate()
217 .hash()
218 .matches(&ResetSecret::generate().hash())
219 );
220 }
221
222 #[test]
223 fn the_hash_is_not_the_secret() {
224 let secret = ResetSecret::generate();
225
226 assert_ne!(secret.hash().as_str(), secret.reveal());
227 assert_eq!(secret.hash().as_str().len(), 64);
228 }
229
230 #[test]
231 fn debug_output_redacts_both_the_secret_and_its_hash() {
232 let (record, secret) = reset(at(1_000));
233
234 assert_eq!(format!("{secret:?}"), "ResetSecret(redacted)");
235 assert_eq!(format!("{:?}", secret.hash()), "ResetHash(redacted)");
236 assert!(!format!("{record:?}").contains(secret.reveal()));
237 }
238
239 #[test]
240 fn a_fresh_reset_is_live_and_records_the_secrets_hash() {
241 let (record, secret) = reset(at(1_000));
242
243 assert!(record.token_hash.matches(&secret.hash()));
244 assert!(record.is_live(at(1_000)));
245 assert_eq!(record.expires_at, at(1_000 + 30 * 60));
246 }
247
248 #[test]
249 fn a_reset_expires_at_its_expiry_and_not_before() {
250 let (record, _) = reset(at(1_000));
251 let expires = at(1_000 + 30 * 60);
252
253 assert!(record.is_live(expires - Duration::from_secs(1)));
254 assert!(!record.is_live(expires));
255 assert!(!record.is_live(expires + Duration::from_secs(1)));
256 }
257
258 #[test]
259 fn a_used_reset_is_dead_even_inside_its_window() {
260 let (mut record, _) = reset(at(1_000));
261 record.used_at = Some(at(1_100));
262
263 assert!(!record.is_live(at(1_200)));
264 }
265}