Skip to content

g1t/services/identity/src/two_factor.rs

551 lines23,428 bytesCodeBlame
1//! Two-factor authentication: a time-based code from an authenticator app
2//! (TOTP, RFC 6238, with HMAC-SHA1, six digits and 30-second steps, as every
3//! app reads it), and recovery codes for when the app is lost.
4//!
5//! **Turning it on.** `two_factor_start` makes a secret, sealed at rest
6//! with `IDENTITY_KEY` and bound to the account (`g1t_secrets::Sealer`),
7//! and returns it with an `otpauth://` address for a QR code. A code from
8//! the app given to `two_factor_enable` confirms it; ten recovery codes are
9//! made then and shown once, kept only as their SHA-256. Both, and turning
10//! it off, need the person to prove it is them (security.rs, "sudo mode");
11//! turning it off also needs a code.
12//!
13//! **Signing in.** With it on, a right password makes no session:
14//! `sign_in` returns a challenge (lib.rs, `start_session`) that
15//! `two_factor_sign_in` trades, with a code, for the session. A challenge
16//! lasts ten minutes and [`TWO_FACTOR_ATTEMPTS`] wrong codes. Git over HTTPS
17//! takes an access token then, never the password.
18//!
19//! **Codes.** A code is accepted for its own step and one either side, for
20//! clocks a little off, and never twice: `two_factor.last_step` keeps the
21//! last step used, and only a later one is accepted after it. A recovery
22//! code works once.
23//!
24//! Each change is in the account's security log, mailed to its primary and
25//! backup addresses, and recorded in the audit log of every workspace the
26//! person belongs to.
27
28use g1t_contracts::accounts::*;
29use g1t_contracts::identity::{SignedIn, UserArgs};
30use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
31use g1t_contracts::{FailureCode, Outcome, Role, User};
32use g1t_kit::now_ms;
33use g1t_secrets::Sealer;
34use hmac::{Hmac, Mac};
35use serde::Deserialize;
36use sha1::Sha1;
37use worker::Result;
38
39use crate::security::{PEOPLE_ONLY, is_person};
40use crate::{Identity, crypto};
41
42const NOT_ON: &str = "Two-factor authentication is not on for this account.";
43const WRONG_CODE: &str = "That code is not right. Use the current code from your authenticator app, or a recovery code.";
44const NO_KEY: &str = "Two-factor authentication is not available right now.";
45
46// --- TOTP, RFC 6238 ---
47
48const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
49
50/// RFC 4648 base32, without padding, as authenticator apps take secrets.
51pub fn base32_encode(bytes: &[u8]) -> String {
52 let mut out = String::new();
53 let mut buffer: u32 = 0;
54 let mut bits = 0;
55 for &byte in bytes {
56 buffer = (buffer << 8) | byte as u32;
57 bits += 8;
58 while bits >= 5 {
59 out.push(BASE32[((buffer >> (bits - 5)) & 31) as usize] as char);
60 bits -= 5;
61 }
62 }
63 if bits > 0 {
64 out.push(BASE32[((buffer << (5 - bits)) & 31) as usize] as char);
65 }
66 out
67}
68
69/// The bytes of a base32 secret; spaces, case and padding ignored.
70pub fn base32_decode(text: &str) -> Option<Vec<u8>> {
71 let mut out = Vec::new();
72 let mut buffer: u32 = 0;
73 let mut bits = 0;
74 for c in text.chars().filter(|c| !c.is_whitespace() && *c != '=') {
75 let value = BASE32.iter().position(|&b| b as char == c.to_ascii_uppercase())? as u32;
76 buffer = (buffer << 5) | value;
77 bits += 5;
78 if bits >= 8 {
79 out.push(((buffer >> (bits - 8)) & 0xff) as u8);
80 bits -= 8;
81 }
82 }
83 Some(out)
84}
85
86/// The HOTP value (RFC 4226) of `secret` at `counter`, as six digits.
87pub fn hotp(secret: &[u8], counter: u64) -> String {
88 let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(secret).expect("HMAC takes any key length");
89 mac.update(&counter.to_be_bytes());
90 let digest = mac.finalize().into_bytes();
91 let offset = (digest[19] & 0x0f) as usize;
92 let binary = ((digest[offset] as u32 & 0x7f) << 24)
93 | ((digest[offset + 1] as u32) << 16)
94 | ((digest[offset + 2] as u32) << 8)
95 | digest[offset + 3] as u32;
96 format!("{:0width$}", binary % 10u32.pow(TOTP_DIGITS), width = TOTP_DIGITS as usize)
97}
98
99/// The step a time falls in.
100pub fn step_at(unix_seconds: u64) -> u64 {
101 unix_seconds / TOTP_STEP_SECONDS
102}
103
104/// The step `code` is right for, within [`TOTP_SKEW_STEPS`] of now and
105/// after `last_step` (so no code is accepted twice); `None` otherwise.
106pub fn verify_totp(secret: &[u8], code: &str, unix_seconds: u64, last_step: u64) -> Option<u64> {
107 let code = tidy_code(code);
108 if !is_totp_shaped(&code) {
109 return None;
110 }
111 let now = step_at(unix_seconds);
112 let mut found = None;
113 for step in now.saturating_sub(TOTP_SKEW_STEPS)..=now + TOTP_SKEW_STEPS {
114 // Compared in full every time, so timing says nothing of which.
115 let right = hotp(secret, step).bytes().zip(code.bytes()).fold(0u8, |diff, (a, b)| diff | (a ^ b)) == 0;
116 if right && step > last_step && found.is_none() {
117 found = Some(step);
118 }
119 }
120 found
121}
122
123/// A recovery code: ten characters in two groups, such as `k7m2q-9xw4d`.
124fn new_recovery_code() -> String {
125 const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
126 let mut bytes = [0u8; 10];
127 getrandom::getrandom(&mut bytes).expect("no source of randomness");
128 let chars: String = bytes.iter().map(|byte| ALPHABET[*byte as usize % ALPHABET.len()] as char).collect();
129 format!("{}-{}", &chars[..5], &chars[5..])
130}
131
132/// What a recovery code is kept as.
133pub fn recovery_hash(code: &str) -> String {
134 crypto::sha256_hex(&tidy_code(code))
135}
136
137#[derive(Deserialize)]
138struct Row {
139 secret: String,
140 enabled_at: Option<String>,
141 last_step: f64,
142}
143
144impl Identity {
145 fn two_factor_sealer(&self) -> Option<Sealer> {
146 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
147 }
148
149 async fn two_factor_row(&self, user_id: &str) -> Result<Option<Row>> {
150 self.db
151 .prepare("SELECT secret, enabled_at, last_step FROM two_factor WHERE user_id = ?")
152 .bind(&[user_id.into()])?
153 .first::<Row>(None)
154 .await
155 }
156
157 /// Whether the account has two-factor authentication on.
158 pub async fn two_factor_enabled(&self, user_id: &str) -> Result<bool> {
159 Ok(self.two_factor_row(user_id).await?.is_some_and(|row| row.enabled_at.is_some()))
160 }
161
162 /// The secret of a row, opened.
163 fn opened(&self, user_id: &str, row: &Row) -> Option<Vec<u8>> {
164 let text = self.two_factor_sealer()?.open(&row.secret, &bound(user_id))?;
165 base32_decode(&text)
166 }
167
168 /// Whether `code` is a right code for the account, using it up: an app
169 /// code's step is recorded, a recovery code is spent. With `pending`, the
170 /// enrolment in progress is what is checked, and recovery codes are not.
171 async fn use_code(&self, user_id: &str, code: &str, pending: bool) -> Result<bool> {
172 let Some(row) = self.two_factor_row(user_id).await? else {
173 return Ok(false);
174 };
175 if pending != row.enabled_at.is_none() {
176 return Ok(false);
177 }
178 let tidy = tidy_code(code);
179 if is_totp_shaped(&tidy) {
180 let Some(secret) = self.opened(user_id, &row) else {
181 return Ok(false);
182 };
183 let Some(step) = verify_totp(&secret, &tidy, now_ms() / 1000, row.last_step as u64) else {
184 return Ok(false);
185 };
186 // Only a later step moves it on, so two requests with one code
187 // cannot both pass.
188 let moved = self
189 .db
190 .prepare("UPDATE two_factor SET last_step = ?1 WHERE user_id = ?2 AND last_step < ?1 RETURNING user_id")
191 .bind(&[(step as f64).into(), user_id.into()])?
192 .first::<serde_json::Value>(None)
193 .await?;
194 return Ok(moved.is_some());
195 }
196 if pending {
197 return Ok(false);
198 }
199 let spent = self
200 .db
201 .prepare(
202 "UPDATE two_factor_recovery SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL
203 RETURNING code_hash",
204 )
205 .bind(&[rfc3339(now_ms()).into(), user_id.into(), recovery_hash(&tidy).into()])?
206 .first::<serde_json::Value>(None)
207 .await?;
208 if spent.is_some() {
209 self.log_security(user_id, "recovery_code_used", None, None).await;
210 }
211 Ok(spent.is_some())
212 }
213
214 /// Ten new recovery codes for the account, replacing any it had.
215 async fn new_recovery_codes(&self, user_id: &str) -> Result<RecoveryCodes> {
216 let codes: Vec<String> = (0..RECOVERY_CODES).map(|_| new_recovery_code()).collect();
217 let mut statements = vec![
218 self.db
219 .prepare("DELETE FROM two_factor_recovery WHERE user_id = ?")
220 .bind(&[user_id.into()])?,
221 ];
222 for code in &codes {
223 statements.push(
224 self.db
225 .prepare("INSERT OR IGNORE INTO two_factor_recovery (user_id, code_hash) VALUES (?, ?)")
226 .bind(&[user_id.into(), recovery_hash(code).into()])?,
227 );
228 }
229 self.db.batch(statements).await?;
230 Ok(RecoveryCodes { codes })
231 }
232
233 /// The workspaces the person belongs to that require two-factor
234 /// authentication, and of those, the ones they own.
235 async fn requiring_workspaces(&self, user_id: &str) -> Result<Vec<(String, bool)>> {
236 #[derive(Deserialize)]
237 struct Requiring {
238 slug: String,
239 role: Role,
240 }
241 Ok(self
242 .db
243 .prepare(
244 "SELECT w.slug, m.role FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
245 WHERE m.user_id = ? AND w.require_two_factor = 1 AND w.deleted_at IS NULL ORDER BY w.slug",
246 )
247 .bind(&[user_id.into()])?
248 .all()
249 .await?
250 .results::<Requiring>()?
251 .into_iter()
252 .map(|row| (row.slug, row.role == Role::Owner))
253 .collect())
254 }
255
256 /// `two_factor_status`.
257 pub async fn two_factor_status(&self, a: UserArgs) -> Result<Outcome<TwoFactorStatus>> {
258 if !is_person(&a.user) {
259 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
260 }
261 #[derive(Deserialize)]
262 struct Left {
263 left: u32,
264 }
265 let row = self.two_factor_row(&a.user.id).await?;
266 let left = self
267 .db
268 .prepare("SELECT count(*) AS left FROM two_factor_recovery WHERE user_id = ? AND used_at IS NULL")
269 .bind(&[a.user.id.as_str().into()])?
270 .first::<Left>(None)
271 .await?
272 .map_or(0, |row| row.left);
273 let enabled_at = row.and_then(|row| row.enabled_at);
274 Ok(Outcome::Ok(TwoFactorStatus {
275 enabled: enabled_at.is_some(),
276 recovery_codes_left: if enabled_at.is_some() { left } else { 0 },
277 enabled_at,
278 required_by: self.requiring_workspaces(&a.user.id).await?.into_iter().map(|(slug, _)| slug).collect(),
279 }))
280 }
281
282 /// `two_factor_start`.
283 pub async fn two_factor_start(&self, a: TwoFactorArgs) -> Result<Outcome<TwoFactorSetup>> {
284 if !is_person(&a.user) {
285 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
286 }
287 if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
288 return Ok(refused);
289 }
290 if self.two_factor_enabled(&a.user.id).await? {
291 return Ok(Outcome::fail(FailureCode::Conflict, "Two-factor authentication is already on."));
292 }
293 let Some(sealer) = self.two_factor_sealer() else {
294 return Ok(Outcome::fail(FailureCode::Conflict, NO_KEY));
295 };
296 let mut bytes = [0u8; 20];
297 getrandom::getrandom(&mut bytes).expect("no source of randomness");
298 let secret = base32_encode(&bytes);
299 self.db
300 .prepare(
301 "INSERT INTO two_factor (user_id, secret, enabled_at, created_at, last_step) VALUES (?1, ?2, NULL, ?3, 0)
302 ON CONFLICT (user_id) DO UPDATE SET secret = excluded.secret, enabled_at = NULL,
303 created_at = excluded.created_at, last_step = 0",
304 )
305 .bind(&[a.user.id.as_str().into(), sealer.seal(&secret, &bound(&a.user.id)).into(), rfc3339(now_ms()).into()])?
306 .run()
307 .await?;
308 Ok(Outcome::Ok(TwoFactorSetup { uri: otpauth_uri(&secret, &a.user.username), secret }))
309 }
310
311 /// `two_factor_enable`.
312 pub async fn two_factor_enable(&self, a: TwoFactorCodeArgs) -> Result<Outcome<RecoveryCodes>> {
313 if !is_person(&a.user) {
314 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
315 }
316 if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
317 return Ok(refused);
318 }
319 if self.two_factor_enabled(&a.user.id).await? {
320 return Ok(Outcome::fail(FailureCode::Conflict, "Two-factor authentication is already on."));
321 }
322 if self.two_factor_row(&a.user.id).await?.is_none() {
323 return Ok(Outcome::fail(FailureCode::Invalid, "Start again: scan the QR code, then enter the code your app shows."));
324 }
325 if !self.use_code(&a.user.id, &a.code, true).await? {
326 return Ok(Outcome::fail(FailureCode::Invalid, "That code is not right. Enter the code your app shows now."));
327 }
328 self.db
329 .prepare(format!("UPDATE two_factor SET enabled_at = {SQL_NOW} WHERE user_id = ?"))
330 .bind(&[a.user.id.as_str().into()])?
331 .run()
332 .await?;
333 let codes = self.new_recovery_codes(&a.user.id).await?;
334 self.log_security(&a.user.id, "two_factor_enabled", Some("Authenticator app"), None).await;
335 self.tell_primary_and_backup(&a.user.id, &a.user.username, "Two-factor authentication was turned on").await;
336 self.audit_account(&a.user, "two_factor.enabled", "Turned on two-factor authentication").await;
337 Ok(Outcome::Ok(codes))
338 }
339
340 /// `two_factor_disable`.
341 pub async fn two_factor_disable(&self, a: TwoFactorCodeArgs) -> Result<Outcome<bool>> {
342 if !is_person(&a.user) {
343 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
344 }
345 if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
346 return Ok(refused);
347 }
348 if !self.two_factor_enabled(&a.user.id).await? {
349 return Ok(Outcome::fail(FailureCode::Conflict, NOT_ON));
350 }
351 let owned: Vec<String> = self
352 .requiring_workspaces(&a.user.id)
353 .await?
354 .into_iter()
355 .filter_map(|(slug, owner)| owner.then_some(slug))
356 .collect();
357 if !owned.is_empty() {
358 return Ok(Outcome::fail(
359 FailureCode::Conflict,
360 format!(
361 "You own {}, which requires two-factor authentication. Stop requiring it there first, or hand the workspace to another owner.",
362 owned.join(", ")
363 ),
364 ));
365 }
366 if !self.use_code(&a.user.id, &a.code, false).await? {
367 return Ok(Outcome::fail(FailureCode::Invalid, WRONG_CODE));
368 }
369 self.db
370 .batch(vec![
371 self.db.prepare("DELETE FROM two_factor WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?,
372 self.db.prepare("DELETE FROM two_factor_recovery WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?,
373 self.db.prepare("DELETE FROM two_factor_challenges WHERE user_id = ?").bind(&[a.user.id.as_str().into()])?,
374 ])
375 .await?;
376 self.log_security(&a.user.id, "two_factor_disabled", None, None).await;
377 self.tell_primary_and_backup(&a.user.id, &a.user.username, "Two-factor authentication was turned off").await;
378 self.audit_account(&a.user, "two_factor.disabled", "Turned off two-factor authentication").await;
379 Ok(Outcome::Ok(true))
380 }
381
382 /// `two_factor_recovery_codes`.
383 pub async fn two_factor_recovery_codes(&self, a: TwoFactorArgs) -> Result<Outcome<RecoveryCodes>> {
384 if !is_person(&a.user) {
385 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
386 }
387 if let Some(refused) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
388 return Ok(refused);
389 }
390 if !self.two_factor_enabled(&a.user.id).await? {
391 return Ok(Outcome::fail(FailureCode::Conflict, NOT_ON));
392 }
393 let codes = self.new_recovery_codes(&a.user.id).await?;
394 self.log_security(&a.user.id, "recovery_codes_regenerated", None, None).await;
395 self.tell_primary_and_backup(&a.user.id, &a.user.username, "New two-factor recovery codes were made; the old ones no longer work").await;
396 Ok(Outcome::Ok(codes))
397 }
398
399 /// A sign-in waiting for its code: the token the caller passes back.
400 pub(crate) async fn issue_challenge(&self, user_id: &str) -> Result<String> {
401 let token = crypto::random_hex(32);
402 self.db
403 .batch(vec![
404 // Old ones for the account go; a sign-in starts one afresh.
405 self.db
406 .prepare(format!("DELETE FROM two_factor_challenges WHERE user_id = ? OR expires_at <= {SQL_NOW}"))
407 .bind(&[user_id.into()])?,
408 self.db
409 .prepare(format!(
410 "INSERT INTO two_factor_challenges (id, user_id, expires_at, attempts) VALUES (?, ?, {}, 0)",
411 sql_after(TWO_FACTOR_CHALLENGE_SECONDS)
412 ))
413 .bind(&[crypto::sha256_hex(&token).into(), user_id.into()])?,
414 ])
415 .await?;
416 Ok(token)
417 }
418
419 /// `two_factor_sign_in`.
420 pub async fn two_factor_sign_in(&self, a: TwoFactorSignInArgs) -> Result<Outcome<SignedIn>> {
421 #[derive(Deserialize)]
422 struct Challenge {
423 user_id: String,
424 attempts: u32,
425 }
426 let id = crypto::sha256_hex(&a.challenge);
427 let challenge = self
428 .db
429 .prepare(format!("SELECT user_id, attempts FROM two_factor_challenges WHERE id = ? AND expires_at > {SQL_NOW}"))
430 .bind(&[id.as_str().into()])?
431 .first::<Challenge>(None)
432 .await?;
433 let expired = || Ok(Outcome::fail(FailureCode::Unauthenticated, "This sign-in has expired. Sign in again."));
434 let Some(challenge) = challenge else {
435 return expired();
436 };
437 if challenge.attempts >= TWO_FACTOR_ATTEMPTS {
438 self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
439 return expired();
440 }
441 if !self.use_code(&challenge.user_id, &a.code, false).await? {
442 self.db
443 .prepare("UPDATE two_factor_challenges SET attempts = attempts + 1 WHERE id = ?")
444 .bind(&[id.as_str().into()])?
445 .run()
446 .await?;
447 return Ok(Outcome::fail(FailureCode::Unauthenticated, WRONG_CODE));
448 }
449 self.db.prepare("DELETE FROM two_factor_challenges WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
450 let user = self
451 .find_user(
452 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
453 &challenge.user_id,
454 )
455 .await?;
456 let Some(user) = user else {
457 return expired();
458 };
459 self.session_for(user).await
460 }
461
462 /// Records a change to the person's own account in the audit log of
463 /// every workspace they belong to, where its owners look.
464 pub(crate) async fn audit_account(&self, user: &User, action: &str, message: &str) {
465 let Ok(memberships) = self.memberships(&user.id).await else {
466 return;
467 };
468 for membership in memberships {
469 self.audit_workspace(user, action, &membership.slug, g1t_contracts::audit::Surface::Web, message.to_owned()).await;
470 }
471 }
472}
473
474/// What a sealed secret is bound to.
475fn bound(user_id: &str) -> String {
476 format!("two_factor:{user_id}")
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 /// RFC 6238, appendix B: the SHA-1 key and the 8-digit values, cut to
484 /// six digits (the last six of each).
485 #[test]
486 fn codes_match_rfc_6238() {
487 let secret = b"12345678901234567890";
488 for (time, expected) in [
489 (59u64, "94287082"),
490 (1_111_111_109, "07081804"),
491 (1_111_111_111, "14050471"),
492 (1_234_567_890, "89005924"),
493 (2_000_000_000, "69279037"),
494 (20_000_000_000, "65353130"),
495 ] {
496 assert_eq!(hotp(secret, step_at(time)), expected[2..], "at {time}");
497 }
498 }
499
500 #[test]
501 fn base32_round_trips_and_reads_what_apps_show() {
502 let bytes = b"12345678901234567890";
503 let text = base32_encode(bytes);
504 assert_eq!(text, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
505 assert_eq!(base32_decode(&text).unwrap(), bytes);
506 assert_eq!(base32_decode("gezd gnbv gy3t qojq gezd gnbv gy3t qojq").unwrap(), bytes);
507 assert!(base32_decode("not base32!").is_none());
508 }
509
510 #[test]
511 fn a_code_works_a_step_either_side_and_no_further() {
512 let secret = b"12345678901234567890";
513 let now = 1_111_111_111;
514 let step = step_at(now);
515 let current = hotp(secret, step);
516 assert_eq!(verify_totp(secret, &current, now, 0), Some(step));
517 // A clock 30 seconds behind or ahead still gets in.
518 assert_eq!(verify_totp(secret, &hotp(secret, step - 1), now, 0), Some(step - 1));
519 assert_eq!(verify_totp(secret, &hotp(secret, step + 1), now, 0), Some(step + 1));
520 // Two steps off does not.
521 assert_eq!(verify_totp(secret, &hotp(secret, step - 2), now, 0), None);
522 assert_eq!(verify_totp(secret, &hotp(secret, step + 2), now, 0), None);
523 // Typed with a space, it is the same code.
524 assert_eq!(verify_totp(secret, &format!("{} {}", &current[..3], &current[3..]), now, 0), Some(step));
525 assert_eq!(verify_totp(secret, "abcdef", now, 0), None);
526 }
527
528 #[test]
529 fn a_code_is_never_accepted_twice() {
530 let secret = b"12345678901234567890";
531 let now = 1_111_111_111;
532 let step = step_at(now);
533 let current = hotp(secret, step);
534 let used = verify_totp(secret, &current, now, 0).unwrap();
535 // Replayed: the step it was used for is the last, so it is refused.
536 assert_eq!(verify_totp(secret, &current, now, used), None);
537 // And an older code, still in the window, is refused after it.
538 assert_eq!(verify_totp(secret, &hotp(secret, step - 1), now, used), None);
539 // The next step's code is fine.
540 assert_eq!(verify_totp(secret, &hotp(secret, step + 1), now + 30, used), Some(step + 1));
541 }
542
543 #[test]
544 fn recovery_codes_are_two_groups_and_kept_as_hashes() {
545 let code = new_recovery_code();
546 assert_eq!(code.len(), 11);
547 assert_eq!(&code[5..6], "-");
548 assert_eq!(recovery_hash(&code), recovery_hash(&code.to_uppercase().replace('-', " ")));
549 assert_ne!(recovery_hash(&code), code);
550 }
551}