Skip to main content

hennery_kernel/
operator.rs

1//! The operator (kernel spec §3): the one owner, their password, the
2//! `public_url` setting, and the one-time setup link that sets them up.
3//!
4//! - **One owner.** It exists from the first start (the kernel's
5//!   migrations create it, plan 3b-iii decision 1); setup sets it up, once,
6//!   and a second setup is refused. Every query names that owner (kernel
7//!   spec §1), bound when the operator opens (`db::kernel_owner`).
8//! - **The setup token lives in memory only.** A restart before setup
9//!   issues a new one, and the old one is dead by construction (kernel
10//!   spec §3.1). Only its SHA-256 is kept.
11//! - **Passwords are Argon2id PHC strings** (`password-auth`). Hashing and
12//!   verifying are CPU- and memory-heavy (about 19 MiB each), so they run
13//!   on a blocking thread and at most `MAX_CONCURRENT_HASHES` at once.
14//! - **Recovery** (the admin socket, kernel spec §4.2): a password reset
15//!   and a `public_url` reset each end every session, and so its streams.
16//!
17//! Every time is seconds since the Unix epoch, passed in by the caller.
18
19use crate::db;
20use crate::ratelimit::{Limiter, Policy};
21use crate::secret::{random_bytes, sha256_hex};
22use anyhow::{Context, Result};
23use rusqlite::{Connection, OptionalExtension, params};
24use std::io::Write;
25use std::os::unix::fs::OpenOptionsExt;
26use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::{Arc, Mutex, OnceLock, RwLock};
29
30/// A setup link is valid this long (kernel spec §3.1).
31pub const SETUP_TOKEN_TTL_SECS: i64 = 60 * 60;
32
33/// The shortest password setup accepts, in characters.
34pub const MIN_PASSWORD_CHARS: usize = 12;
35
36/// The longest password accepted, in bytes: hashing is bounded by it.
37pub const MAX_PASSWORD_BYTES: usize = 1024;
38
39/// Argon2 runs at most this many at once, whatever the number of clients.
40pub const MAX_CONCURRENT_HASHES: usize = 2;
41
42/// The file in the data directory that holds the setup link until setup
43/// (kernel spec §1, §3.1).
44pub const SETUP_URL_FILE: &str = "setup-url";
45
46/// The `settings` key of the public URL.
47const PUBLIC_URL_KEY: &str = "public_url";
48
49/// Where browsers reach the collector (umbrella spec §7.5): `https://`, or
50/// `http://` to a loopback address, with no path, query or credentials.
51/// Kept as its origin exactly as a browser serialises it in `Origin`
52/// (lowercase scheme and host, no default port, no trailing slash), so the
53/// two compare as plain strings.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PublicUrl {
56    origin: String,
57    https: bool,
58    /// The host, when it is a domain rather than an IP address.
59    rp_id: Option<String>,
60}
61
62impl PublicUrl {
63    pub fn parse(input: &str) -> Result<Self, String> {
64        let url = url::Url::parse(input.trim()).map_err(|err| format!("public_url is not a URL: {err}"))?;
65        if !url.username().is_empty() || url.password().is_some() {
66            return Err("public_url must not hold credentials".into());
67        }
68        if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
69            return Err("public_url must be an origin only, with no path, query or fragment".into());
70        }
71        let https = match url.scheme() {
72            "https" => true,
73            "http" if is_loopback_host(&url) => false,
74            "http" => return Err("public_url must be https://, or http:// to a loopback address".into()),
75            _ => return Err("public_url must be https:// or http://".into()),
76        };
77        let origin = url.origin().ascii_serialization();
78        let rp_id = url.domain().map(str::to_string);
79        Ok(Self { origin, https, rp_id })
80    }
81
82    /// `scheme://host[:port]`, as a browser's `Origin` header has it.
83    pub fn origin(&self) -> &str {
84        &self.origin
85    }
86
87    /// Whether the session cookie is `Secure`: everywhere but loopback
88    /// `http://` (kernel spec §3.2).
89    pub fn is_https(&self) -> bool {
90        self.https
91    }
92
93    /// The passkeys' relying party id (kernel spec §3.2): the host, in
94    /// lowercase. `None` when the host is an IP address, which WebAuthn
95    /// cannot bind a credential to.
96    pub fn rp_id(&self) -> Option<&str> {
97        self.rp_id.as_deref()
98    }
99}
100
101fn is_loopback_host(url: &url::Url) -> bool {
102    match url.host() {
103        Some(url::Host::Domain(domain)) => domain == "localhost",
104        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
105        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
106        None => false,
107    }
108}
109
110/// The outcome of `Operator::set_up`.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum SetupOutcome {
113    Done {
114        owner_id: String,
115        /// The PHC string just stored: the session setup opens is bound to
116        /// it (`open_session`).
117        phc: String,
118    },
119    /// The owner is set up already.
120    AlreadySetUp,
121    /// Unknown, used or expired: one answer for all three.
122    InvalidToken,
123    /// The password or `public_url` is not acceptable (why); the token is
124    /// not used up.
125    Invalid(String),
126}
127
128/// The outcome of `Operator::reset_password` and `reset_public_url`.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Reset {
131    /// Done; this many signed-in sessions were ended, and this many
132    /// passkeys removed: every one, by a password reset (plan 3c decision
133    /// 2), or those bound to a host name `public_url` no longer has
134    /// (decision 9).
135    Done {
136        sessions_ended: usize,
137        passkeys_removed: usize,
138    },
139    /// The owner is not set up yet: setup is the way in.
140    NotSetUp,
141    /// The new password or `public_url` is not acceptable (why); nothing
142    /// changed.
143    Invalid(String),
144}
145
146/// The outcome of `Operator::change_public_url`.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum PublicUrlChange {
149    /// Done, as `Reset::Done`; `from` and `to` were read and written under
150    /// the lock that orders every change (plan 4d-B4's review, A2).
151    Done {
152        from: Option<PublicUrl>,
153        to: PublicUrl,
154        sessions_ended: usize,
155        passkeys_removed: usize,
156    },
157    /// The owner is not set up yet.
158    NotSetUp,
159    /// The new `public_url` or contact is not acceptable (why); nothing
160    /// changed.
161    Invalid(String),
162    /// The caller's session ended before the write; nothing changed.
163    SignedOut,
164    /// The caller's step-up lapsed before the write; nothing changed.
165    StepUpRequired,
166}
167
168struct SetupToken {
169    hash: String,
170    expires_at: i64,
171}
172
173/// Where the setup link was written (`Operator::announce_setup`). `Debug`
174/// leaves the token out: a failing assertion or a log line must not show it.
175#[derive(Clone, PartialEq, Eq)]
176pub struct SetupLink {
177    /// `<base>/setup#<token>`: the token in the fragment (3b decision 16).
178    pub url: String,
179    /// The 0600 file holding `url`.
180    pub file: PathBuf,
181}
182
183impl std::fmt::Debug for SetupLink {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        let base = self.url.split('#').next().unwrap_or_default();
186        f.debug_struct("SetupLink")
187            .field("url", &format_args!("{base}#<redacted>"))
188            .field("file", &self.file)
189            .finish()
190    }
191}
192
193pub struct Operator {
194    conn: crate::db::CoordinatedConnection,
195    /// The database's owner (`db::kernel_owner`), whom every query names.
196    owner: String,
197    /// Loaded at open and replaced by setup and by `reset_public_url`: read
198    /// on every browser request.
199    public_url: RwLock<Option<PublicUrl>>,
200    setup: Mutex<Option<SetupToken>>,
201    /// The link last written to `setup-url` (and so the file), removed
202    /// once setup is done.
203    announced: Mutex<Option<SetupLink>>,
204    /// `check_password`'s slots, each held until its verify ends.
205    hashing: Arc<tokio::sync::Semaphore>,
206    verifications: AtomicU64,
207    /// Wrong passwords per client address at login (kernel spec §3.2).
208    pub login_limiter: Limiter,
209    /// Wrong passwords per client address at step-up, a budget of its own
210    /// (3b decision 10): a login flood from a shared address does not stop
211    /// a signed-in owner stepping up, nor step-up guesses lock out login.
212    pub step_up_limiter: Limiter,
213    /// Passkey logins begun per client address (plan 3c decision 8): a
214    /// budget of its own, untouched by wrong passwords.
215    pub passkey_limiter: Limiter,
216    /// Bumped whenever a session ends (revoked or signed out), and by
217    /// `reset_password` and `reset_public_url`, each of which ends every
218    /// session: streams held open by a session re-check it on every bump
219    /// (3b decision 7).
220    ended: tokio::sync::watch::Sender<u64>,
221    /// The passkey ceremonies begun and not yet finished (plan 3c
222    /// decision 5).
223    pub ceremonies: crate::passkeys::Ceremonies,
224    /// Verifies running now, and the most ever at once (`check_password`'s
225    /// bound, pinned by the unit tests below).
226    #[cfg(test)]
227    in_flight: std::sync::atomic::AtomicUsize,
228    #[cfg(test)]
229    max_in_flight: std::sync::atomic::AtomicUsize,
230    /// Password resets that have started hashing (`reset_password`'s
231    /// permit, pinned by the unit tests below).
232    #[cfg(test)]
233    reset_hashes: AtomicU64,
234}
235
236impl Operator {
237    /// Open the kernel's tables in `hennery.db` (shared with the host
238    /// registry and the sessions store).
239    pub fn open(path: &Path) -> Result<Self> {
240        Self::init(db::open(path)?)
241    }
242
243    pub fn open_in_memory() -> Result<Self> {
244        Self::init(db::open_in_memory()?)
245    }
246
247    fn init(mut conn: crate::db::OwnedConnection) -> Result<Self> {
248        let owner = db::kernel_owner(&mut conn)?;
249        let public_url = load_public_url(&conn, &owner)?;
250        Ok(Self {
251            conn: crate::db::CoordinatedConnection::new(conn),
252            owner,
253            public_url: RwLock::new(public_url),
254            setup: Mutex::new(None),
255            announced: Mutex::new(None),
256            hashing: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_HASHES)),
257            verifications: AtomicU64::new(0),
258            login_limiter: Limiter::new(Policy::LOGIN),
259            step_up_limiter: Limiter::new(Policy::LOGIN),
260            passkey_limiter: Limiter::new(Policy::PASSKEY_LOGIN),
261            ended: tokio::sync::watch::Sender::new(0),
262            ceremonies: Default::default(),
263            #[cfg(test)]
264            in_flight: Default::default(),
265            #[cfg(test)]
266            max_in_flight: Default::default(),
267            #[cfg(test)]
268            reset_hashes: Default::default(),
269        })
270    }
271
272    pub(crate) fn conn(&self) -> crate::db::ConnectionGuard<'_> {
273        self.conn.lock().expect("operator lock")
274    }
275
276    /// The owner's id. The owner exists from the first start, set up or
277    /// not (`is_set_up`).
278    pub fn owner_id(&self) -> &str {
279        &self.owner
280    }
281
282    /// Whether setup has run: the owner has a password and a `public_url`.
283    pub fn is_set_up(&self) -> Result<bool> {
284        Ok(self.conn().query_row(
285            "SELECT set_up_at IS NOT NULL FROM owners WHERE id = ?1",
286            [&self.owner],
287            |r| r.get(0),
288        )?)
289    }
290
291    /// Whether the database answers a query (`/readyz`).
292    pub fn ping(&self) -> Result<()> {
293        self.conn().query_row("SELECT 1", [], |_| Ok(()))?;
294        Ok(())
295    }
296
297    pub fn public_url(&self) -> Option<PublicUrl> {
298        self.public_url.read().expect("public_url lock").clone()
299    }
300
301    /// A fresh setup token (64 hex characters), valid for
302    /// `SETUP_TOKEN_TTL_SECS`, replacing any earlier one. `None` once there
303    /// is an owner.
304    pub fn issue_setup_token(&self, now: i64) -> Result<Option<String>> {
305        if self.is_set_up()? {
306            return Ok(None);
307        }
308        let token = hex::encode(random_bytes::<32>());
309        *self.setup.lock().expect("setup lock") = Some(SetupToken {
310            hash: sha256_hex(token.as_bytes()),
311            expires_at: now + SETUP_TOKEN_TTL_SECS,
312        });
313        Ok(Some(token))
314    }
315
316    /// Before setup: issue a fresh setup token and write its link,
317    /// `<base_url>/setup#<token>`, to `dir/setup-url` (kernel spec §3.1).
318    /// The file is created 0600 under a temporary name and renamed into
319    /// place, so an existing `setup-url` (even a symlink) is replaced, never
320    /// written through. Once set up: remove a stale `setup-url` and return
321    /// `None`.
322    pub fn announce_setup(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
323        let file = dir.join(SETUP_URL_FILE);
324        let Some(token) = self.issue_setup_token(now)? else {
325            remove_setup_file(&file);
326            return Ok(None);
327        };
328        let url = format!("{}/setup#{token}", base_url.trim_end_matches('/'));
329        let temp = dir.join(format!(".{SETUP_URL_FILE}.{}.tmp", hex::encode(random_bytes::<8>())));
330        let written = (|| {
331            let mut out = std::fs::OpenOptions::new()
332                .write(true)
333                .create_new(true)
334                .mode(0o600)
335                .open(&temp)?;
336            writeln!(out, "{url}")?;
337            out.sync_all()?;
338            std::fs::rename(&temp, &file)
339        })();
340        if let Err(err) = written {
341            let _ = std::fs::remove_file(&temp);
342            return Err(err).with_context(|| format!("write {}", file.display()));
343        }
344        let link = SetupLink { url, file };
345        *self.announced.lock().expect("announced lock") = Some(link.clone());
346        Ok(Some(link))
347    }
348
349    /// The setup link for the admin socket's `setup-url` (kernel spec
350    /// §4.2): the one last announced while its token is live, else a fresh
351    /// one, announced as `announce_setup` does (the old token dies). `None`
352    /// once set up.
353    pub fn setup_link(&self, dir: &Path, base_url: &str, now: i64) -> Result<Option<SetupLink>> {
354        if self.is_set_up()? {
355            return Ok(None);
356        }
357        let live = self
358            .setup
359            .lock()
360            .expect("setup lock")
361            .as_ref()
362            .is_some_and(|t| t.expires_at > now);
363        if live && let Some(link) = self.announced.lock().expect("announced lock").clone() {
364            return Ok(Some(link));
365        }
366        self.announce_setup(dir, base_url, now)
367    }
368
369    /// Set the owner up with `password` and store `public_url` (kernel spec
370    /// §3.1), if `token` is the live setup token, keeping the default hat's
371    /// name. See `set_up_naming_hat`.
372    pub fn set_up(&self, token: &str, password: &str, public_url: &str, now: i64) -> Result<SetupOutcome> {
373        self.set_up_naming_hat(token, password, public_url, None, now)
374    }
375
376    /// Set the owner up with `password` and store `public_url` (kernel spec
377    /// §3.1), if `token` is the live setup token, and name the default hat
378    /// `hat_name` if one is given (plan 5a decision 1: the hat exists from
379    /// the first start). The token is used up only once the owner is
380    /// committed. Hashes the password: call it on a blocking thread. It
381    /// takes no `check_password` slot: only the token's holder gets as far
382    /// as the hash, and the setup lock serialises it.
383    pub fn set_up_naming_hat(
384        &self,
385        token: &str,
386        password: &str,
387        public_url: &str,
388        hat_name: Option<&str>,
389        now: i64,
390    ) -> Result<SetupOutcome> {
391        // Held throughout, so two setups cannot both pass the checks.
392        let mut setup = self.setup.lock().expect("setup lock");
393        if self.is_set_up()? {
394            return Ok(SetupOutcome::AlreadySetUp);
395        }
396        let live = setup
397            .as_ref()
398            .is_some_and(|t| t.expires_at > now && t.hash == sha256_hex(token.as_bytes()));
399        if !live {
400            return Ok(SetupOutcome::InvalidToken);
401        }
402        if let Some(problem) = password_problem(password) {
403            return Ok(SetupOutcome::Invalid(problem));
404        }
405        let public_url = match PublicUrl::parse(public_url) {
406            Ok(url) => url,
407            Err(problem) => return Ok(SetupOutcome::Invalid(problem)),
408        };
409        if let Some(problem) = hat_name.and_then(crate::hats::hat_name_problem) {
410            return Ok(SetupOutcome::Invalid(problem));
411        }
412        let phc = password_auth::generate_hash(password);
413        let owner_id = self.owner.clone();
414        {
415            let mut conn = self.conn();
416            let tx = conn.transaction()?;
417            // Marked in the write, not only checked above: of two setups,
418            // one marks the owner and the other finds it marked.
419            let marked = tx.execute(
420                "UPDATE owners SET set_up_at = ?2 WHERE id = ?1 AND set_up_at IS NULL",
421                params![owner_id, now],
422            )?;
423            if marked == 0 {
424                return Ok(SetupOutcome::AlreadySetUp);
425            }
426            tx.execute(
427                "INSERT INTO password_credentials(owner_id, phc, updated_at) VALUES (?1, ?2, ?3)",
428                params![owner_id, phc, now],
429            )?;
430            tx.execute(
431                "INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)",
432                params![owner_id, PUBLIC_URL_KEY, public_url.origin()],
433            )?;
434            if let Some(name) = hat_name {
435                // The one hat there is before setup: the default for new
436                // hosts, which `up`'s host may already have.
437                let renamed = tx.execute(
438                    "UPDATE hats SET name = ?2 WHERE owner_id = ?1
439                         AND id = (SELECT value FROM settings WHERE owner_id = ?1 AND key = ?3)",
440                    params![owner_id, name.trim(), crate::hats::DEFAULT_HAT_KEY],
441                )?;
442                anyhow::ensure!(renamed == 1, "the owner has no default hat to name");
443            }
444            tx.commit()?;
445            // Still under the connection's lock: no reset lands between
446            // the row and the cache (as `reset_public_url` does it too).
447            *self.public_url.write().expect("public_url lock") = Some(public_url);
448        }
449        *setup = None;
450        if let Some(link) = self.announced.lock().expect("announced lock").take() {
451            remove_setup_file(&link.file);
452        }
453        Ok(SetupOutcome::Done { owner_id, phc })
454    }
455
456    /// Whether `password` is the owner's: the PHC string it verified
457    /// against, or `None`. Before setup it is checked against a dummy hash,
458    /// so the answer takes as long either way. A session opened on the
459    /// strength of it passes the PHC to `open_session`, so a reset landing
460    /// in between wins (3b-ii decision 11). Blocking: prefer
461    /// `check_password`.
462    pub fn verify_password(&self, password: &str) -> Result<Option<String>> {
463        // First, so it is counted before `verifications` and dropped last.
464        #[cfg(test)]
465        let _gauge = InFlight::enter(self);
466        self.verifications.fetch_add(1, Ordering::Relaxed);
467        let phc: Option<String> = self
468            .conn()
469            .query_row(
470                "SELECT phc FROM password_credentials WHERE owner_id = ?1",
471                [&self.owner],
472                |r| r.get(0),
473            )
474            .optional()?;
475        let Some(phc) = phc else {
476            let _ = password_auth::verify_password(password, dummy_hash());
477            return Ok(None);
478        };
479        // An oversized password is still verified (and fails), so its
480        // answer takes as long as any other.
481        let password = if password.len() > MAX_PASSWORD_BYTES {
482            ""
483        } else {
484            password
485        };
486        Ok(password_auth::verify_password(password, &phc).is_ok().then_some(phc))
487    }
488
489    /// `verify_password` on a blocking thread, at most
490    /// `MAX_CONCURRENT_HASHES` at once; others wait their turn. The slot
491    /// moves into the blocking closure: a caller that goes away (a client
492    /// disconnect, a timeout) cannot free it while its verify still runs.
493    pub async fn check_password(self: &Arc<Self>, password: String) -> Result<Option<String>> {
494        let permit = self
495            .hashing
496            .clone()
497            .acquire_owned()
498            .await
499            .context("the hashing semaphore is closed")?;
500        let this = self.clone();
501        tokio::task::spawn_blocking(move || {
502            let _permit = permit;
503            this.verify_password(&password)
504        })
505        .await?
506    }
507
508    /// How many password checks have run: every login attempt that is not
509    /// rate limited runs exactly one (kernel spec §3.2's constant-time
510    /// failure path), which tests pin with this.
511    pub fn verifications(&self) -> u64 {
512        self.verifications.load(Ordering::Relaxed)
513    }
514
515    /// Replace the owner's password (the admin socket's reset, kernel spec
516    /// §4.2) and end every signed-in session, so their streams end too. The
517    /// hash runs on a blocking thread and takes a `check_password` slot like
518    /// any other, so a reset cannot add a third Argon2 run to a login
519    /// flood. The limiters are cleared: the operator proved local access.
520    /// It fails, changing nothing, unless it replaced exactly one password
521    /// (3b-ii's O9): an owner with none (a passkey-only owner, in 3c) has
522    /// nothing to reset. Every passkey is removed and every passkey
523    /// ceremony begun before it ends, a finish re-checking under the lock
524    /// in any case (plan 3c decision 2, amended by its review's A1): a
525    /// passkey added with a stolen session must not survive the recovery.
526    /// With no change-password route, every password change costs the
527    /// passkeys.
528    pub async fn reset_password(self: &Arc<Self>, password: String, now: i64) -> Result<Reset> {
529        if let Some(problem) = password_problem(&password) {
530            return Ok(Reset::Invalid(problem));
531        }
532        if !self.is_set_up()? {
533            return Ok(Reset::NotSetUp);
534        }
535        let permit = self
536            .hashing
537            .clone()
538            .acquire_owned()
539            .await
540            .context("the hashing semaphore is closed")?;
541        #[cfg(test)]
542        let this = self.clone();
543        let phc = tokio::task::spawn_blocking(move || {
544            let _permit = permit;
545            #[cfg(test)]
546            this.reset_hashes.fetch_add(1, Ordering::SeqCst);
547            password_auth::generate_hash(password)
548        })
549        .await?;
550        let (ended, passkeys_removed) = {
551            let mut conn = self.conn();
552            let tx = conn.transaction()?;
553            let replaced = tx.execute(
554                "UPDATE password_credentials SET phc = ?2, updated_at = ?3 WHERE owner_id = ?1",
555                params![self.owner, phc, now],
556            )?;
557            // Otherwise the transaction rolls back: no session ends.
558            anyhow::ensure!(replaced == 1, "the owner has no password to reset");
559            let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
560            // Each went with the session that subscribed it (plan 10a
561            // decision 4): a device subscribed with a stolen session must
562            // not survive the recovery either.
563            tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
564            let passkeys_removed = tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?;
565            tx.commit()?;
566            // Still under the connection's lock, which every finish holds
567            // from its check to its write: none lands after this.
568            self.ceremonies.clear();
569            (ended, passkeys_removed)
570        };
571        self.sessions_ended();
572        self.login_limiter.clear();
573        self.step_up_limiter.clear();
574        self.passkey_limiter.clear();
575        Ok(Reset::Done {
576            sessions_ended: ended,
577            passkeys_removed,
578        })
579    }
580
581    /// Replace `public_url` (the admin socket's recovery when the collector
582    /// moved, 3b decision 4): the stored row and the origin every browser
583    /// request is checked against, which is cached here. Every signed-in
584    /// session ends: they were opened at the old origin, and the new one
585    /// signs in afresh. Passkeys are bound to the host name, the RP id
586    /// (kernel spec §3.2): when it changes they stop working, and are
587    /// removed; a move to another port or scheme keeps them (plan 3c
588    /// decision 9). Every passkey ceremony begun before it ends too, a
589    /// finish re-checking under the lock in any case.
590    pub fn reset_public_url(&self, input: &str) -> Result<Reset> {
591        Ok(match self.change_public_url(input, None, None)? {
592            PublicUrlChange::Done {
593                sessions_ended,
594                passkeys_removed,
595                ..
596            } => Reset::Done {
597                sessions_ended,
598                passkeys_removed,
599            },
600            PublicUrlChange::NotSetUp => Reset::NotSetUp,
601            PublicUrlChange::Invalid(problem) => Reset::Invalid(problem),
602            // Only a caller's session is re-checked, and the admin socket
603            // names none.
604            PublicUrlChange::SignedOut | PublicUrlChange::StepUpRequired => {
605                anyhow::bail!("the admin socket's reset re-checked a session")
606            }
607        })
608    }
609
610    /// What `reset_public_url` does, the one way `public_url` changes after
611    /// setup: the admin socket's reset and `PATCH /api/settings` (plan 4d-B4
612    /// decision 1) both call it.
613    /// - With `contact`, the push contact is set too (`Some(None)` clears
614    ///   it), in the same transaction: a body that names both changes both
615    ///   or neither. Both are checked before anything is written; either
616    ///   one refused is `Invalid`, nothing changed.
617    /// - With `caller`, the session that asked and the time it asked: the
618    ///   transaction's first statement re-checks that session, live and
619    ///   stepped up, as a passkey registration's write does (plan 3c A2;
620    ///   4d-B4's review, A1). One ended or stepped down since the request's
621    ///   checks (a password reset, a revoke, a slow body) changes nothing.
622    pub fn change_public_url(
623        &self,
624        input: &str,
625        contact: Option<Option<&str>>,
626        caller: Option<(&str, i64)>,
627    ) -> Result<PublicUrlChange> {
628        let public_url = match PublicUrl::parse(input) {
629            Ok(url) => url,
630            Err(problem) => return Ok(PublicUrlChange::Invalid(problem)),
631        };
632        if let Some(problem) = contact.flatten().and_then(crate::push::contact_problem) {
633            return Ok(PublicUrlChange::Invalid(problem));
634        }
635        if !self.is_set_up()? {
636            return Ok(PublicUrlChange::NotSetUp);
637        }
638        let (from, ended, passkeys_removed) = {
639            let mut conn = self.conn();
640            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
641            if let Some((session_id, now)) = caller {
642                let stepped_up_at: Option<Option<i64>> = tx
643                    .query_row(
644                        "SELECT last_step_up_at FROM auth_sessions
645                         WHERE id_hash = ?1 AND owner_id = ?2 AND expires_at > ?3",
646                        params![session_id, self.owner, now],
647                        |r| r.get(0),
648                    )
649                    .optional()?;
650                match stepped_up_at {
651                    None => return Ok(PublicUrlChange::SignedOut),
652                    // `Authenticated::stepped_up`'s window.
653                    Some(at) if !at.is_some_and(|at| now - at < STEP_UP_SECS) => {
654                        return Ok(PublicUrlChange::StepUpRequired);
655                    }
656                    Some(_) => {}
657                }
658            }
659            if let Some(contact) = contact {
660                tx.execute(
661                    "UPDATE owners SET contact = ?2 WHERE id = ?1",
662                    params![self.owner, contact],
663                )?;
664            }
665            tx.execute(
666                "INSERT INTO settings(owner_id, key, value) VALUES (?1, ?2, ?3)
667                 ON CONFLICT(owner_id, key) DO UPDATE SET value = excluded.value",
668                params![self.owner, PUBLIC_URL_KEY, public_url.origin()],
669            )?;
670            let ended = tx.execute("DELETE FROM auth_sessions WHERE owner_id = ?1", [&self.owner])?;
671            // Every subscription goes with its session (plan 10a decision
672            // 4), and each was made by a service worker of the old origin.
673            tx.execute("DELETE FROM push_subscriptions WHERE owner_id = ?1", [&self.owner])?;
674            // Read under the lock, which every writer of the cache holds.
675            let from = self.public_url();
676            let same_host = from.as_ref().and_then(PublicUrl::rp_id) == public_url.rp_id();
677            let passkeys_removed = if same_host {
678                0
679            } else {
680                tx.execute("DELETE FROM passkeys WHERE owner_id = ?1", [&self.owner])?
681            };
682            tx.commit()?;
683            // Still under the connection's lock: no other reset lands
684            // between the row and the cache.
685            *self.public_url.write().expect("public_url lock") = Some(public_url.clone());
686            self.ceremonies.clear();
687            (from, ended, passkeys_removed)
688        };
689        self.sessions_ended();
690        Ok(PublicUrlChange::Done {
691            from,
692            to: public_url,
693            sessions_ended: ended,
694            passkeys_removed,
695        })
696    }
697
698    /// The owner's push contact (kernel spec §6): an e-mail address, or
699    /// `None` when they gave none and the VAPID token names `public_url`.
700    pub fn contact(&self) -> Result<Option<String>> {
701        Ok(self
702            .conn()
703            .query_row("SELECT contact FROM owners WHERE id = ?1", [&self.owner], |r| r.get(0))?)
704    }
705
706    /// Set the owner's push contact, or clear it with `None` (plan 10a
707    /// decision 7). `Err` with the reason, and nothing stored, for one that
708    /// is not a plain e-mail address.
709    pub fn set_contact(&self, contact: Option<&str>) -> Result<Result<(), String>> {
710        if let Some(problem) = contact.and_then(crate::push::contact_problem) {
711            return Ok(Err(problem));
712        }
713        self.conn().execute(
714            "UPDATE owners SET contact = ?2 WHERE id = ?1",
715            params![self.owner, contact],
716        )?;
717        Ok(Ok(()))
718    }
719
720    /// Wake every stream held open by a session: some have ended.
721    fn sessions_ended(&self) {
722        self.ended.send_modify(|generation| *generation += 1);
723    }
724}
725
726fn remove_setup_file(file: &Path) {
727    match std::fs::remove_file(file) {
728        Ok(()) => {}
729        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
730        Err(err) => tracing::warn!(file = %file.display(), error = %err, "could not remove the setup link"),
731    }
732}
733
734fn load_public_url(conn: &Connection, owner: &str) -> Result<Option<PublicUrl>> {
735    let stored: Option<String> = conn
736        .query_row(
737            "SELECT value FROM settings WHERE owner_id = ?1 AND key = ?2",
738            [owner, PUBLIC_URL_KEY],
739            |r| r.get(0),
740        )
741        .optional()?;
742    stored
743        .map(|s| PublicUrl::parse(&s).map_err(|why| anyhow::anyhow!("the stored public_url is invalid: {why}")))
744        .transpose()
745}
746
747/// Why `password` cannot be the owner's, if it cannot.
748fn password_problem(password: &str) -> Option<String> {
749    if password.chars().count() < MIN_PASSWORD_CHARS {
750        return Some(format!("the password must be at least {MIN_PASSWORD_CHARS} characters"));
751    }
752    if password.len() > MAX_PASSWORD_BYTES {
753        return Some(format!("the password must be at most {MAX_PASSWORD_BYTES} bytes"));
754    }
755    None
756}
757
758/// A hash no password is checked against for real, so a check without an
759/// owner costs one Argon2 verify like any other.
760fn dummy_hash() -> &'static str {
761    static HASH: OnceLock<String> = OnceLock::new();
762    HASH.get_or_init(|| password_auth::generate_hash(hex::encode(random_bytes::<16>())))
763}
764
765/// A signed-in session lives this long past its last use (kernel spec §3.2).
766pub const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
767
768/// A session's expiry slides at most this often, so a busy client does
769/// not write to the database on every request.
770pub const SESSION_SLIDE_SECS: i64 = 60;
771
772/// A password check is fresh enough for step-up this long (kernel spec
773/// §3.4).
774pub const STEP_UP_SECS: i64 = 5 * 60;
775
776/// The session cookie's name (kernel spec §3.2).
777pub const SESSION_COOKIE: &str = "hennery_session";
778
779/// The longest `User-Agent` kept with a session, in characters.
780const MAX_USER_AGENT: usize = 256;
781
782/// `user_agent` as a session keeps it: no control characters, at most
783/// `MAX_USER_AGENT` characters.
784pub(crate) fn kept_user_agent(user_agent: &str) -> String {
785    user_agent
786        .chars()
787        .filter(|c| !c.is_control())
788        .take(MAX_USER_AGENT)
789        .collect()
790}
791
792/// A request's session, once its cookie checks out.
793#[derive(Debug, Clone, PartialEq, Eq)]
794pub struct Authenticated {
795    /// The session's id: the SHA-256 of its token, as stored.
796    pub session_id: String,
797    pub owner_id: String,
798    pub last_step_up_at: Option<i64>,
799    pub expires_at: i64,
800    /// This request slid the expiry: the cookie is sent again with it.
801    pub slid: bool,
802}
803
804impl Authenticated {
805    /// Whether the last password check was within `STEP_UP_SECS`.
806    pub fn stepped_up(&self, now: i64) -> bool {
807        self.last_step_up_at.is_some_and(|at| now - at < STEP_UP_SECS)
808    }
809}
810
811/// Store a session for `owner` that `token` opens, stepped up at `now`,
812/// after dropping the owner's expired sessions: what every login does, by
813/// password (`open_session`) or by passkey. With `verified`, the session is
814/// stored only while that PHC string is still the owner's password, and
815/// `false` says it was not. `None` skips that binding, so it is only for a
816/// caller that proved the login some other way in the same transaction (a
817/// passkey login's `record_use`).
818pub(crate) fn insert_auth_session(
819    conn: &Connection,
820    owner: &str,
821    token: &str,
822    user_agent: &str,
823    verified: Option<&str>,
824    now: i64,
825) -> Result<bool> {
826    conn.execute(
827        "DELETE FROM auth_sessions WHERE expires_at <= ?1 AND owner_id = ?2",
828        params![now, owner],
829    )?;
830    let opened = conn.execute(
831        "INSERT INTO auth_sessions(id_hash, owner_id, user_agent, created_at, last_seen_at, last_step_up_at, expires_at)
832         SELECT ?1, ?2, ?3, ?4, ?4, ?4, ?5
833         WHERE ?6 IS NULL OR EXISTS (SELECT 1 FROM password_credentials WHERE owner_id = ?2 AND phc = ?6)",
834        params![
835            sha256_hex(token.as_bytes()),
836            owner,
837            kept_user_agent(user_agent),
838            now,
839            now + SESSION_TTL_SECS,
840            verified
841        ],
842    )?;
843    Ok(opened > 0)
844}
845
846/// One signed-in session, as Settings lists them (kernel spec §3.2).
847#[derive(Debug, Clone, PartialEq, Eq)]
848pub struct AuthSession {
849    pub id: String,
850    pub user_agent: String,
851    pub created_at: i64,
852    pub last_seen_at: i64,
853    pub last_step_up_at: Option<i64>,
854    pub expires_at: i64,
855}
856
857impl Operator {
858    /// Open a session for the owner and return its token, the cookie's
859    /// value. Only the token's hash is stored. The password was just
860    /// checked, so the session starts stepped up. `verified` is the PHC
861    /// string that check verified against (`verify_password`): the session
862    /// is opened only while it is still the owner's, so a login whose check
863    /// raced a password reset gets no session (`None`), like one before
864    /// setup, when the owner has no password.
865    pub fn open_session(&self, user_agent: &str, verified: &str, now: i64) -> Result<Option<String>> {
866        let token = hex::encode(random_bytes::<32>());
867        let opened = insert_auth_session(&self.conn(), &self.owner, &token, user_agent, Some(verified), now)?;
868        Ok(opened.then_some(token))
869    }
870
871    /// The live session `token` names, if any. Its expiry slides to
872    /// `SESSION_TTL_SECS` from now, at most every `SESSION_SLIDE_SECS`.
873    pub fn authenticate(&self, token: &str, now: i64) -> Result<Option<Authenticated>> {
874        if token.len() != 64 {
875            return Ok(None);
876        }
877        let id = sha256_hex(token.as_bytes());
878        let conn = self.conn();
879        let row: Option<(i64, Option<i64>, i64)> = conn
880            .query_row(
881                "SELECT last_seen_at, last_step_up_at, expires_at FROM auth_sessions
882                 WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
883                params![id, now, self.owner],
884                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
885            )
886            .optional()?;
887        let Some((last_seen_at, last_step_up_at, mut expires_at)) = row else {
888            return Ok(None);
889        };
890        let slid = now - last_seen_at >= SESSION_SLIDE_SECS;
891        if slid {
892            expires_at = now + SESSION_TTL_SECS;
893            conn.execute(
894                "UPDATE auth_sessions SET last_seen_at = ?2, expires_at = ?3 WHERE id_hash = ?1 AND owner_id = ?4",
895                params![id, now, expires_at, self.owner],
896            )?;
897        }
898        Ok(Some(Authenticated {
899            session_id: id,
900            owner_id: self.owner.clone(),
901            last_step_up_at,
902            expires_at,
903            slid,
904        }))
905    }
906
907    /// Record a fresh password check on a session (kernel spec §3.4).
908    /// Whether the session still exists.
909    pub fn step_up(&self, session_id: &str, now: i64) -> Result<bool> {
910        let changed = self.conn().execute(
911            "UPDATE auth_sessions SET last_step_up_at = ?2 WHERE id_hash = ?1 AND owner_id = ?3",
912            params![session_id, now, self.owner],
913        )?;
914        Ok(changed > 0)
915    }
916
917    /// Every live session, most recently used first.
918    pub fn sessions(&self, now: i64) -> Result<Vec<AuthSession>> {
919        let conn = self.conn();
920        let mut stmt = conn.prepare(
921            "SELECT id_hash, user_agent, created_at, last_seen_at, last_step_up_at, expires_at
922             FROM auth_sessions WHERE expires_at > ?1 AND owner_id = ?2 ORDER BY last_seen_at DESC, id_hash",
923        )?;
924        let rows = stmt.query_map(params![now, self.owner], |r| {
925            Ok(AuthSession {
926                id: r.get(0)?,
927                user_agent: r.get(1)?,
928                created_at: r.get(2)?,
929                last_seen_at: r.get(3)?,
930                last_step_up_at: r.get(4)?,
931                expires_at: r.get(5)?,
932            })
933        })?;
934        Ok(rows.collect::<rusqlite::Result<_>>()?)
935    }
936
937    /// End a live session, and the push subscriptions it made (plan 10a
938    /// decision 4): a device signed out, or revoked as lost, gets no more
939    /// notifications. Whether there was one: an expired session is not
940    /// listed by `sessions`, so it is not there to revoke either.
941    pub fn revoke_session(&self, session_id: &str, now: i64) -> Result<bool> {
942        let mut conn = self.conn();
943        let tx = conn.transaction()?;
944        let changed = tx.execute(
945            "DELETE FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
946            params![session_id, now, self.owner],
947        )?;
948        if changed > 0 {
949            tx.execute(
950                "DELETE FROM push_subscriptions WHERE auth_session = ?1 AND owner_id = ?2",
951                params![session_id, self.owner],
952            )?;
953        }
954        tx.commit()?;
955        drop(conn);
956        if changed > 0 {
957            self.sessions_ended();
958        }
959        Ok(changed > 0)
960    }
961
962    /// Changes whenever a session ends (`revoke_session`, and so logout,
963    /// and the resets).
964    pub fn session_ends(&self) -> tokio::sync::watch::Receiver<u64> {
965        self.ended.subscribe()
966    }
967
968    /// When the live session `session_id` expires, without sliding it;
969    /// `None` once it is gone or expired.
970    pub fn session_expires_at(&self, session_id: &str, now: i64) -> Result<Option<i64>> {
971        Ok(self
972            .conn()
973            .query_row(
974                "SELECT expires_at FROM auth_sessions WHERE id_hash = ?1 AND expires_at > ?2 AND owner_id = ?3",
975                params![session_id, now, self.owner],
976                |r| r.get(0),
977            )
978            .optional()?)
979    }
980
981    /// The first of the request's session cookies (`session_tokens`) that
982    /// authenticates, with its token; `None` when none does. Like
983    /// `authenticate`, it slides the session it finds.
984    pub fn authenticate_cookies(
985        &self,
986        headers: &axum::http::HeaderMap,
987        now: i64,
988    ) -> Result<Option<(String, Authenticated)>> {
989        for token in session_tokens(headers) {
990            if let Some(session) = self.authenticate(token, now)? {
991                return Ok(Some((token.to_string(), session)));
992            }
993        }
994        Ok(None)
995    }
996}
997
998/// `Set-Cookie` for a session (kernel spec §3.2): `HttpOnly`,
999/// `SameSite=Strict`, `Path=/`, for `SESSION_TTL_SECS`, and `Secure`
1000/// unless `public_url` is loopback `http://`.
1001pub fn session_cookie(token: &str, secure: bool) -> String {
1002    let secure = if secure { "; Secure" } else { "" };
1003    format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={SESSION_TTL_SECS}{secure}")
1004}
1005
1006/// `Set-Cookie` that removes the session cookie.
1007pub fn cleared_cookie(secure: bool) -> String {
1008    let secure = if secure { "; Secure" } else { "" };
1009    format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0{secure}")
1010}
1011
1012/// At most this many `hennery_session` cookies of a request are tried, so
1013/// one request cannot cost many session lookups.
1014pub const MAX_SESSION_COOKIES: usize = 4;
1015
1016/// Every session token in a request's `Cookie` headers, in order, at most
1017/// `MAX_SESSION_COOKIES`. There can be more than one: a sibling subdomain
1018/// can set a cookie of the same name for the parent domain, and the browser
1019/// may send it first. Taking only the first would let it sign the owner out.
1020pub fn session_tokens(headers: &axum::http::HeaderMap) -> Vec<&str> {
1021    headers
1022        .get_all(axum::http::header::COOKIE)
1023        .iter()
1024        .filter_map(|v| v.to_str().ok())
1025        .flat_map(|v| v.split(';'))
1026        .filter_map(|pair| {
1027            let (name, value) = pair.trim().split_once('=')?;
1028            (name == SESSION_COOKIE).then_some(value)
1029        })
1030        .take(MAX_SESSION_COOKIES)
1031        .collect()
1032}
1033
1034/// Counts one verify in `Operator::in_flight` for as long as it lives.
1035#[cfg(test)]
1036struct InFlight<'a>(&'a Operator);
1037
1038#[cfg(test)]
1039impl<'a> InFlight<'a> {
1040    fn enter(op: &'a Operator) -> Self {
1041        let now = op.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1042        op.max_in_flight.fetch_max(now, Ordering::SeqCst);
1043        Self(op)
1044    }
1045}
1046
1047#[cfg(test)]
1048impl Drop for InFlight<'_> {
1049    fn drop(&mut self) {
1050        self.0.in_flight.fetch_sub(1, Ordering::SeqCst);
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use std::time::{Duration, Instant};
1058
1059    const NOW: i64 = 1_800_000_000;
1060    const PASSWORD: &str = "correct horse battery";
1061
1062    fn set_up() -> Arc<Operator> {
1063        let op = Arc::new(Operator::open_in_memory().unwrap());
1064        let token = op.issue_setup_token(NOW).unwrap().unwrap();
1065        op.set_up(&token, PASSWORD, "https://hennery.example", NOW).unwrap();
1066        op
1067    }
1068
1069    /// Wait (bounded) until `done` holds.
1070    async fn until(what: &str, done: impl Fn() -> bool) {
1071        let deadline = Instant::now() + Duration::from_secs(30);
1072        while !done() {
1073            assert!(Instant::now() < deadline, "timed out waiting until {what}");
1074            tokio::time::sleep(Duration::from_millis(1)).await;
1075        }
1076    }
1077
1078    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1079    async fn at_most_two_checks_verify_at_once() {
1080        let op = set_up();
1081        let checks: Vec<_> = (0..8)
1082            .map(|_| {
1083                let op = op.clone();
1084                tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await.unwrap() })
1085            })
1086            .collect();
1087        for check in checks {
1088            assert!(check.await.unwrap().is_some());
1089        }
1090        assert_eq!(op.verifications(), 8);
1091        assert!(op.max_in_flight.load(Ordering::SeqCst) <= MAX_CONCURRENT_HASHES);
1092    }
1093
1094    /// A password reset hashes only once it holds a slot: with both taken
1095    /// (two verifies of a login flood, say) it waits, and runs once one is
1096    /// free.
1097    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1098    async fn a_password_reset_hashes_only_with_a_hashing_slot() {
1099        let op = set_up();
1100        let held = op
1101            .hashing
1102            .clone()
1103            .acquire_many_owned(MAX_CONCURRENT_HASHES as u32)
1104            .await
1105            .unwrap();
1106        let reset = {
1107            let op = op.clone();
1108            tokio::spawn(async move { op.reset_password("a new long password".into(), NOW).await })
1109        };
1110        // Load can only make this pass falsely (the reset not yet started),
1111        // never fail falsely.
1112        tokio::time::sleep(Duration::from_millis(200)).await;
1113        assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 0, "hashed without a slot");
1114        drop(held);
1115        assert_eq!(
1116            reset.await.unwrap().unwrap(),
1117            Reset::Done {
1118                sessions_ended: 0,
1119                passkeys_removed: 0
1120            }
1121        );
1122        assert_eq!(op.reset_hashes.load(Ordering::SeqCst), 1);
1123    }
1124
1125    /// A check whose caller goes away (a client disconnect, a timeout)
1126    /// keeps its slot until its verify ends: the verify runs on regardless.
1127    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1128    async fn an_abandoned_check_keeps_its_slot_until_its_verify_ends() {
1129        let op = set_up();
1130        for round in 1..=8u64 {
1131            let checks: Vec<_> = (0..2)
1132                .map(|_| {
1133                    let op = op.clone();
1134                    tokio::spawn(async move { op.check_password(PASSWORD.to_string()).await })
1135                })
1136                .collect();
1137            until("both checks of the round verify", || op.verifications() == 2 * round).await;
1138            for check in &checks {
1139                check.abort();
1140            }
1141        }
1142        until("every verify ends", || op.in_flight.load(Ordering::SeqCst) == 0).await;
1143        let max = op.max_in_flight.load(Ordering::SeqCst);
1144        assert!(max <= MAX_CONCURRENT_HASHES, "{max} verifies ran at once");
1145    }
1146}