auth.rs
⎇
Raw
1//! Passwords, sessions, and token minting.
2//!
3//! Logging in *is* pairing. There is no QR code, no enrollment token, no
4//! out-of-band step: a phone posts credentials and gets back a `token_id` and a
5//! 32-byte key, which is exactly the mental model of a browser session. A
6//! reinstall is just another login, and because positions belong to the account,
7//! the new token writes into the same continuous history.
8
9use std::net::IpAddr;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use anyhow::{Context, Result};
14// `SaltString::generate` wants the rand_core that password-hash was built
15// against, which is not the same major version as the `rand` used elsewhere in
16// this crate. Importing it through argon2 keeps the two from being confused.
17use argon2::password_hash::rand_core::OsRng as PhOsRng;
18use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
19use argon2::{Algorithm, Argon2, Params, Version};
20use dashmap::DashMap;
21use sqlx::SqlitePool;
22
23use crate::config::Config;
24use crate::db::now;
25use crate::ingest::{Ingest, TokenSlot};
26use crate::keys::{KeyVault, random_token_id, random_token_key};
27use otproto::RevokeReason;
28
29/// Failed logins allowed per (IP, username) before the pair is locked out.
30const MAX_ATTEMPTS: u32 = 5;
31const ATTEMPT_WINDOW: Duration = Duration::from_secs(15 * 60);
32
33/// An argon2 hash of a throwaway password, used to spend the same CPU on an
34/// unknown username as on a known one.
35///
36/// Without this, "user not found" returns in microseconds while a real user's
37/// verify takes ~50 ms, and the difference enumerates the whole user list.
38const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$\
39 Fj5r5rD/hqCvQeYqNSlF9y5FZbTCUYPl5jrCLj/eKz0";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum AuthError {
43 /// Wrong username, wrong password, or a disabled account — deliberately not
44 /// distinguished, so a caller cannot learn which usernames exist.
45 Invalid,
46 /// Too many failures for this (IP, username) pair.
47 LockedOut { retry_after_s: u64 },
48}
49
50impl std::fmt::Display for AuthError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::Invalid => write!(f, "invalid username or password"),
54 Self::LockedOut { retry_after_s } => {
55 write!(f, "too many attempts; try again in {retry_after_s}s")
56 }
57 }
58 }
59}
60
61impl std::error::Error for AuthError {}
62
63#[derive(Debug, Default)]
64struct Attempts {
65 count: u32,
66 window_started: Option<Instant>,
67}
68
69/// Per-(IP, username) failed-login throttle.
70#[derive(Default)]
71pub struct LoginThrottle {
72 attempts: DashMap<(IpAddr, String), Attempts>,
73}
74
75impl LoginThrottle {
76 /// Keyed on the pair, not on either alone: keying on IP would let one shared
77 /// office address lock out a whole team, and keying on username alone would
78 /// let anyone lock a known user out on purpose.
79 pub fn check(&self, ip: IpAddr, username: &str) -> Result<(), AuthError> {
80 let key = (ip, username.to_lowercase());
81 let now = Instant::now();
82 if let Some(a) = self.attempts.get(&key)
83 && let Some(started) = a.window_started
84 && now.duration_since(started) <= ATTEMPT_WINDOW
85 && a.count >= MAX_ATTEMPTS
86 {
87 return Err(AuthError::LockedOut {
88 retry_after_s: (ATTEMPT_WINDOW - now.duration_since(started)).as_secs(),
89 });
90 }
91 Ok(())
92 }
93
94 pub fn note_failure(&self, ip: IpAddr, username: &str) {
95 let key = (ip, username.to_lowercase());
96 let now = Instant::now();
97 let mut entry = self.attempts.entry(key).or_default();
98 match entry.window_started {
99 Some(started) if now.duration_since(started) <= ATTEMPT_WINDOW => entry.count += 1,
100 _ => {
101 entry.window_started = Some(now);
102 entry.count = 1;
103 }
104 }
105 }
106
107 pub fn note_success(&self, ip: IpAddr, username: &str) {
108 self.attempts.remove(&(ip, username.to_lowercase()));
109 }
110
111 pub fn gc(&self) {
112 let now = Instant::now();
113 self.attempts.retain(|_, a| {
114 a.window_started
115 .is_some_and(|t| now.duration_since(t) <= ATTEMPT_WINDOW)
116 });
117 }
118}
119
120/// Argon2id parameters from config.
121///
122/// 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile and fits a
123/// small VM. The parameters are stored inside each PHC hash string, so raising
124/// them later does not invalidate anything — [`verify`] re-hashes on the next
125/// successful login instead.
126fn hasher(cfg: &Config) -> Result<Argon2<'static>> {
127 let params = Params::new(
128 cfg.argon2_memory_kib,
129 cfg.argon2_iterations,
130 cfg.argon2_parallelism,
131 None,
132 )
133 .map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
134 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
135}
136
137/// Hash a password. Runs on a blocking thread: argon2 is deliberately expensive,
138/// and ~50 ms of CPU on an async worker would stall every other request on it.
139pub async fn hash_password(cfg: &Config, password: String) -> Result<String> {
140 let argon = hasher(cfg)?;
141 tokio::task::spawn_blocking(move || {
142 let salt = SaltString::generate(&mut PhOsRng);
143 argon
144 .hash_password(password.as_bytes(), &salt)
145 .map(|h| h.to_string())
146 .map_err(|e| anyhow::anyhow!("hashing failed: {e}"))
147 })
148 .await
149 .context("hashing task panicked")?
150}
151
152/// Outcome of a verify, including whether the stored hash should be upgraded.
153pub struct Verified {
154 pub ok: bool,
155 /// A fresh hash under current policy, when the stored one used older params.
156 pub rehashed: Option<String>,
157}
158
159pub async fn verify(cfg: &Config, stored: String, password: String) -> Result<Verified> {
160 let argon = hasher(cfg)?;
161 let want = Params::new(
162 cfg.argon2_memory_kib,
163 cfg.argon2_iterations,
164 cfg.argon2_parallelism,
165 None,
166 )
167 .map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
168
169 tokio::task::spawn_blocking(move || {
170 let parsed = match PasswordHash::new(&stored) {
171 Ok(p) => p,
172 Err(_) => {
173 // A corrupt hash must still cost the same time as a real one, or
174 // the failure mode becomes an oracle.
175 let dummy = PasswordHash::new(DUMMY_HASH).expect("the dummy hash is a literal");
176 let _ = argon.verify_password(password.as_bytes(), &dummy);
177 return Ok(Verified {
178 ok: false,
179 rehashed: None,
180 });
181 }
182 };
183
184 if argon.verify_password(password.as_bytes(), &parsed).is_err() {
185 return Ok(Verified {
186 ok: false,
187 rehashed: None,
188 });
189 }
190
191 // Transparent upgrade when policy has moved on since this hash was made.
192 //
193 // Only the three cost parameters are compared. A parsed `Params` also
194 // carries an output length and optional keyid/data fields that the freshly
195 // built one does not, so comparing whole structs would rehash on every
196 // single login — an easy 50 ms tax to add by accident.
197 let current: Params = (&parsed).try_into().unwrap_or_else(|_| want.clone());
198 let costs_differ = current.m_cost() != want.m_cost()
199 || current.t_cost() != want.t_cost()
200 || current.p_cost() != want.p_cost();
201 let rehashed = if costs_differ {
202 let salt = SaltString::generate(&mut PhOsRng);
203 argon
204 .hash_password(password.as_bytes(), &salt)
205 .ok()
206 .map(|h| h.to_string())
207 } else {
208 None
209 };
210 Ok(Verified { ok: true, rehashed })
211 })
212 .await
213 .context("verify task panicked")?
214}
215
216/// Spend the same CPU as a real verify would, then fail.
217///
218/// Called when the username does not exist, so that the response time carries no
219/// information about which accounts are real.
220pub async fn dummy_verify(cfg: &Config, password: String) {
221 let _ = verify(cfg, DUMMY_HASH.to_string(), password).await;
222}
223
224#[derive(Debug)]
225pub struct Account {
226 pub id: i64,
227 pub username: String,
228 pub display_name: String,
229 pub is_admin: bool,
230}
231
232/// Look up and authenticate a user.
233pub async fn authenticate(
234 pool: &SqlitePool,
235 writer: &crate::writer::WriteHandle,
236 cfg: &Config,
237 throttle: &LoginThrottle,
238 ip: IpAddr,
239 username: &str,
240 password: String,
241) -> Result<Account, AuthError> {
242 throttle.check(ip, username)?;
243
244 let row: Option<(i64, String, String, String, i64, Option<i64>)> = sqlx::query_as(
245 "SELECT id, username, display_name, pw_hash, is_admin, disabled_at \
246 FROM users WHERE username = ?",
247 )
248 .bind(username)
249 .fetch_optional(pool)
250 .await
251 .map_err(|_| AuthError::Invalid)?;
252
253 let Some((id, username_stored, display_name, pw_hash, is_admin, disabled_at)) = row else {
254 dummy_verify(cfg, password).await;
255 throttle.note_failure(ip, username);
256 return Err(AuthError::Invalid);
257 };
258
259 // A disabled account still pays for a full verify, so disabling somebody is
260 // not observable from outside.
261 let verified = verify(cfg, pw_hash, password)
262 .await
263 .map_err(|_| AuthError::Invalid)?;
264
265 if !verified.ok || disabled_at.is_some() {
266 throttle.note_failure(ip, username);
267 return Err(AuthError::Invalid);
268 }
269
270 if let Some(new_hash) = verified.rehashed {
271 let _ = writer
272 .send(crate::writer::WriteOp::Audit {
273 user_id: Some(id),
274 at: now(),
275 action: "password_rehashed".into(),
276 detail: String::new(),
277 src_ip: Some(ip.to_string()),
278 })
279 .await;
280 // Best effort: a failed upgrade must not fail the login.
281 let _ = sqlx::query("UPDATE users SET pw_hash = ? WHERE id = ?")
282 .bind(new_hash)
283 .bind(id)
284 .execute(pool)
285 .await;
286 }
287
288 throttle.note_success(ip, username);
289 Ok(Account {
290 id,
291 username: username_stored,
292 display_name,
293 is_admin: is_admin != 0,
294 })
295}
296
297/// A freshly minted device credential.
298pub struct MintedToken {
299 pub token_id: u64,
300 pub token_key: [u8; 32],
301 pub config_version: u16,
302}
303
304/// Mint a token for a login.
305///
306/// The plaintext key is returned exactly once — here — and stored only wrapped.
307/// The caller must hand it to the device and then drop it.
308pub async fn mint_token(
309 pool: &SqlitePool,
310 vault: &KeyVault,
311 ingest: &Arc<Ingest>,
312 user_id: i64,
313 name: &str,
314 platform: &str,
315 ip: IpAddr,
316) -> Result<MintedToken> {
317 let token_id = random_token_id()?;
318 let token_key = random_token_key()?;
319 let wrapped = vault.wrap(token_id, &token_key)?;
320 let config_version: u16 = 1;
321
322 sqlx::query(
323 "INSERT INTO tokens (token_id, user_id, key_wrapped, name, platform, config_version, \
324 created_at, created_ip) \
325 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
326 )
327 .bind(token_id as i64)
328 .bind(user_id)
329 .bind(&wrapped)
330 .bind(name)
331 .bind(platform)
332 .bind(i64::from(config_version))
333 .bind(now())
334 .bind(ip.to_string())
335 .execute(pool)
336 .await
337 .context("inserting token")?;
338
339 // Insert into the ingest cache before returning, so the device's very first
340 // datagram is served — there is no window where a just-issued token looks
341 // unknown.
342 ingest.insert_token(TokenSlot::new(
343 token_id,
344 user_id,
345 &token_key,
346 config_version,
347 ));
348
349 Ok(MintedToken {
350 token_id,
351 token_key,
352 config_version,
353 })
354}
355
356/// Load every usable token into the ingest cache. Called once at startup.
357///
358/// A token whose key cannot be unwrapped is skipped with a warning rather than
359/// aborting startup: that is what a botched `OT_SECRET_KEY` rotation looks like,
360/// and refusing to boot would turn a recoverable mistake into an outage.
361pub async fn load_tokens(
362 pool: &SqlitePool,
363 vault: &KeyVault,
364 ingest: &Arc<Ingest>,
365) -> Result<usize> {
366 // Revoked tokens are loaded too, flagged. Their keys authorise nothing; they
367 // exist so a phone that has not noticed yet gets a sealed answer instead of
368 // silence. A disabled account is treated as a revocation, since the effect on
369 // the device is the same.
370 /// `(token_id, user_id, key_wrapped, config_version, revoked_at, disabled_at)`.
371 type TokenRow = (i64, i64, Vec<u8>, i64, Option<i64>, Option<i64>);
372
373 let rows: Vec<TokenRow> = sqlx::query_as(
374 "SELECT t.token_id, t.user_id, t.key_wrapped, t.config_version, t.revoked_at, \
375 u.disabled_at \
376 FROM tokens t JOIN users u ON u.id = t.user_id",
377 )
378 .fetch_all(pool)
379 .await
380 .context("loading tokens")?;
381
382 let mut loaded = 0;
383 let mut revoked = 0;
384 for (token_id, user_id, wrapped, config_version, revoked_at, disabled_at) in rows {
385 match vault.unwrap(token_id as u64, &wrapped) {
386 Ok(key) => {
387 let slot = TokenSlot::new(token_id as u64, user_id, &key, config_version as u16);
388 if revoked_at.is_some() || disabled_at.is_some() {
389 ingest.insert_token(slot.revoked(RevokeReason::Revoked));
390 revoked += 1;
391 } else {
392 ingest.insert_token(slot);
393 loaded += 1;
394 }
395 }
396 Err(e) => tracing::warn!(token_id, error = %e, "skipping token with an unusable key"),
397 }
398 }
399 tracing::debug!(revoked, "revoked tokens kept so their devices can be told");
400 Ok(loaded)
401}
402
403/// Revoke one token: database row, then ingest cache.
404///
405/// The row and its wrapped key are kept, and the cache slot is flagged rather
406/// than dropped. That is deliberate: the key is the only thing that lets the
407/// server tell this device it has been logged out, in a message no third party
408/// could forge. Dropping it would leave silence as the only safe answer.
409pub async fn revoke_token(pool: &SqlitePool, ingest: &Arc<Ingest>, token_id: i64) -> Result<bool> {
410 let affected =
411 sqlx::query("UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL")
412 .bind(now())
413 .bind(token_id)
414 .execute(pool)
415 .await
416 .context("revoking token")?
417 .rows_affected();
418 ingest.mark_revoked(token_id as u64, RevokeReason::Revoked);
419 Ok(affected > 0)
420}
421
422/// Revoke every token for an account except optionally one.
423///
424/// This is what "log out all other devices" and a password change both do.
425pub async fn revoke_other_tokens(
426 pool: &SqlitePool,
427 ingest: &Arc<Ingest>,
428 user_id: i64,
429 keep: Option<i64>,
430) -> Result<usize> {
431 let ids: Vec<i64> = sqlx::query_scalar(
432 "SELECT token_id FROM tokens WHERE user_id = ? AND revoked_at IS NULL AND token_id IS NOT ?",
433 )
434 .bind(user_id)
435 .bind(keep)
436 .fetch_all(pool)
437 .await
438 .context("listing tokens to revoke")?;
439
440 for id in &ids {
441 revoke_token(pool, ingest, *id).await?;
442 }
443 Ok(ids.len())
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::db::Db;
450
451 const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7));
452
453 /// Cheap argon2 parameters: these tests are about logic, not about how long a
454 /// hash takes, and the real parameters make the suite unbearably slow.
455 fn cfg() -> Config {
456 Config {
457 argon2_memory_kib: 8,
458 argon2_iterations: 1,
459 argon2_parallelism: 1,
460 admin_email: "ops@example.net".into(),
461 ..Config::default()
462 }
463 }
464
465 async fn fixture() -> (
466 Db,
467 Arc<Ingest>,
468 crate::writer::WriteHandle,
469 tempfile::TempDir,
470 ) {
471 let dir = tempfile::tempdir().expect("temp dir");
472 let db = Db::open(&dir.path().join("t.db")).await.expect("open");
473 let (writer, _task) = crate::writer::spawn(db.write.clone());
474 let ingest = Arc::new(Ingest::new(writer.clone(), 30 * 86_400, None));
475 (db, ingest, writer, dir)
476 }
477
478 async fn make_user(db: &Db, cfg: &Config, username: &str, password: &str) -> i64 {
479 let hash = hash_password(cfg, password.to_string())
480 .await
481 .expect("hash");
482 let n = now();
483 sqlx::query(
484 "INSERT INTO users (username, pw_hash, display_name, created_at, pw_changed_at) \
485 VALUES (?, ?, ?, ?, ?)",
486 )
487 .bind(username)
488 .bind(hash)
489 .bind(username)
490 .bind(n)
491 .bind(n)
492 .execute(&db.write)
493 .await
494 .expect("user");
495 sqlx::query_scalar("SELECT id FROM users WHERE username = ?")
496 .bind(username)
497 .fetch_one(&db.read)
498 .await
499 .expect("id")
500 }
501
502 #[tokio::test]
503 async fn a_password_verifies_and_a_wrong_one_does_not() {
504 let cfg = cfg();
505 let hash = hash_password(&cfg, "correct horse".into())
506 .await
507 .expect("hash");
508 assert!(
509 verify(&cfg, hash.clone(), "correct horse".into())
510 .await
511 .expect("v")
512 .ok
513 );
514 assert!(
515 !verify(&cfg, hash, "wrong horse".into())
516 .await
517 .expect("v")
518 .ok
519 );
520 }
521
522 #[tokio::test]
523 async fn hashes_are_salted() {
524 let cfg = cfg();
525 let a = hash_password(&cfg, "same".into()).await.expect("hash");
526 let b = hash_password(&cfg, "same".into()).await.expect("hash");
527 assert_ne!(a, b, "two hashes of one password must differ");
528 }
529
530 #[tokio::test]
531 async fn a_corrupt_stored_hash_fails_closed() {
532 let cfg = cfg();
533 let v = verify(&cfg, "not a phc string".into(), "anything".into())
534 .await
535 .expect("v");
536 assert!(!v.ok);
537 }
538
539 #[tokio::test]
540 async fn a_hash_with_stale_parameters_is_upgraded_on_login() {
541 // Hash under weak parameters, then verify under stronger ones.
542 let weak = cfg();
543 let hash = hash_password(&weak, "pw".into()).await.expect("hash");
544 let strong = Config {
545 argon2_iterations: 3,
546 ..weak
547 };
548 let v = verify(&strong, hash, "pw".into()).await.expect("v");
549 assert!(v.ok);
550 let rehashed = v
551 .rehashed
552 .expect("stale parameters should produce a new hash");
553 assert!(
554 rehashed.contains("t=3"),
555 "the new hash should use current parameters: {rehashed}"
556 );
557 }
558
559 #[tokio::test]
560 async fn a_hash_with_current_parameters_is_not_rehashed() {
561 let cfg = cfg();
562 let hash = hash_password(&cfg, "pw".into()).await.expect("hash");
563 let v = verify(&cfg, hash, "pw".into()).await.expect("v");
564 assert!(v.ok);
565 assert!(
566 v.rehashed.is_none(),
567 "no upgrade needed, so no needless write"
568 );
569 }
570
571 #[tokio::test]
572 async fn usernames_are_case_insensitive() {
573 let (db, _ingest, writer, _dir) = fixture().await;
574 let cfg = cfg();
575 make_user(&db, &cfg, "Marc", "pw").await;
576 let throttle = LoginThrottle::default();
577 let account = authenticate(&db.read, &writer, &cfg, &throttle, IP, "MARC", "pw".into())
578 .await
579 .expect("should authenticate regardless of case");
580 assert_eq!(
581 account.username, "Marc",
582 "the stored spelling is what is returned"
583 );
584 }
585
586 #[tokio::test]
587 async fn a_disabled_account_cannot_log_in() {
588 let (db, _ingest, writer, _dir) = fixture().await;
589 let cfg = cfg();
590 let id = make_user(&db, &cfg, "gone", "pw").await;
591 sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
592 .bind(now())
593 .bind(id)
594 .execute(&db.write)
595 .await
596 .expect("disable");
597
598 let throttle = LoginThrottle::default();
599 let err = authenticate(&db.read, &writer, &cfg, &throttle, IP, "gone", "pw".into())
600 .await
601 .expect_err("must be refused");
602 assert_eq!(
603 err,
604 AuthError::Invalid,
605 "a disabled account must be indistinguishable from a wrong password"
606 );
607 }
608
609 #[tokio::test]
610 async fn repeated_failures_lock_the_pair_out() {
611 let (db, _ingest, writer, _dir) = fixture().await;
612 let cfg = cfg();
613 make_user(&db, &cfg, "victim", "pw").await;
614 let throttle = LoginThrottle::default();
615
616 for _ in 0..MAX_ATTEMPTS {
617 assert_eq!(
618 authenticate(
619 &db.read,
620 &writer,
621 &cfg,
622 &throttle,
623 IP,
624 "victim",
625 "bad".into()
626 )
627 .await
628 .expect_err("wrong password"),
629 AuthError::Invalid
630 );
631 }
632 let err = authenticate(
633 &db.read,
634 &writer,
635 &cfg,
636 &throttle,
637 IP,
638 "victim",
639 "pw".into(),
640 )
641 .await
642 .expect_err("should be locked out even with the right password");
643 assert!(matches!(err, AuthError::LockedOut { .. }));
644 }
645
646 #[tokio::test]
647 async fn a_lockout_does_not_spread_to_other_addresses() {
648 let (db, _ingest, writer, _dir) = fixture().await;
649 let cfg = cfg();
650 make_user(&db, &cfg, "victim", "pw").await;
651 let throttle = LoginThrottle::default();
652 for _ in 0..MAX_ATTEMPTS {
653 let _ = authenticate(
654 &db.read,
655 &writer,
656 &cfg,
657 &throttle,
658 IP,
659 "victim",
660 "bad".into(),
661 )
662 .await;
663 }
664 let elsewhere = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 1));
665 authenticate(
666 &db.read,
667 &writer,
668 &cfg,
669 &throttle,
670 elsewhere,
671 "victim",
672 "pw".into(),
673 )
674 .await
675 .expect("another address must not be locked out — otherwise this is a DoS on the user");
676 }
677
678 #[tokio::test]
679 async fn a_successful_login_clears_the_failure_count() {
680 let (db, _ingest, writer, _dir) = fixture().await;
681 let cfg = cfg();
682 make_user(&db, &cfg, "u", "pw").await;
683 let throttle = LoginThrottle::default();
684 for _ in 0..MAX_ATTEMPTS - 1 {
685 let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
686 }
687 authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
688 .await
689 .expect("still allowed");
690 for _ in 0..MAX_ATTEMPTS - 1 {
691 let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
692 }
693 authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
694 .await
695 .expect("the counter should have been reset by the successful login");
696 }
697
698 #[tokio::test]
699 async fn minting_a_token_makes_it_immediately_usable() {
700 let (db, ingest, _writer, _dir) = fixture().await;
701 let cfg = cfg();
702 let user_id = make_user(&db, &cfg, "u", "pw").await;
703 let vault = KeyVault::for_test([9; 32]);
704
705 let minted = mint_token(&db.write, &vault, &ingest, user_id, "Pixel", "android", IP)
706 .await
707 .expect("mint");
708
709 // A device that logs in and immediately sends a LOC must be served: there
710 // must be no window where a just-issued token looks unknown.
711 assert!(
712 ingest
713 .key_for(minted.token_id, otproto::msg::Direction::Up)
714 .is_some(),
715 "the token must be in the ingest cache before mint_token returns"
716 );
717
718 // And the stored key must round-trip through the vault.
719 let wrapped: Vec<u8> =
720 sqlx::query_scalar("SELECT key_wrapped FROM tokens WHERE token_id = ?")
721 .bind(minted.token_id as i64)
722 .fetch_one(&db.read)
723 .await
724 .expect("wrapped");
725 assert_eq!(
726 vault.unwrap(minted.token_id, &wrapped).expect("unwrap"),
727 minted.token_key
728 );
729 assert_ne!(
730 wrapped.as_slice(),
731 minted.token_key.as_slice(),
732 "the plaintext key must never be what is stored"
733 );
734 }
735
736 #[tokio::test]
737 async fn tokens_reload_into_the_cache_at_startup() {
738 let (db, ingest, _writer, _dir) = fixture().await;
739 let cfg = cfg();
740 let user_id = make_user(&db, &cfg, "u", "pw").await;
741 let vault = KeyVault::for_test([9; 32]);
742 let a = mint_token(&db.write, &vault, &ingest, user_id, "A", "android", IP)
743 .await
744 .expect("a");
745 let b = mint_token(&db.write, &vault, &ingest, user_id, "B", "android", IP)
746 .await
747 .expect("b");
748 revoke_token(&db.write, &ingest, b.token_id as i64)
749 .await
750 .expect("revoke");
751
752 // Simulate a restart: a fresh cache, repopulated from the database.
753 let (writer2, _t) = crate::writer::spawn(db.write.clone());
754 let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
755 let loaded = load_tokens(&db.read, &vault, &fresh).await.expect("load");
756
757 assert_eq!(loaded, 1, "a revoked token must not come back as active");
758 assert_eq!(fresh.active_token_count(), 1);
759 assert!(
760 fresh
761 .key_for(a.token_id, otproto::msg::Direction::Up)
762 .is_some()
763 );
764 // The revoked token keeps its key across a restart, flagged. Without it
765 // the phone that still holds that token could only be met with silence,
766 // and silence is indistinguishable from a network fault.
767 assert!(
768 fresh
769 .key_for(b.token_id, otproto::msg::Direction::Up)
770 .is_some(),
771 "a revoked token must keep its key so its device can be told"
772 );
773 }
774
775 #[tokio::test]
776 async fn revoke_others_keeps_the_current_token_only() {
777 let (db, ingest, _writer, _dir) = fixture().await;
778 let cfg = cfg();
779 let user_id = make_user(&db, &cfg, "u", "pw").await;
780 let vault = KeyVault::for_test([9; 32]);
781 let keep = mint_token(&db.write, &vault, &ingest, user_id, "keep", "android", IP)
782 .await
783 .expect("k");
784 for name in ["a", "b", "c"] {
785 mint_token(&db.write, &vault, &ingest, user_id, name, "android", IP)
786 .await
787 .expect("m");
788 }
789 assert_eq!(ingest.active_token_count(), 4);
790
791 let revoked = revoke_other_tokens(&db.write, &ingest, user_id, Some(keep.token_id as i64))
792 .await
793 .expect("revoke others");
794 assert_eq!(revoked, 3);
795 assert_eq!(ingest.active_token_count(), 1);
796 assert!(
797 ingest
798 .key_for(keep.token_id, otproto::msg::Direction::Up)
799 .is_some()
800 );
801 }
802
803 #[tokio::test]
804 async fn a_token_belonging_to_a_disabled_user_is_not_loaded() {
805 let (db, ingest, _writer, _dir) = fixture().await;
806 let cfg = cfg();
807 let user_id = make_user(&db, &cfg, "u", "pw").await;
808 let vault = KeyVault::for_test([9; 32]);
809 mint_token(&db.write, &vault, &ingest, user_id, "phone", "android", IP)
810 .await
811 .expect("m");
812 sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
813 .bind(now())
814 .bind(user_id)
815 .execute(&db.write)
816 .await
817 .expect("disable");
818
819 let (writer2, _t) = crate::writer::spawn(db.write.clone());
820 let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
821 assert_eq!(
822 load_tokens(&db.read, &vault, &fresh).await.expect("load"),
823 0,
824 "disabling an account must stop its phones, not just its browser logins"
825 );
826 }
827
828 #[test]
829 fn the_dummy_hash_is_parseable() {
830 // If this literal ever stops parsing, the unknown-username path silently
831 // becomes fast and the timing oracle reopens.
832 PasswordHash::new(DUMMY_HASH).expect("the dummy hash must be a valid PHC string");
833 }
834}
835