Skip to main content

choir_node/
accounts.rs

1//! Account and credential self-service (D36).
2//!
3//! Adding a user used to mean the operator appending a line to the
4//! `--auth-file` by hand and restarting the daemon, then appending
5//! another to the `--acl-file`, then a third to an `authorized_keys`.
6//! This module is the same three facts issued through the node instead:
7//! one record per account holding the BLAKE3 of its token, the grants it
8//! was issued with, and its registered SSH keys.
9//!
10//! Three properties carry the design.
11//!
12//! **Issuance is invite-only.** There is no registration endpoint. An
13//! account exists because a holder of `@node write` minted a single-use,
14//! expiring invite naming it, and the invited person redeemed that
15//! invite. The invite *is a credential*: it is presented as ordinary
16//! basic auth, so the node's 401 wall, its constant-time compare and
17//! D33's per-user buckets all apply to redemption without a line of new
18//! code, and an anonymous request still reaches nothing. An unauthenticated
19//! redeem route would have been the one unmetered, unattributed way in.
20//!
21//! **Issued grants are enforced by the D29 table, not beside it.** The
22//! store renders its grants as ACL lines and hands them to
23//! [`Acl::parse`], and [`Accounts::acl`] returns the result. The node
24//! merges that with the file table, so the git chokepoint,
25//! [`crate::acl::api_denial`], [`crate::acl::filter_response`], D33's
26//! `@node` exemption and the `choir-ssh` shim all grade an issued
27//! credential without any of them knowing this module exists. The store
28//! can never grant [`crate::acl::Scope::Node`]: node-wide authority stays operator-
29//! authored in the ACL file, so self-service cannot mint itself an
30//! auditor or a rate-limit exemption.
31//!
32//! **This is not in the op log, and that is the decision.** D35 measured
33//! what a new `OpKind` variant costs: the fold has no tolerant arm, so
34//! every reader older than the variant stops materializing the log at the
35//! first op it does not know, totally rather than partially. Paying that
36//! for credentials would also buy the wrong property — the log is
37//! append-only and replayed in full by any `@node auditor`, and a token
38//! hash placed there could never be forgotten, while revocation's whole
39//! contract is that it forgets. So the store is a node-owned file,
40//! rewritten in place, and revocation is deletion.
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44use std::sync::atomic::{AtomicU64, Ordering};
45use std::sync::RwLock;
46
47use choir_identity::ActorKey;
48use choir_oplog::ContentHash;
49
50use crate::acl::Acl;
51
52/// Version of the on-disk store. Every persisted struct carries one
53/// (invariant 1); new fields are additive and old files still load.
54pub const FORMAT_VERSION: u64 = 1;
55
56/// How long an unredeemed invite stays usable when the caller names no
57/// lifetime: one day, which is longer than handing someone a secret takes
58/// and shorter than forgetting about it does.
59pub const DEFAULT_INVITE_SECS: u64 = 86_400;
60
61/// Longest lifetime an invite may be given. An invite is a bearer secret
62/// for an account that does not exist yet, so "expires eventually" is not
63/// the same promise as "expires".
64pub const MAX_INVITE_SECS: u64 = 30 * 86_400;
65
66/// Prefix every invite id carries, and a spelling [`validate_username`]
67/// refuses, so an invite can never name the same principal an account
68/// does.
69pub const INVITE_PREFIX: &str = "invite-";
70
71/// The name an open seat's grants are graded against (D75).
72///
73/// A grant's validity does not depend on who holds it --
74/// [`validate_grant`] uses the name only to build a line for
75/// [`Acl::parse`] and returns the `<target> <level>` half -- but it
76/// needs *a* name to build one. This is a legal username and can never
77/// be an account: [`validate_username`] refuses it, so nothing can
78/// redeem into it.
79const OPEN_SEAT: &str = "open-seat";
80
81/// Stood in for a missing token hash so the comparison in
82/// [`Accounts::authenticate`] runs over a fixed string either way (D75).
83///
84/// Never a hash of anything: it is not a valid [`ContentHash`] spelling,
85/// so no secret can hash to it.
86const NO_TOKEN: &str = "no-token";
87
88/// Prefix on an access-request id (D72), so an id says which table it
89/// belongs to without a lookup. Disjoint from [`INVITE_PREFIX`] because
90/// the two live in different maps and a reader comparing a claim link
91/// against a log line should not have to guess which one they are
92/// holding.
93pub const REQUEST_PREFIX: &str = "ask-";
94
95/// How long an unanswered access request survives (D72).
96///
97/// Long, because the person who asked cannot be reminded and an operator
98/// who is away for three weeks should not come back to an empty queue.
99/// Finite, because an unanswered request is a row in a file that nobody
100/// is ever going to answer, and the alternative to a sweep is an
101/// operator deleting rows by hand.
102pub const REQUEST_TTL_SECS: u64 = 30 * 86_400;
103
104/// How long the invite a granted request becomes is good for (D72).
105///
106/// A week rather than [`DEFAULT_INVITE_SECS`]'s day: nobody is standing
107/// by. The asker learns they were granted by revisiting the link they
108/// were given, so the window has to survive them not looking for a
109/// while.
110pub const GRANTED_REQUEST_SECS: u64 = 7 * 86_400;
111
112/// The most unanswered requests this node holds at once (D72).
113///
114/// The endpoint that fills this table is the only one on the node an
115/// unauthenticated caller can use to write to disk, so it needs a
116/// ceiling that is not "the disk". Sixty-four is a queue a person can
117/// actually read; past that the honest answer to the sixty-fifth
118/// stranger is that the operator is not keeping up, which is what the
119/// refusal says.
120pub const MAX_PENDING_REQUESTS: usize = 64;
121
122/// Longest `about` line a request may carry (D72). One line, not a
123/// letter: the operator reads a queue of these, and the decision they
124/// are making is whether to let somebody in, not whether the essay was
125/// good.
126pub const MAX_ABOUT_CHARS: usize = 280;
127
128/// Most WebAuthn credentials one account may enrol (D39). A person has a
129/// laptop, a phone and a hardware key; a hundred is not a person with
130/// many devices, it is a store being filled by something automated.
131pub const MAX_PASSKEYS: usize = 8;
132
133/// Longest credential id accepted. The spec allows up to 1023 raw bytes;
134/// this is that ceiling in base64url, so nothing legitimate is refused
135/// and an unbounded string is.
136pub const MAX_CREDENTIAL_ID_CHARS: usize = 1364;
137
138/// Longest passkey label. Long enough to say "work laptop, touch id",
139/// short enough that a roster stays a roster.
140pub const MAX_LABEL_CHARS: usize = 64;
141
142/// Who a set of presented credentials turned out to be.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Principal {
145    /// An operator credential from `--auth-file`, or a redeemed account
146    /// from the store. Both are ordinary users of the node, and the name
147    /// is what every downstream check keys on.
148    Account(String),
149    /// An unredeemed invite, identified by its id. It may reach exactly
150    /// one route — its own redemption — and is refused everywhere else,
151    /// which is enforced by the caller rather than here.
152    Invite(String),
153}
154
155impl Principal {
156    /// The name to attribute a request to. For an invite this is the
157    /// invite id, so a redemption attempt is attributable in the D33
158    /// request log without naming the account it would create.
159    #[must_use]
160    pub fn name(&self) -> &str {
161        match self {
162            Self::Account(user) | Self::Invite(user) => user,
163        }
164    }
165
166    /// Whether this principal is an unredeemed invite.
167    #[must_use]
168    pub fn is_invite(&self) -> bool {
169        matches!(self, Self::Invite(_))
170    }
171}
172
173/// One issued account.
174#[derive(Debug, Clone)]
175struct Account {
176    /// BLAKE3 of the token, never the token: the store is read by the
177    /// process that serves requests, and a stolen store should not be a
178    /// stolen credential.
179    ///
180    /// `None` on an account that has never had one (D75). A passwordless
181    /// account holds a passkey and nothing else, and the honest way to
182    /// say "there is no password" is to store no hash rather than a hash
183    /// of a secret nobody was ever shown. [`Accounts::authenticate`]
184    /// matches nothing for such an account, so basic auth simply has no
185    /// answer for it -- which is the point: the credential a browser
186    /// uses is the passkey, and one for git is minted when it is
187    /// actually wanted.
188    token_hash: Option<String>,
189    /// Grants in the ACL file's own two-column spelling, e.g.
190    /// `owner/repo read`.
191    grants: Vec<String>,
192    /// `ssh-ed25519 <base64>` pairs, comment deliberately dropped — see
193    /// [`validate_ssh_key`].
194    ssh_keys: Vec<String>,
195    /// Enrolled WebAuthn credentials (D39). Held inside the account
196    /// rather than in a table beside it, so revocation — which deletes
197    /// the account — cannot forget to forget them.
198    passkeys: Vec<Passkey>,
199    /// What to call this account's holder in anything a person reads
200    /// (D46). `None` on an account whose *name* is already the human
201    /// name — every account issued before D46, and any issued since with
202    /// an explicit `user`.
203    ///
204    /// This is the deletable half of an identity and the only half. The
205    /// account's key in [`State::accounts`] is what
206    /// [`crate::quota::channel_for`] turns into a channel, which
207    /// `choir_oplog::signing_hash` covers, which the log keeps forever —
208    /// so a name used as the key can never be withdrawn, while a name
209    /// held here is one row to drop.
210    ///
211    /// Nothing authorizes against it and nothing may start: the moment a
212    /// display name decides anything, deleting it changes what the node
213    /// permits, and it stops being safe to delete.
214    display_name: Option<String>,
215    /// The channel the redemption bound an ed25519 actor key to, and
216    /// that key's public hex. `None` on every account redeemed without
217    /// one, which is every account issued before invite binding existed.
218    ///
219    /// Recorded here so the roster can answer "which key did this
220    /// account arrive with" after the fact. It is **not** what admits
221    /// the key: the trusted-keys file is, exactly as for a key an
222    /// operator pasted by hand, so revoking stays one line to delete and
223    /// this row cannot contradict what the node actually trusts.
224    actor_key: Option<(String, String)>,
225    /// Unix seconds at redemption.
226    created_at: u64,
227}
228
229/// One enrolled WebAuthn credential (D39).
230///
231/// Both byte strings are public: a credential id is an opaque handle the
232/// browser hands back, and the key is a public key. Nothing here is a
233/// secret, which is why this record — unlike [`Account::token_hash`] —
234/// stores values rather than hashes of them. A verifier needs the key
235/// itself, so hashing it would make it useless.
236#[derive(Debug, Clone)]
237struct Passkey {
238    /// The credential id as the browser reports it, base64url. Used to
239    /// pick which enrolled key an assertion claims to come from.
240    credential_id: String,
241    /// The credential public key, base64url of SubjectPublicKeyInfo DER
242    /// — exactly what `getPublicKey()` returns. D39 scoped a CBOR reader
243    /// out, so nothing here parses an attestation object.
244    public_key: String,
245    /// What the holder called it, so a roster of three keys is legible.
246    label: String,
247    /// Unix seconds at enrolment.
248    created_at: u64,
249}
250
251/// One minted, not yet redeemed invite.
252#[derive(Debug, Clone)]
253struct Invite {
254    /// BLAKE3 of the secret half.
255    secret_hash: String,
256    /// Account this invite creates when redeemed, when the **issuer**
257    /// insisted on one -- a bot, a script, a name an operator needs to
258    /// be exact.
259    ///
260    /// `None` is an open seat, and it is the ordinary case (D75): the
261    /// person redeeming picks their own username. D46's rule that
262    /// nobody's real name is frozen into the op log by somebody else is
263    /// not weakened by that, it is the reason for it. The log keeps
264    /// `OpEntry::channel` forever and a signature covers it, so the one
265    /// string about a person that can never be withdrawn should be the
266    /// one they chose knowing that, rather than one an operator typed
267    /// on their behalf or one this node invented for them.
268    user: Option<String>,
269    /// Readable name the redeemed account carries (D46), when the issuer
270    /// gave one. Travels on the invite because the person redeeming it
271    /// does not choose it — the issuer did.
272    display_name: Option<String>,
273    /// Grants that account will be issued.
274    grants: Vec<String>,
275    /// Unix seconds after which it stops working.
276    expires_at: u64,
277    /// Who minted it. Kept so the operator can audit issuance without a
278    /// second log.
279    issued_by: String,
280    /// Unix seconds at minting.
281    issued_at: u64,
282}
283
284/// One access request nobody has answered yet (D72).
285///
286/// Deliberately not an [`Invite`] with empty grants. An invite is a
287/// decision already taken and a credential already live; this is neither,
288/// and the two must not be able to be confused by a lookup that forgets
289/// to check a flag. They are separate tables for the same reason
290/// `accounts` and `invites` are.
291///
292/// **It holds no way to reach the asker, on purpose.** An address here
293/// would be a stranger's personal data sitting in an operator's file
294/// forever, collected by an unauthenticated endpoint, to solve a problem
295/// the claim link already solves: the asker keeps the link and comes
296/// back to it, and it starts working when the operator says yes. Nothing
297/// is sent, so nothing needs to be stored to send it to.
298#[derive(Debug, Clone)]
299struct Request {
300    /// BLAKE3 of the secret half of the claim link.
301    secret_hash: String,
302    /// What the asker calls themselves. **Never a principal**: the
303    /// handle is minted when the operator grants, exactly as for an
304    /// invite issued with a `display_name` (D46), so a stranger cannot
305    /// choose the string the op log will carry forever.
306    display_name: String,
307    /// The one line the asker wrote about why.
308    about: String,
309    /// Unix seconds at asking.
310    asked_at: u64,
311    /// Unix seconds after which it is swept.
312    expires_at: u64,
313}
314
315/// One pending request, as the operator's queue and the asker's own
316/// waiting page read it (D72).
317///
318/// A copy rather than a borrow, for the same reason [`InviteSummary`] is
319/// one: the lock is released before anything is rendered.
320#[derive(Debug, Clone)]
321pub struct RequestSummary {
322    /// The request id. Half of the claim link, and the id the operator
323    /// grants against.
324    pub id: String,
325    /// What the asker called themselves.
326    pub display_name: String,
327    /// The one line they wrote.
328    pub about: String,
329    /// Unix seconds at asking.
330    pub asked_at: u64,
331    /// Unix seconds after which it is swept.
332    pub expires_at: u64,
333}
334
335/// What an invite promises its holder, read by the join page (D57).
336///
337/// A copy rather than a borrow: the store's lock is released before the
338/// page is rendered, and rendering must not be able to hold it.
339///
340/// Deliberately not the whole stored invite: `secret_hash` has no business
341/// leaving the store, and `issued_at` says nothing a holder needs.
342pub struct InviteSummary {
343    /// The account name redemption will create, when the issuer fixed
344    /// one at minting.
345    ///
346    /// `None` is the ordinary case (D75): an open seat, whose holder
347    /// picks their own username on the page that redeems it.
348    pub user: Option<String>,
349    /// Readable name the account will carry (D46), when the issuer set one.
350    pub display_name: Option<String>,
351    /// The grants that account will be issued, as `<repo> <level>`.
352    pub grants: Vec<String>,
353    /// Unix seconds after which the invite stops working.
354    pub expires_at: u64,
355    /// Who minted it, so the holder can see whether they know that name.
356    pub issued_by: String,
357}
358
359/// The whole store, as held in memory.
360#[derive(Debug, Default, Clone)]
361struct State {
362    accounts: BTreeMap<String, Account>,
363    invites: BTreeMap<String, Invite>,
364    /// Unanswered access requests (D72), keyed by request id.
365    ///
366    /// Additive on disk: absent from every store written before D72, and
367    /// that decodes as empty. Nothing here authorizes anything, which is
368    /// what makes an unauthenticated writer into this table safe to have
369    /// at all.
370    requests: BTreeMap<String, Request>,
371    /// Names that have held an account and been revoked, and are
372    /// therefore never issued again.
373    ///
374    /// **Forget the secret, remember the name** — and the second half
375    /// costs nothing, because the name was never forgettable.
376    ///
377    /// Revocation deletes the token hash, the grants and the keys: a
378    /// credential must be forgettable, which is the whole case for
379    /// keeping credentials out of the append-only log. A name is not a
380    /// credential. `OpEntry::channel` carries `git/<name>` on every op
381    /// its holder ever authored, and `choir_oplog::signing_hash` covers
382    /// that channel, so the name sits *inside the author's signature* in
383    /// a hash chain — unrewritable without invalidating the signature
384    /// that makes the entry admissible. This list therefore adds no new
385    /// permanent record. It indexes one the log already keeps forever,
386    /// so that reissuing a name cannot hand a second person the first
387    /// person's signed attribution: their workspace tally (D37), their
388    /// provenance, their reviews.
389    ///
390    /// **Deliberately over-refuses.** The precise rule is "refuse a name
391    /// that has authored at least one op", since a name that was issued
392    /// and never used has no attribution to inherit. Answering that
393    /// needs a scan of log entries or a maintained index — `View::apply`
394    /// takes a `ViewOp` and never sees the channel, so no fold can
395    /// answer it — which is new derived persisted state to protect a
396    /// rare case whose workaround is picking another name. Refusing
397    /// every revoked name is the cheaper side to err on, and it is a
398    /// choice rather than an oversight.
399    retired: BTreeSet<String>,
400}
401
402/// Where a generated `authorized_keys` goes and what the forced command
403/// in it should say (D31's file, written by the node instead of by hand).
404#[derive(Debug, Clone)]
405pub struct SshKeysOut {
406    /// File to write. `sshd` is pointed at it once with
407    /// `AuthorizedKeysFile`; it is generated, so a hand edit is lost at
408    /// the next mutation.
409    pub path: PathBuf,
410    /// The `choir-ssh` binary the forced command runs.
411    pub shim: PathBuf,
412    /// Repository root the shim serves, its `--root`.
413    pub root: PathBuf,
414    /// The `--handoff` file the shim reads the daemon's address and
415    /// loopback secret from. Without it the shim refuses pushes rather
416    /// than running them unsequenced, so it is not optional here.
417    pub handoff: PathBuf,
418}
419
420/// The credential store: accounts, invites, and the two files it owns.
421#[derive(Debug)]
422pub struct Accounts {
423    path: PathBuf,
424    keys_out: Option<SshKeysOut>,
425    /// Trusted-keys file a redemption may append one bound actor key to,
426    /// when the operator asked for that with `--invite-binds-keys`.
427    ///
428    /// `None` is the default and the pre-existing behaviour: redemption
429    /// mints a token and nothing else, and an actor key still reaches
430    /// the node by an operator pasting a line. The flag exists because
431    /// that paste is the second out-of-band human step in admission, and
432    /// whether to automate it is the operator's call rather than ours.
433    actor_keys: Option<PathBuf>,
434    /// Names the store may never issue, because something else already
435    /// answers to them: every `--auth-file` user, plus the `anon`
436    /// placeholder an unauthenticated request is attributed to.
437    reserved: BTreeSet<String>,
438    state: RwLock<State>,
439    /// Bumped on every mutation. The node caches the merged ACL against
440    /// it, so authentication does not rebuild a table per request.
441    generation: AtomicU64,
442}
443
444impl Accounts {
445    /// Opens the store at `path`, creating an empty one if absent, and
446    /// writes the generated `authorized_keys` if one is configured.
447    ///
448    /// `reserved` is the set of names that already exist elsewhere —
449    /// the `--auth-file` users — and can therefore never be issued.
450    ///
451    /// # Errors
452    ///
453    /// Returns a message when the file cannot be read, does not parse, or
454    /// cannot be written back. Fatal by design: a store that half-loaded
455    /// would silently drop somebody's credential, and the failure would
456    /// look like a revocation nobody performed.
457    pub fn open(
458        path: PathBuf,
459        keys_out: Option<SshKeysOut>,
460        reserved: BTreeSet<String>,
461    ) -> Result<Self, String> {
462        let state = match std::fs::read_to_string(&path) {
463            Ok(text) => parse_state(&text).map_err(|e| format!("{}: {e}", path.display()))?,
464            Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::default(),
465            Err(e) => return Err(format!("{}: {e}", path.display())),
466        };
467        let store = Self {
468            path,
469            keys_out,
470            actor_keys: None,
471            reserved,
472            state: RwLock::new(state),
473            generation: AtomicU64::new(0),
474        };
475        // Written on every start for the reason D31's handoff is: the
476        // file is derived state, and a store restored from backup beside
477        // a stale `authorized_keys` would serve keys nobody holds.
478        let state = store.state.read().expect("accounts read lock");
479        store.write_authorized_keys(&state)?;
480        drop(state);
481        Ok(store)
482    }
483
484    /// Lets a redemption bind one actor key by appending it to the
485    /// operator's trusted-keys file at `path` (D51).
486    ///
487    /// Opt-in, and off unless the operator passed `--invite-binds-keys`.
488    /// What it removes is the *clerical* half of admission: the operator
489    /// still decides who is admitted, by issuing the invite, and the
490    /// invite still carries the grants. What it stops requiring is a
491    /// second out-of-band round trip in which a newcomer pastes a hex
492    /// string to a human who pastes it into a file.
493    ///
494    /// The key lands in the same file an operator would have edited, so
495    /// nothing downstream learns a new source of trust: the existing
496    /// mtime reload picks it up, `allowed_signers` is regenerated from
497    /// it, and revoking is still deleting one line.
498    #[must_use]
499    pub fn binding_actor_keys_into(mut self, path: PathBuf) -> Self {
500        self.actor_keys = Some(path);
501        self
502    }
503
504    /// Appends one `<channel> <hex>` line to the trusted-keys file.
505    ///
506    /// Read-modify-write rather than an open-in-append-mode: the file is
507    /// 0600 operator-authored config, and this must not widen its mode,
508    /// truncate it, or leave it half-written if the process dies. It
509    /// also must not append a line the file already carries, which is
510    /// what makes a replayed redemption a no-op instead of a duplicate
511    /// the parser then refuses as two keys for one name.
512    fn append_actor_key(&self, channel: &str, key_hex: &str) -> Result<(), String> {
513        let Some(path) = &self.actor_keys else {
514            return Ok(());
515        };
516        let existing = match std::fs::read_to_string(path) {
517            Ok(text) => text,
518            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
519            Err(e) => return Err(format!("{}: {e}", path.display())),
520        };
521        let line = format!("{channel} {key_hex}");
522        if existing
523            .lines()
524            .any(|row| row.split('#').next().unwrap_or("").trim() == line)
525        {
526            return Ok(());
527        }
528        // A name already spoken for by another key is refused rather
529        // than appended: `parse_keys_file` rejects a file binding one
530        // name twice, so appending would not grant this key anything —
531        // it would break every key in the file at the next reload.
532        if existing.lines().any(|row| {
533            row.split('#')
534                .next()
535                .unwrap_or("")
536                .split_whitespace()
537                .next()
538                .is_some_and(|name| name == channel)
539        }) {
540            return Err(format!(
541                "the trusted-keys file already binds `{channel}` to a different key"
542            ));
543        }
544        let mut next = existing;
545        if !next.is_empty() && !next.ends_with('\n') {
546            next.push('\n');
547        }
548        next.push_str(&line);
549        next.push('\n');
550        choir_fs::write_atomic_private(path, &next).map_err(|e| format!("{}: {e}", path.display()))
551    }
552
553    /// Where the store is persisted. The `choir-ssh` shim is pointed at
554    /// it through the D31 handoff file.
555    #[must_use]
556    pub fn path(&self) -> &Path {
557        &self.path
558    }
559
560    /// How many accounts have been issued.
561    #[must_use]
562    pub fn len(&self) -> usize {
563        self.state
564            .read()
565            .expect("accounts read lock")
566            .accounts
567            .len()
568    }
569
570    /// Whether no account has been issued yet.
571    #[must_use]
572    pub fn is_empty(&self) -> bool {
573        self.len() == 0
574    }
575
576    /// Counter bumped by every mutation, for callers caching anything
577    /// derived from the store.
578    #[must_use]
579    pub fn generation(&self) -> u64 {
580        self.generation.load(Ordering::Acquire)
581    }
582
583    /// The grants this store holds, as an [`Acl`] the node merges with
584    /// the file table.
585    ///
586    /// Built by rendering ACL lines and parsing them, rather than by
587    /// constructing grants directly, so the two paths cannot drift into
588    /// meaning different things by the same words.
589    #[must_use]
590    pub fn acl(&self) -> Acl {
591        let state = self.state.read().expect("accounts read lock");
592        Acl::parse(&acl_text(&state)).unwrap_or_default()
593    }
594
595    /// Identifies a presented `user:secret` pair, in constant time
596    /// against the stored hash, or `None` when it matches nothing.
597    ///
598    /// An expired invite matches nothing, so expiry needs no sweep to
599    /// take effect.
600    #[must_use]
601    pub fn authenticate(&self, user: &str, secret: &str) -> Option<Principal> {
602        let presented = hash(secret);
603        let state = self.state.read().expect("accounts read lock");
604        if let Some(account) = state.accounts.get(user) {
605            // A passwordless account (D75) matches nothing here, and
606            // the compare still runs against a fixed string so that
607            // "no password" and "wrong password" cost the same.
608            let stored = account.token_hash.as_deref().unwrap_or(NO_TOKEN);
609            if account.token_hash.is_some() && ct_eq(stored.as_bytes(), presented.as_bytes()) {
610                return Some(Principal::Account(user.to_string()));
611            }
612            return None;
613        }
614        let invite = state.invites.get(user)?;
615        if invite.expires_at <= now_secs() {
616            return None;
617        }
618        ct_eq(invite.secret_hash.as_bytes(), presented.as_bytes())
619            .then(|| Principal::Invite(user.to_string()))
620    }
621
622    /// Why `user` cannot be issued, or `None` if it can.
623    ///
624    /// One answer for the two moments that ask it -- minting an invite
625    /// that fixes a name, and redeeming one into a name the holder
626    /// chose (D75). They used to be one copy each, and the second was
627    /// the one a person actually reads, since it is the one that
628    /// answers somebody typing a name into a box.
629    fn name_taken(&self, state: &State, user: &str) -> Option<String> {
630        if state.accounts.contains_key(user) {
631            return Some(format!("`{user}` is taken"));
632        }
633        if self.reserved.contains(user) {
634            return Some(format!(
635                "`{user}` is already an operator credential; the auth file owns that name"
636            ));
637        }
638        // Names are never reused. See `State::retired`: the log has this
639        // string frozen into every channel the old holder wrote under,
640        // and issuing it again transfers their attribution to somebody
641        // else with nothing able to tell them apart afterwards.
642        if state.retired.contains(user) {
643            return Some(format!(
644                "`{user}` was held by somebody else and is never reused: the op log still \
645                 attributes that name's history to whoever had it. Choose another."
646            ));
647        }
648        None
649    }
650
651    /// Mints an invite for the account described by `body`, as `issuer`.
652    ///
653    /// Returns the API's `(status, json)`. The secret half is in the
654    /// response and nowhere else: only its hash is stored, so an invite
655    /// that is lost is reissued rather than recovered.
656    #[must_use]
657    pub fn invite(&self, issuer: &str, body: &serde_json::Value) -> (u16, String) {
658        // Which field names the account decides who chooses the one
659        // string about a person the op log can never withdraw.
660        //
661        // `user` still means "this exact string is the principal", which
662        // is what a bot or a script wants and is the only way an issuer
663        // fixes a name. `display_name` no longer mints a handle (D75):
664        // it records what to call somebody and leaves the seat open, so
665        // the person redeeming picks their own username. Neither is an
666        // open seat with nothing written down, which is what a link
667        // handed to somebody you have not met yet is.
668        //
669        // D46 is not weakened by that; it is the reason for it. The rule
670        // was that nobody's real name gets frozen into the log by
671        // somebody else. Minting a handle satisfied it by letting *this
672        // node* choose instead of the operator, which is one step better
673        // and one step short: the person themselves never got asked.
674        let (seat, display_name) = match (
675            body.get("user").and_then(serde_json::Value::as_str),
676            body.get("display_name").and_then(serde_json::Value::as_str),
677        ) {
678            (Some(_), Some(_)) => {
679                return bad_request(
680                    "send `user` or `display_name`, not both: they are two different \
681                     decisions about what the log records forever",
682                )
683            }
684            (Some(user), None) => (Some(user.to_string()), None),
685            (None, Some(name)) => {
686                if let Err(e) = validate_display_name(name) {
687                    return bad_request(&e);
688                }
689                (None, Some(name.to_string()))
690            }
691            (None, None) => (None, None),
692        };
693        if let Some(user) = seat.as_deref() {
694            if let Err(e) = validate_username(user) {
695                return bad_request(&e);
696            }
697            if self.reserved.contains(user) {
698                return bad_request(&format!(
699                    "`{user}` is already an operator credential; the auth file owns that name"
700                ));
701            }
702        }
703        // An open seat has no user to grade a grant against yet, and
704        // `validate_grant` uses the name only to build a line for
705        // `Acl::parse` -- what it returns is the `<target> <level>` half,
706        // which no name appears in. So a placeholder is graded here and
707        // the real name is graded again at redemption, where it exists.
708        let grading_name = seat.as_deref().unwrap_or(OPEN_SEAT);
709        let grants = match body.get("grants") {
710            Some(serde_json::Value::Array(items)) => {
711                let mut grants = Vec::new();
712                for item in items {
713                    let Some(text) = item.as_str() else {
714                        return bad_request("each grant must be a string, `<repo|*> <read|write>`");
715                    };
716                    match validate_grant(grading_name, text) {
717                        Ok(grant) => grants.push(grant),
718                        Err(e) => return bad_request(&e),
719                    }
720                }
721                grants
722            }
723            // An account with no grants is a credential that authenticates
724            // and reaches nothing, which is a confusing thing to hand
725            // somebody. Refused rather than issued.
726            _ => return bad_request("`grants` is required and must be a non-empty array"),
727        };
728        if grants.is_empty() {
729            return bad_request("`grants` is required and must be a non-empty array");
730        }
731        let ttl = match body.get("expires_in_secs") {
732            None => DEFAULT_INVITE_SECS,
733            Some(value) => match value.as_u64() {
734                Some(secs) if secs > 0 && secs <= MAX_INVITE_SECS => secs,
735                _ => {
736                    return bad_request(&format!(
737                        "`expires_in_secs` must be between 1 and {MAX_INVITE_SECS}"
738                    ))
739                }
740            },
741        };
742
743        let mut state = self.state.write().expect("accounts write lock");
744        state
745            .invites
746            .retain(|_, invite| invite.expires_at > now_secs());
747        // Only a seat somebody claimed can already be taken. An open one
748        // is checked at redemption instead, which is the only moment the
749        // name exists.
750        if let Some(user) = seat.as_deref() {
751            if let Some(taken) = self.name_taken(&state, user) {
752                return conflict(&taken);
753            }
754            if state
755                .invites
756                .values()
757                .any(|invite| invite.user.as_deref() == Some(user))
758            {
759                return conflict(&format!(
760                    "`{user}` already has an invite outstanding; revoke it first"
761                ));
762            }
763        }
764        let id = format!("{INVITE_PREFIX}{}", mint_secret());
765        let secret = mint_secret();
766        let expires_at = now_secs().saturating_add(ttl);
767        state.invites.insert(
768            id.clone(),
769            Invite {
770                secret_hash: hash(&secret),
771                user: seat.clone(),
772                display_name: display_name.clone(),
773                grants: grants.clone(),
774                expires_at,
775                issued_by: issuer.to_string(),
776                issued_at: now_secs(),
777            },
778        );
779        if let Err(e) = self.commit(&state) {
780            return server_error(&e);
781        }
782        drop(state);
783        (
784            200,
785            serde_json::json!({
786                "format_version": FORMAT_VERSION,
787                // The principal, when the issuer fixed one. `null` on
788                // an open seat (D75), because the answer does not exist
789                // yet: the person redeeming has not picked it.
790                "user": seat,
791                "display_name": display_name,
792                "invite_id": id,
793                // The two halves as one basic-auth pair, because that is
794                // how it is used: `curl -u <invite>` and nothing else.
795                "invite": format!("{id}:{secret}"),
796                "grants": grants,
797                "expires_at": expires_at,
798                "note": "single use; shown once. The holder redeems it at POST /api/accounts/redeem.",
799            })
800            .to_string(),
801        )
802    }
803
804    /// Records an access request and returns its claim link halves (D72).
805    ///
806    /// The one write on this node an unauthenticated caller can perform.
807    /// What makes that safe is that the row it writes authorizes
808    /// **nothing**: it is not a credential, it does not appear in
809    /// [`Accounts::acl`], and [`Accounts::authenticate`] will not return
810    /// it. Until an operator grants it, holding the secret proves only
811    /// that you are the person who asked.
812    ///
813    /// The queue is capped and swept here rather than by a timer, so the
814    /// bound holds without anything having to be running.
815    #[must_use]
816    pub fn request_access(&self, body: &serde_json::Value) -> (u16, String) {
817        let Some(display_name) = body.get("display_name").and_then(serde_json::Value::as_str)
818        else {
819            return bad_request("`display_name` is required: say what to call you");
820        };
821        if let Err(e) = validate_display_name(display_name) {
822            return bad_request(&e);
823        }
824        let about = body
825            .get("about")
826            .and_then(serde_json::Value::as_str)
827            .unwrap_or("")
828            .trim()
829            .to_string();
830        if about.chars().count() > MAX_ABOUT_CHARS {
831            return bad_request(&format!(
832                "`about` must be at most {MAX_ABOUT_CHARS} characters"
833            ));
834        }
835        if about.chars().any(char::is_control) {
836            return bad_request("`about` must not contain control characters");
837        }
838
839        let now = now_secs();
840        let mut state = self.state.write().expect("accounts write lock");
841        state.requests.retain(|_, request| request.expires_at > now);
842        if state.requests.len() >= MAX_PENDING_REQUESTS {
843            // 503 rather than 429: the caller did nothing wrong and
844            // retrying in a minute will not help. The queue is full
845            // until a person empties it.
846            return (
847                503,
848                serde_json::json!({
849                    "error": "this node's request queue is full; try again once the operator \
850                              has worked through it",
851                })
852                .to_string(),
853            );
854        }
855        let id = format!("{REQUEST_PREFIX}{}", mint_secret());
856        let secret = mint_secret();
857        let expires_at = now.saturating_add(REQUEST_TTL_SECS);
858        state.requests.insert(
859            id.clone(),
860            Request {
861                secret_hash: hash(&secret),
862                display_name: display_name.trim().to_string(),
863                about,
864                asked_at: now,
865                expires_at,
866            },
867        );
868        if let Err(e) = self.commit(&state) {
869            return server_error(&e);
870        }
871        drop(state);
872        (
873            200,
874            serde_json::json!({
875                "format_version": FORMAT_VERSION,
876                "request_id": id,
877                // Both halves as one pair, the same shape an invite
878                // answers with, so the caller that builds a link out of
879                // one can build a link out of the other.
880                "request": format!("{id}:{secret}"),
881                "expires_at": expires_at,
882                "note": "keep the link; it becomes an invite if the operator grants it.",
883            })
884            .to_string(),
885        )
886    }
887
888    /// The request `id`, if it is pending and `secret` is its secret (D72).
889    ///
890    /// Deliberately **not** part of [`Accounts::authenticate`]. That
891    /// function answers "who is this", and the honest answer for a
892    /// pending request is nobody: it reaches no route, carries no grant
893    /// and passes no check. Keeping it out means no arm anywhere else on
894    /// this node has to remember to refuse it.
895    #[must_use]
896    pub fn pending_request(&self, id: &str, secret: &str) -> Option<RequestSummary> {
897        let presented = hash(secret);
898        let state = self.state.read().expect("accounts read lock");
899        let request = state.requests.get(id)?;
900        if request.expires_at <= now_secs() {
901            return None;
902        }
903        ct_eq(request.secret_hash.as_bytes(), presented.as_bytes()).then(|| RequestSummary {
904            id: id.to_string(),
905            display_name: request.display_name.clone(),
906            about: request.about.clone(),
907            asked_at: request.asked_at,
908            expires_at: request.expires_at,
909        })
910    }
911
912    /// Every pending request, oldest first, for the operator's queue (D72).
913    ///
914    /// Oldest first because a queue is worked from the front, and because
915    /// the id order is random — sorting by it would shuffle the list on
916    /// every render for no reason a reader could follow.
917    #[must_use]
918    pub fn pending_requests(&self) -> Vec<RequestSummary> {
919        let now = now_secs();
920        let state = self.state.read().expect("accounts read lock");
921        let mut rows: Vec<RequestSummary> = state
922            .requests
923            .iter()
924            .filter(|(_, request)| request.expires_at > now)
925            .map(|(id, request)| RequestSummary {
926                id: id.clone(),
927                display_name: request.display_name.clone(),
928                about: request.about.clone(),
929                asked_at: request.asked_at,
930                expires_at: request.expires_at,
931            })
932            .collect();
933        rows.sort_by_key(|row| (row.asked_at, row.id.clone()));
934        rows
935    }
936
937    /// Turns a pending request into a live invite, as `issuer` (D72).
938    ///
939    /// **The id and the secret hash are carried over unchanged.** That is
940    /// the whole mechanism: the link the asker already holds is the link
941    /// that starts working, so a grant needs no message sent, no address
942    /// stored, and no second artefact to lose.
943    ///
944    /// The account handle is minted here rather than at asking, so the
945    /// string the op log carries forever is chosen by this node at the
946    /// moment an operator said yes -- never by the stranger, and never
947    /// before anybody agreed to it (D46).
948    #[must_use]
949    pub fn grant_request(&self, issuer: &str, body: &serde_json::Value) -> (u16, String) {
950        let Some(id) = body.get("request_id").and_then(serde_json::Value::as_str) else {
951            return bad_request("`request_id` is required");
952        };
953        let ttl = match body.get("expires_in_secs") {
954            None => GRANTED_REQUEST_SECS,
955            Some(value) => match value.as_u64() {
956                Some(secs) if secs > 0 && secs <= MAX_INVITE_SECS => secs,
957                _ => {
958                    return bad_request(&format!(
959                        "`expires_in_secs` must be between 1 and {MAX_INVITE_SECS}"
960                    ))
961                }
962            },
963        };
964        let raw_grants = match body.get("grants") {
965            Some(serde_json::Value::Array(items)) if !items.is_empty() => {
966                let mut rows = Vec::new();
967                for item in items {
968                    let Some(text) = item.as_str() else {
969                        return bad_request("each grant must be a string, `<repo|*> <read|write>`");
970                    };
971                    rows.push(text.to_string());
972                }
973                rows
974            }
975            _ => return bad_request("`grants` is required and must be a non-empty array"),
976        };
977
978        let now = now_secs();
979        let mut state = self.state.write().expect("accounts write lock");
980        state.requests.retain(|_, request| request.expires_at > now);
981        let Some(request) = state.requests.get(id).cloned() else {
982            return (
983                404,
984                r#"{"error":"no such request; it may have expired or been declined"}"#.to_string(),
985            );
986        };
987        // The seat stays open (D75): the person who asked picks their
988        // own username when they redeem. Minting a handle here would be
989        // this node choosing the one string about them the op log can
990        // never withdraw, on behalf of somebody who is right there and
991        // can be asked.
992        //
993        // Grants are graded against the placeholder for the same reason
994        // `invite` does, and graded again at redemption once the name
995        // exists.
996        let mut grants = Vec::new();
997        for text in &raw_grants {
998            match validate_grant(OPEN_SEAT, text) {
999                Ok(grant) => grants.push(grant),
1000                Err(e) => return bad_request(&e),
1001            }
1002        }
1003        state.requests.remove(id);
1004        state.invites.retain(|_, invite| invite.expires_at > now);
1005        let expires_at = now.saturating_add(ttl);
1006        state.invites.insert(
1007            id.to_string(),
1008            Invite {
1009                secret_hash: request.secret_hash.clone(),
1010                user: None,
1011                display_name: Some(request.display_name.clone()),
1012                grants: grants.clone(),
1013                expires_at,
1014                issued_by: issuer.to_string(),
1015                issued_at: now,
1016            },
1017        );
1018        if let Err(e) = self.commit(&state) {
1019            return server_error(&e);
1020        }
1021        drop(state);
1022        (
1023            200,
1024            serde_json::json!({
1025                "format_version": FORMAT_VERSION,
1026                // No name yet, and that is the design: they pick it.
1027                "user": serde_json::Value::Null,
1028                "display_name": request.display_name,
1029                "invite_id": id,
1030                "grants": grants,
1031                "expires_at": expires_at,
1032                // No `invite` pair and no `join_url`: this node never
1033                // held the secret half, only its hash, and the person who
1034                // does hold it is already holding the link. Answering
1035                // with a link here would mean minting a second secret and
1036                // breaking the first one.
1037                "note": "the link the asker already holds now works.",
1038            })
1039            .to_string(),
1040        )
1041    }
1042
1043    /// Drops a pending request (D72).
1044    ///
1045    /// The asker's link then renders exactly the page a link that was
1046    /// never valid renders -- the one refusal the join page gives for
1047    /// every reason an invite does not work. Declining says nothing
1048    /// back: a node that distinguished "declined" from "never existed"
1049    /// would let a stranger probe which of their guesses had been read.
1050    #[must_use]
1051    pub fn decline_request(&self, body: &serde_json::Value) -> (u16, String) {
1052        let Some(id) = body.get("request_id").and_then(serde_json::Value::as_str) else {
1053            return bad_request("`request_id` is required");
1054        };
1055        let mut state = self.state.write().expect("accounts write lock");
1056        if state.requests.remove(id).is_none() {
1057            return (
1058                404,
1059                r#"{"error":"no such request; it may have expired or been declined"}"#.to_string(),
1060            );
1061        }
1062        if let Err(e) = self.commit(&state) {
1063            return server_error(&e);
1064        }
1065        drop(state);
1066        (200, r#"{"declined":true}"#.to_string())
1067    }
1068
1069    /// Redeems `invite_id`, creating its account and minting its token.
1070    ///
1071    /// The token is in the response and nowhere else. An optional
1072    /// `ssh_key` registers a key at the same time, which is what makes
1073    /// the SSH transport self-service rather than a second errand.
1074    #[must_use]
1075    pub fn redeem(&self, invite_id: &str, body: &serde_json::Value) -> (u16, String) {
1076        let actor_key = match body.get("actor_key") {
1077            None | Some(serde_json::Value::Null) => None,
1078            Some(serde_json::Value::String(hex)) => {
1079                if self.actor_keys.is_none() {
1080                    return bad_request(
1081                        "this node does not bind actor keys at redemption; \
1082                         ask the operator to register your key, or to start the \
1083                         daemon with --invite-binds-keys",
1084                    );
1085                }
1086                match validate_actor_key(hex) {
1087                    Ok(hex) => Some(hex),
1088                    Err(e) => return bad_request(&e),
1089                }
1090            }
1091            Some(_) => return bad_request("`actor_key` must be a string"),
1092        };
1093        let ssh_key = match body.get("ssh_key") {
1094            None | Some(serde_json::Value::Null) => None,
1095            Some(serde_json::Value::String(line)) => match validate_ssh_key(line) {
1096                Ok(key) => Some(key),
1097                Err(e) => return bad_request(&e),
1098            },
1099            Some(_) => return bad_request("`ssh_key` must be a string"),
1100        };
1101        // The passkey a passwordless account is created with (D75).
1102        //
1103        // Enrolled *by* the redemption rather than after it, which is
1104        // what makes the whole route passwordless: the invite link is
1105        // the credential that authorizes this one act, and what it
1106        // leaves behind is an account whose credential is an
1107        // authenticator. There is no moment in between where a password
1108        // has to exist for the person to sign in and enrol one.
1109        let passkey = match body.get("passkey") {
1110            None | Some(serde_json::Value::Null) => None,
1111            Some(value) => match parse_enrolment(value) {
1112                Ok(key) => Some(key),
1113                Err(e) => return bad_request(&e),
1114            },
1115        };
1116        let mut state = self.state.write().expect("accounts write lock");
1117        let Some(invite) = state.invites.get(invite_id).cloned() else {
1118            return (404, error_json("no such invite"));
1119        };
1120        if invite.expires_at <= now_secs() {
1121            state.invites.remove(invite_id);
1122            let _ = self.commit(&state);
1123            return (403, error_json("this invite has expired"));
1124        }
1125        // Who this becomes. The issuer decided only when they insisted
1126        // (D75); otherwise the person redeeming is being asked, here,
1127        // for the one string about them the op log can never withdraw.
1128        let user = match invite.user.clone() {
1129            Some(fixed) => fixed,
1130            None => {
1131                let Some(chosen) = body.get("user").and_then(serde_json::Value::as_str) else {
1132                    return bad_request("`user` is required: this invite lets you pick your name");
1133                };
1134                let chosen = chosen.trim().to_string();
1135                if let Err(e) = validate_username(&chosen) {
1136                    return bad_request(&e);
1137                }
1138                chosen
1139            }
1140        };
1141        // The auth file, the roster and the retired list can all have
1142        // gained that name between minting and redemption -- and on an
1143        // open seat none of them was ever consulted, because the name
1144        // did not exist. Issuing anyway would create an account nothing
1145        // can use: authentication consults the operator's file first, so
1146        // it would be a credential that authenticates as somebody else.
1147        if let Some(taken) = self.name_taken(&state, &user) {
1148            return conflict(&taken);
1149        }
1150        if let Some(key) = passkey.as_ref() {
1151            if credential_enrolled(&state, &key.credential_id) {
1152                return conflict("that credential is already enrolled");
1153            }
1154        }
1155        if state
1156            .invites
1157            .iter()
1158            .any(|(id, other)| id != invite_id && other.user.as_deref() == Some(user.as_str()))
1159        {
1160            return conflict(&format!(
1161                "`{user}` is spoken for by an invite not yet redeemed"
1162            ));
1163        }
1164        // The grants were graded against a placeholder when the seat was
1165        // open, so they are graded again now that the name exists. An
1166        // issuer cannot smuggle `@node` past the first check and have it
1167        // land here, and this is the line that says so.
1168        let mut grants = Vec::with_capacity(invite.grants.len());
1169        for grant in &invite.grants {
1170            match validate_grant(&user, grant) {
1171                Ok(checked) => grants.push(checked),
1172                Err(e) => return bad_request(&e),
1173            }
1174        }
1175        // The bound channel always carries the account name as its
1176        // operator prefix. That is not decoration: the reviewer draw
1177        // refuses to draw a reviewer sharing the author's operator
1178        // prefix, so a channel a newcomer could name freely would let
1179        // them place themselves outside their own operator and be drawn
1180        // onto a colleague's review -- or name a prefix belonging to
1181        // somebody else entirely.
1182        let channel = match actor_key.as_ref() {
1183            None => None,
1184            Some(_) => match body.get("channel") {
1185                None | Some(serde_json::Value::Null) => Some(format!("{user}/agent")),
1186                Some(serde_json::Value::String(channel)) => {
1187                    match channel.split_once('/') {
1188                        Some((prefix, suffix))
1189                            if prefix == user
1190                                && !suffix.is_empty()
1191                                && suffix.len() <= 32
1192                                && suffix
1193                                    .bytes()
1194                                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') =>
1195                        {
1196                            Some(channel.clone())
1197                        }
1198                        _ => {
1199                            return bad_request(&format!(
1200                                "`channel` must be `{user}/<name>`, where <name> is letters, digits, `-` or `_`"
1201                            ))
1202                        }
1203                    }
1204                }
1205                Some(_) => return bad_request("`channel` must be a string"),
1206            },
1207        };
1208        // Before the account is inserted and the invite consumed: an
1209        // append that fails must leave the invite redeemable, or a
1210        // newcomer whose key collided with a name is left holding a
1211        // spent invite and no account.
1212        if let (Some(channel), Some(key_hex)) = (channel.as_ref(), actor_key.as_ref()) {
1213            if let Err(e) = self.append_actor_key(channel, key_hex) {
1214                return conflict(&e);
1215            }
1216        }
1217        // A passkey and a token are alternatives, not a pair (D75).
1218        // Minting one anyway "just in case" would leave a live password
1219        // in the store that its holder has never seen and cannot rotate
1220        // knowingly, which is the opposite of what the passwordless
1221        // route is for. The account page mints one on request, when
1222        // there is something -- git, the CLI -- that actually needs it.
1223        let token = passkey.is_none().then(mint_secret);
1224        state.accounts.insert(
1225            user.clone(),
1226            Account {
1227                token_hash: token.as_deref().map(hash),
1228                display_name: invite.display_name,
1229                grants: grants.clone(),
1230                ssh_keys: ssh_key.into_iter().collect(),
1231                actor_key: channel.clone().zip(actor_key.clone()),
1232                passkeys: passkey.into_iter().collect(),
1233                created_at: now_secs(),
1234            },
1235        );
1236        // Single use: consumed whether or not anything below fails, so a
1237        // replayed redemption cannot mint a second token.
1238        state.invites.remove(invite_id);
1239        if let Err(e) = self.commit(&state) {
1240            return server_error(&e);
1241        }
1242        drop(state);
1243        (
1244            200,
1245            serde_json::json!({
1246                "format_version": FORMAT_VERSION,
1247                "user": user,
1248                // `null` on the passwordless route, and the caller must
1249                // read it as "there is no password" rather than as a
1250                // field it can go and look for elsewhere.
1251                "token": token,
1252                "grants": grants,
1253                "channel": channel,
1254                "actor_key_bound": actor_key.is_some(),
1255                "note": "a token, when there is one, is shown once; the node stores only its hash.",
1256            })
1257            .to_string(),
1258        )
1259    }
1260
1261    /// Mints a fresh token for `user`, replacing any it had (D75).
1262    ///
1263    /// The credential git and the CLI need, for an account whose sign-in
1264    /// credential is a passkey. Made on request rather than at
1265    /// redemption, because a secret nobody asked for is a secret nobody
1266    /// looks after -- and because the person who wants one is, by then,
1267    /// somebody this node has already authenticated.
1268    ///
1269    /// **It replaces.** One account, one token, so a rotation is the
1270    /// same act as a first mint and there is no set of live credentials
1271    /// to keep track of. The caller is told, because the old one stops
1272    /// working the moment this returns.
1273    #[must_use]
1274    pub fn mint_token(&self, user: &str) -> (u16, String) {
1275        let mut state = self.state.write().expect("accounts write lock");
1276        let Some(account) = state.accounts.get_mut(user) else {
1277            return (
1278                404,
1279                error_json("only an issued account can hold a token; yours is not one"),
1280            );
1281        };
1282        let replaced = account.token_hash.is_some();
1283        let token = mint_secret();
1284        account.token_hash = Some(hash(&token));
1285        if let Err(e) = self.commit(&state) {
1286            return server_error(&e);
1287        }
1288        drop(state);
1289        (
1290            200,
1291            serde_json::json!({
1292                "format_version": FORMAT_VERSION,
1293                "user": user,
1294                "token": token,
1295                "replaced": replaced,
1296                "note": "shown once; the node stores only its hash. Use it as the password in basic auth.",
1297            })
1298            .to_string(),
1299        )
1300    }
1301
1302    /// Removes an account and any invite outstanding for the same name.
1303    ///
1304    /// Deletion rather than a tombstone: the token stops authenticating
1305    /// on the next request, the grants leave the merged table, and the
1306    /// key leaves the generated `authorized_keys`. That is the property
1307    /// an append-only log could not have provided.
1308    #[must_use]
1309    pub fn revoke(&self, body: &serde_json::Value) -> (u16, String) {
1310        let Some(user) = body.get("user").and_then(serde_json::Value::as_str) else {
1311            return bad_request("`user` is required");
1312        };
1313        let mut state = self.state.write().expect("accounts write lock");
1314        let had_account = state.accounts.remove(user).is_some();
1315        let before = state.invites.len();
1316        state
1317            .invites
1318            .retain(|_, invite| invite.user.as_deref() != Some(user));
1319        let invites_dropped = before - state.invites.len();
1320        if !had_account && invites_dropped == 0 {
1321            return (404, error_json("no such account"));
1322        }
1323        // Retired only when an account really existed. A name whose
1324        // invite was cancelled before redemption never wrote anything to
1325        // the log, so there is no history to protect and burning the name
1326        // over a mistyped invite would be a worse answer than reusing it.
1327        if had_account {
1328            state.retired.insert(user.to_string());
1329        }
1330        if let Err(e) = self.commit(&state) {
1331            return server_error(&e);
1332        }
1333        drop(state);
1334        (
1335            200,
1336            serde_json::json!({
1337                "format_version": FORMAT_VERSION,
1338                "user": user,
1339                "account_revoked": had_account,
1340                "invites_revoked": invites_dropped,
1341            })
1342            .to_string(),
1343        )
1344    }
1345
1346    /// Enrols a WebAuthn credential on `user`'s own account (D39).
1347    ///
1348    /// The caller is the account: this never takes a `user` from the
1349    /// body, so holding a credential is the whole authorization story
1350    /// and there is no way to spell "enrol a key on someone else".
1351    ///
1352    /// **What this does not check, said plainly rather than implied by
1353    /// silence: possession.** D39 scoped out a CBOR reader, so nothing
1354    /// here parses or verifies an attestation object; the node takes the
1355    /// public key the authenticated caller sends. Enrolling a key you do
1356    /// not hold gains you nothing — you still cannot sign with it — but
1357    /// a *stolen token* can enrol an attacker's own authenticator and
1358    /// keep it. Two things bound that: the roster lists every enrolled
1359    /// credential, so it is visible rather than silent, and revocation
1360    /// deletes the account and its keys with it.
1361    ///
1362    /// # Errors
1363    ///
1364    /// 400 for a missing or malformed field, a public key that is not a
1365    /// P-256 SubjectPublicKeyInfo, or 409 for a credential id already
1366    /// enrolled anywhere on this node; 404 when the caller has no account
1367    /// record, which is the case for an `--auth-file` operator.
1368    #[must_use]
1369    pub fn enroll_passkey(&self, user: &str, body: &serde_json::Value) -> (u16, String) {
1370        let key = match parse_enrolment(body) {
1371            Ok(key) => key,
1372            Err(e) => return bad_request(&e),
1373        };
1374        let (credential_id, public_key, label) = (
1375            key.credential_id.clone(),
1376            key.public_key.clone(),
1377            key.label.clone(),
1378        );
1379        let _ = &public_key;
1380
1381        let mut state = self.state.write().expect("accounts write lock");
1382        // Store-wide, not just this account's. A credential id names one
1383        // credential on one authenticator, so the same id under two
1384        // accounts is a claim that cannot be true of both. It also has a
1385        // consequence at sign-in, where the credential id is the only name
1386        // in the assertion and the account is looked up from it: with the
1387        // check scoped per account, enrolling somebody else's credential
1388        // id together with their public key -- neither of which is secret,
1389        // and the roster prints both -- would let their next sign-in
1390        // verify correctly and open a session on the wrong account.
1391        if credential_enrolled(&state, &credential_id) {
1392            return conflict("that credential is already enrolled");
1393        }
1394        let Some(account) = state.accounts.get_mut(user) else {
1395            return (
1396                404,
1397                error_json(
1398                    "no account record for this credential; passkeys are enrolled on issued \
1399                     accounts, and an operator credential from the auth file is not one",
1400                ),
1401            );
1402        };
1403        if account.passkeys.len() >= MAX_PASSKEYS {
1404            return conflict("this account already holds the maximum number of passkeys");
1405        }
1406        account.passkeys.push(key);
1407        let enrolled = account.passkeys.len();
1408        if let Err(e) = self.commit(&state) {
1409            return server_error(&e);
1410        }
1411        drop(state);
1412        (
1413            200,
1414            serde_json::json!({
1415                "format_version": FORMAT_VERSION,
1416                "user": user,
1417                "credential_id": credential_id,
1418                "label": label,
1419                "enrolled": enrolled,
1420            })
1421            .to_string(),
1422        )
1423    }
1424
1425    /// Removes one of `user`'s own enrolled credentials (D39).
1426    ///
1427    /// The mirror of enrolment, and it exists for the same reason
1428    /// revocation does: a credential that cannot be withdrawn is not a
1429    /// credential, it is a permanent fact. A lost authenticator has to be
1430    /// removable by the person who still holds the token.
1431    ///
1432    /// # Errors
1433    ///
1434    /// 400 without `credential_id`, and 404 when this account has no such
1435    /// credential enrolled.
1436    #[must_use]
1437    pub fn remove_passkey(&self, user: &str, body: &serde_json::Value) -> (u16, String) {
1438        let Some(credential_id) = body
1439            .get("credential_id")
1440            .and_then(serde_json::Value::as_str)
1441            .map(str::trim)
1442            .filter(|s| !s.is_empty())
1443        else {
1444            return bad_request("`credential_id` is required");
1445        };
1446        let mut state = self.state.write().expect("accounts write lock");
1447        let Some(account) = state.accounts.get_mut(user) else {
1448            return (404, error_json("no account record for this credential"));
1449        };
1450        let before = account.passkeys.len();
1451        account
1452            .passkeys
1453            .retain(|k| k.credential_id != credential_id);
1454        if account.passkeys.len() == before {
1455            return (404, error_json("no such credential on this account"));
1456        }
1457        let remaining = account.passkeys.len();
1458        if let Err(e) = self.commit(&state) {
1459            return server_error(&e);
1460        }
1461        drop(state);
1462        (
1463            200,
1464            serde_json::json!({
1465                "format_version": FORMAT_VERSION,
1466                "user": user,
1467                "credential_id": credential_id,
1468                "remaining": remaining,
1469            })
1470            .to_string(),
1471        )
1472    }
1473
1474    /// The SubjectPublicKeyInfo DER of one enrolled credential, ready for
1475    /// [`choir_identity::verify_webauthn_assertion`] (D39).
1476    ///
1477    /// Keyed by `(user, credential_id)` rather than by credential id
1478    /// alone: an assertion names a credential, but the request already
1479    /// names a principal, and looking the key up under that principal is
1480    /// The account holding `credential_id`, and that credential's public
1481    /// key (D71).
1482    ///
1483    /// Sign-in runs the lookup the other way round from every other
1484    /// passkey path: an assertion arrives before anyone has said who they
1485    /// are, and the credential id is the only name in it. WebAuthn's own
1486    /// answer is a discoverable credential carrying a user handle, which
1487    /// this deliberately does not depend on -- a credential enrolled
1488    /// before that was asked for would then be one its owner could sign
1489    /// with but not sign in with, and the person holding it would have no
1490    /// way to tell those apart.
1491    ///
1492    /// A credential id is enrolled at most once across the store, which
1493    /// [`Accounts::enroll_passkey`] enforces, so the first match is the
1494    /// only match.
1495    #[must_use]
1496    pub fn account_for_credential(&self, credential_id: &str) -> Option<(String, Vec<u8>)> {
1497        let state = self.state.read().expect("accounts read lock");
1498        let found = state.accounts.iter().find_map(|(name, account)| {
1499            let key = account
1500                .passkeys
1501                .iter()
1502                .find(|k| k.credential_id == credential_id)?;
1503            Some((name.clone(), key.public_key.clone()))
1504        });
1505        drop(state);
1506        let (name, encoded) = found?;
1507        Some((name, base64url_decode(&encoded)?))
1508    }
1509
1510    /// what stops one account's assertion from being spent as another's.
1511    #[must_use]
1512    pub fn passkey_spki(&self, user: &str, credential_id: &str) -> Option<Vec<u8>> {
1513        let state = self.state.read().expect("accounts read lock");
1514        let encoded = state
1515            .accounts
1516            .get(user)?
1517            .passkeys
1518            .iter()
1519            .find(|k| k.credential_id == credential_id)?
1520            .public_key
1521            .clone();
1522        drop(state);
1523        base64url_decode(&encoded)
1524    }
1525
1526    /// One account's own enrolled credentials, for the page that account
1527    /// manages them on (D39).
1528    ///
1529    /// Separate from [`Accounts::list_json`] because the questions differ:
1530    /// the roster is the operator's view of everyone and needs a node-wide
1531    /// read, while this is a person looking at their own keys and needs
1532    /// only their own credential. Returning an empty list for an unknown
1533    /// name is deliberate — an `--auth-file` operator has no account
1534    /// record, and that is "nothing enrolled", not an error.
1535    #[must_use]
1536    pub fn passkeys_json(&self, user: &str) -> Vec<serde_json::Value> {
1537        let state = self.state.read().expect("accounts read lock");
1538        state
1539            .accounts
1540            .get(user)
1541            .map(|account| render_passkeys(&account.passkeys))
1542            .unwrap_or_default()
1543    }
1544
1545    /// Whether `user` holds a token at all (D75).
1546    ///
1547    /// False for a passwordless account, and false for a name with no
1548    /// account record. The account page asks so it can say "make one"
1549    /// rather than "replace the one you have".
1550    #[must_use]
1551    pub fn has_token(&self, user: &str) -> bool {
1552        self.state
1553            .read()
1554            .expect("accounts read lock")
1555            .accounts
1556            .get(user)
1557            .is_some_and(|account| account.token_hash.is_some())
1558    }
1559
1560    /// Whether this name has an account record at all, which is what
1561    /// decides between "you have no passkeys yet" and "this credential
1562    /// cannot hold one".
1563    #[must_use]
1564    pub fn has_account(&self, user: &str) -> bool {
1565        self.state
1566            .read()
1567            .expect("accounts read lock")
1568            .accounts
1569            .contains_key(user)
1570    }
1571
1572    /// What an invite promises, for the page that shows a holder what
1573    /// they are about to accept (D57).
1574    ///
1575    /// **Only ever call this for an invite whose secret has already
1576    /// authenticated.** Nothing here checks possession, so a caller that
1577    /// reaches it with an id alone has built an oracle: `Some` versus
1578    /// `None` would tell an anonymous stranger which invite ids exist and
1579    /// which accounts are pending on this node. The one caller is the join
1580    /// page, which gets the id from a successful
1581    /// [`Accounts::authenticate`] and never from the request.
1582    ///
1583    /// Expiry is re-checked rather than assumed, so this cannot be the
1584    /// place a stale invite is presented as a live one.
1585    #[must_use]
1586    pub fn invite_summary(&self, invite_id: &str) -> Option<InviteSummary> {
1587        let state = self.state.read().expect("accounts read lock");
1588        let invite = state.invites.get(invite_id)?;
1589        if invite.expires_at <= now_secs() {
1590            return None;
1591        }
1592        Some(InviteSummary {
1593            user: invite.user.clone(),
1594            display_name: invite.display_name.clone(),
1595            grants: invite.grants.clone(),
1596            expires_at: invite.expires_at,
1597            issued_by: invite.issued_by.clone(),
1598        })
1599    }
1600
1601    /// What to call `user` in something a person reads (D46).
1602    ///
1603    /// `None` means "call it by its own name", which is the answer in
1604    /// three different situations a caller must not try to tell apart:
1605    /// an account issued before D46, one issued with an explicit `user`,
1606    /// and one whose display name has been deleted. **The third is the
1607    /// point of the field**, so a caller that renders "unknown" or
1608    /// "deleted" for it undoes the deletion by announcing it. Render the
1609    /// handle and say nothing.
1610    #[must_use]
1611    pub fn display_name(&self, user: &str) -> Option<String> {
1612        self.state
1613            .read()
1614            .expect("accounts read lock")
1615            .accounts
1616            .get(user)
1617            .and_then(|account| account.display_name.clone())
1618    }
1619
1620    /// Every handle the store can name, as `(handle, display name)`.
1621    ///
1622    /// The rendering half of the D46 ACL decision: grants are written
1623    /// against handles, and `choir acl render` regenerates the trailing
1624    /// comments from this. Accounts with no display name are omitted
1625    /// rather than listed as themselves — a comment repeating the handle
1626    /// is noise, and the file already says it.
1627    #[must_use]
1628    pub fn roster(&self) -> BTreeMap<String, String> {
1629        self.state
1630            .read()
1631            .expect("accounts read lock")
1632            .accounts
1633            .iter()
1634            .filter_map(|(user, account)| {
1635                account
1636                    .display_name
1637                    .clone()
1638                    .map(|name| (user.clone(), name))
1639            })
1640            .collect()
1641    }
1642
1643    /// Everything the store holds except the secrets: who has an account,
1644    /// what they were granted, which public keys are registered, and
1645    /// which invites are outstanding.
1646    #[must_use]
1647    pub fn list_json(&self) -> serde_json::Value {
1648        let state = self.state.read().expect("accounts read lock");
1649        let now = now_secs();
1650        let accounts: Vec<serde_json::Value> = state
1651            .accounts
1652            .iter()
1653            .map(|(user, account)| {
1654                serde_json::json!({
1655                    "user": user,
1656                    // Always present, `null` when the account has none
1657                    // (D46). The store file omits the key to stay
1658                    // readable; this is read by programs, and a key that
1659                    // appears and disappears is one every client has to
1660                    // handle twice.
1661                    "display_name": account.display_name,
1662                    "grants": account.grants,
1663                    "ssh_keys": account.ssh_keys,
1664                    "passkeys": render_passkeys(&account.passkeys),
1665                    "created_at": account.created_at,
1666                })
1667            })
1668            .collect();
1669        let invites: Vec<serde_json::Value> = state
1670            .invites
1671            .iter()
1672            .filter(|(_, invite)| invite.expires_at > now)
1673            .map(|(id, invite)| {
1674                serde_json::json!({
1675                    "invite_id": id,
1676                    "user": invite.user,
1677                    "display_name": invite.display_name,
1678                    "grants": invite.grants,
1679                    "issued_by": invite.issued_by,
1680                    "issued_at": invite.issued_at,
1681                    "expires_at": invite.expires_at,
1682                })
1683            })
1684            .collect();
1685        let requests: Vec<serde_json::Value> = state
1686            .requests
1687            .iter()
1688            .filter(|(_, request)| request.expires_at > now)
1689            .map(|(id, request)| {
1690                serde_json::json!({
1691                    "request_id": id,
1692                    "display_name": request.display_name,
1693                    "about": request.about,
1694                    "asked_at": request.asked_at,
1695                    "expires_at": request.expires_at,
1696                })
1697            })
1698            .collect();
1699        serde_json::json!({
1700            "format_version": FORMAT_VERSION,
1701            "accounts": accounts,
1702            "invites": invites,
1703            // D72's queue, so a client that reads this endpoint sees the
1704            // whole of who is here and who is asking, rather than having
1705            // to know about a second route.
1706            "requests": requests,
1707            // Served so an operator can see why a name is refused before
1708            // they go looking for an override.
1709            "retired": state.retired,
1710        })
1711    }
1712
1713    /// Persists the state and regenerates the derived key file, then
1714    /// bumps the generation counter.
1715    fn commit(&self, state: &State) -> Result<(), String> {
1716        let text = render_state(state);
1717        write_private(&self.path, text.as_bytes())?;
1718        self.write_authorized_keys(state)?;
1719        self.generation.fetch_add(1, Ordering::Release);
1720        Ok(())
1721    }
1722
1723    /// Rewrites the generated `authorized_keys`, when one is configured.
1724    ///
1725    /// Every field interpolated into a line is validated before it is
1726    /// stored — the username by [`validate_username`], the key by
1727    /// [`validate_ssh_key`], which also drops the client's comment — so
1728    /// no value here can end a line early and start another with a
1729    /// forced command of its own.
1730    fn write_authorized_keys(&self, state: &State) -> Result<(), String> {
1731        let Some(out) = &self.keys_out else {
1732            return Ok(());
1733        };
1734        let mut text = String::from(
1735            "# Generated by choir-node (D36). Edits are lost at the next account change;\n\
1736             # point sshd at a second file rather than editing this one.\n",
1737        );
1738        for (user, account) in &state.accounts {
1739            for key in &account.ssh_keys {
1740                text.push_str(&format!(
1741                    "command=\"{} --root {} --user {user} --handoff {}\",restrict {key} {user}\n",
1742                    out.shim.display(),
1743                    out.root.display(),
1744                    out.handoff.display(),
1745                ));
1746            }
1747        }
1748        write_private(&out.path, text.as_bytes())
1749    }
1750}
1751
1752/// The grants in a store, as an [`Acl`] file body.
1753///
1754/// Exposed for the `choir-ssh` shim, which enforces the same grants from
1755/// a separate process and must not be able to disagree with the daemon
1756/// about what they mean.
1757///
1758/// # Errors
1759///
1760/// Returns a message when the store cannot be read or does not parse. A
1761/// missing file yields an empty table rather than an error: an ACL that
1762/// grants nothing is the fail-closed answer, and refusing to start would
1763/// take the SSH transport down with a file the daemon may simply not
1764/// have written yet.
1765pub fn grants_acl(path: &Path) -> Result<Acl, String> {
1766    let state = match std::fs::read_to_string(path) {
1767        Ok(text) => parse_state(&text).map_err(|e| format!("{}: {e}", path.display()))?,
1768        Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::default(),
1769        Err(e) => return Err(format!("{}: {e}", path.display())),
1770    };
1771    Acl::parse(&acl_text(&state))
1772}
1773
1774/// The ACL file body a store's grants amount to.
1775fn acl_text(state: &State) -> String {
1776    let mut text = String::new();
1777    for (user, account) in &state.accounts {
1778        for grant in &account.grants {
1779            text.push_str(&format!("{user} {grant}\n"));
1780        }
1781    }
1782    text
1783}
1784
1785/// Checks one grant column pair against the real ACL parser and returns
1786/// it in canonical spelling.
1787///
1788/// # Errors
1789///
1790/// Returns a message when the pair does not parse, or when it names
1791/// [`crate::acl::Scope::Node`]: node-wide authority is what D33's rate-limit
1792/// exemption and D29's whole-node reads key on, so it stays operator-
1793/// authored in the ACL file and is never self-service.
1794pub fn validate_grant(user: &str, grant: &str) -> Result<String, String> {
1795    let mut columns = grant.split_whitespace();
1796    let (Some(target), Some(level), deadline, None) = (
1797        columns.next(),
1798        columns.next(),
1799        columns.next(),
1800        columns.next(),
1801    ) else {
1802        return Err(format!(
1803            "`{grant}` is not a grant; write `<repo|*> <read|write>`, optionally followed by \
1804             `until=<unix seconds>`"
1805        ));
1806    };
1807    if target == "@node" || target.starts_with('@') {
1808        return Err(
1809            "`@node` cannot be issued: node-wide authority stays in the ACL file (D36)".to_string(),
1810        );
1811    }
1812    let tail = deadline.map(|d| format!(" {d}")).unwrap_or_default();
1813    let table = Acl::parse(&format!("{user} {target} {level}{tail}\n"))?;
1814    // Belt and braces against a future spelling of the node scope that
1815    // the check above does not recognize: ask the parsed table rather
1816    // than the text. Asked of the file's own words rather than of a
1817    // dated table, so a grant issued with a deadline in the past cannot
1818    // pass this check by being expired rather than by being allowed.
1819    if table.grants_node(user) {
1820        return Err(
1821            "`@node` cannot be issued: node-wide authority stays in the ACL file (D36)".to_string(),
1822        );
1823    }
1824    Ok(format!("{target} {level}{tail}"))
1825}
1826
1827/// Checks a readable display name (D46).
1828///
1829/// Looser than [`validate_username`] on purpose: this string is never a
1830/// principal, never a path segment, never an ACL subject and never a
1831/// channel, so the reasons a username is restricted do not apply to it.
1832/// What it must not do is break the files and pages that render it, so
1833/// control characters and newlines are refused and the length is capped.
1834///
1835/// The one restriction that is not about rendering: a name may not be
1836/// spelled like a handle. A page shows an unresolvable handle as itself,
1837/// so a display name of `HANDLE_CHARS` hex characters renders exactly
1838/// as somebody else's deleted account does, and the person it points at
1839/// cannot correct the record because their name is the thing that was
1840/// deleted. Refused at the door because the alternative is a rendering
1841/// rule that has to know which of two identical strings it is holding.
1842///
1843/// # Errors
1844///
1845/// Returns a message when it is empty, too long, carries a control
1846/// character, or is spelled like an account handle.
1847pub fn validate_display_name(name: &str) -> Result<(), String> {
1848    let name = name.trim();
1849    if name.is_empty() || name.chars().count() > MAX_LABEL_CHARS {
1850        return Err(format!(
1851            "a display name must be 1 to {MAX_LABEL_CHARS} characters"
1852        ));
1853    }
1854    if name.chars().any(char::is_control) {
1855        return Err("a display name must not contain control characters".to_string());
1856    }
1857    if looks_like_a_handle(name) {
1858        return Err(format!(
1859            "a display name must not be spelled like an account handle \
1860             ({HANDLE_CHARS} hexadecimal characters)"
1861        ));
1862    }
1863    Ok(())
1864}
1865
1866/// Whether a string is spelled the way [`mint_handle`] spells one.
1867///
1868/// Case-insensitive, because a reader comparing a name against a handle
1869/// is not comparing bytes, and `7F3AC2AB19CD` impersonates
1870/// `7f3ac2ab19cd` on every surface a person actually reads.
1871fn looks_like_a_handle(name: &str) -> bool {
1872    name.len() == HANDLE_CHARS && name.chars().all(|c| c.is_ascii_hexdigit())
1873}
1874
1875/// Checks a name the node will interpolate into an `authorized_keys`
1876/// forced command and key on for every authorization decision.
1877///
1878/// # Errors
1879///
1880/// Returns a message when the name is empty, too long, spelled with
1881/// anything but ASCII letters, digits, `-`, `_` and `.`, reserved for the
1882/// unauthenticated placeholder, or spelled like an invite id.
1883pub fn validate_username(user: &str) -> Result<(), String> {
1884    if user.is_empty() || user.len() > 32 {
1885        return Err("a username must be 1 to 32 characters".to_string());
1886    }
1887    if !user
1888        .bytes()
1889        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
1890    {
1891        return Err("a username may hold only ASCII letters, digits, `-`, `_` and `.`".to_string());
1892    }
1893    if user == "anon" {
1894        return Err("`anon` is the name an unauthenticated request already has".to_string());
1895    }
1896    if user.starts_with(INVITE_PREFIX) {
1897        return Err(format!("a username may not start with `{INVITE_PREFIX}`"));
1898    }
1899    // Same reasoning one line up, for D72's table: an account whose name
1900    // reads like a request id makes a claim link and a credential
1901    // indistinguishable by eye, which is the only way anybody ever tells
1902    // them apart in a chat window.
1903    if user.starts_with(REQUEST_PREFIX) {
1904        return Err(format!("a username may not start with `{REQUEST_PREFIX}`"));
1905    }
1906    // The placeholder an open seat's grants are graded against (D75).
1907    // Refusing it here is what makes it safe to use as one: no
1908    // redemption can land on the name a validity check borrowed.
1909    if user == OPEN_SEAT {
1910        return Err(format!("`{OPEN_SEAT}` is reserved"));
1911    }
1912    Ok(())
1913}
1914
1915/// Checks an ed25519 actor public key and returns it lowercased.
1916///
1917/// Normalised rather than accepted as sent, because this string becomes
1918/// a line in the trusted-keys file and the duplicate check that keeps a
1919/// replayed redemption idempotent is a string comparison. Two spellings
1920/// of one key would append it twice, and a file binding one name to two
1921/// keys is refused wholesale at the next reload -- taking every other
1922/// key in it down with it.
1923///
1924/// # Errors
1925///
1926/// Returns a message when the value is not 64 hex characters.
1927pub fn validate_actor_key(hex: &str) -> Result<String, String> {
1928    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1929        return Err("an actor key is 64 hex characters (an ed25519 public key)".to_string());
1930    }
1931    Ok(hex.to_ascii_lowercase())
1932}
1933
1934/// Checks an OpenSSH public key line and returns it without its comment.
1935///
1936/// The comment is dropped rather than validated: it is the one field a
1937/// client controls freely, the generated `authorized_keys` is a
1938/// line-oriented file where a forced command precedes the key, and a
1939/// value that cannot appear cannot be escaped wrongly. The node writes
1940/// the account name there instead.
1941///
1942/// # Errors
1943///
1944/// Returns a message when the line is not a single `ssh-ed25519 <base64>`
1945/// pair whose blob really is a 32-byte ed25519 key.
1946pub fn validate_ssh_key(line: &str) -> Result<String, String> {
1947    if line.contains('\n') || line.contains('\r') {
1948        return Err("an ssh key must be one line".to_string());
1949    }
1950    let mut columns = line.split_whitespace();
1951    let (Some(algorithm), Some(blob)) = (columns.next(), columns.next()) else {
1952        return Err("an ssh key is `ssh-ed25519 <base64>`".to_string());
1953    };
1954    if algorithm != "ssh-ed25519" {
1955        return Err(format!(
1956            "`{algorithm}` keys are not registered here; this node uses ssh-ed25519"
1957        ));
1958    }
1959    let Some(bytes) = crate::base64_decode(blob) else {
1960        return Err("the key blob is not base64".to_string());
1961    };
1962    // OpenSSH wire form: length-prefixed algorithm name, then the
1963    // length-prefixed 32-byte key. Anything else is not the key it says
1964    // it is, whatever it decodes to.
1965    let expected: Vec<u8> = [0, 0, 0, 11]
1966        .iter()
1967        .copied()
1968        .chain(b"ssh-ed25519".iter().copied())
1969        .chain([0, 0, 0, 32])
1970        .collect();
1971    if bytes.len() != 51 || !bytes.starts_with(&expected) {
1972        return Err("that is not an ssh-ed25519 public key".to_string());
1973    }
1974    Ok(format!("{algorithm} {blob}"))
1975}
1976
1977/// A fresh 256-bit secret in hex, from the same generator every other key
1978/// on this node comes from. No `rand` dependency is added for it: an
1979/// actor key is already an OS-random keypair, and its actor id is the
1980/// BLAKE3 of the public half.
1981fn mint_secret() -> String {
1982    ActorKey::generate().actor_id().to_hex()
1983}
1984
1985/// How many hex characters an account handle carries (D46).
1986///
1987/// Twelve is 48 bits: long enough that [`mint_handle`]'s retry loop is
1988/// theatre rather than a real code path, short enough to read in an ACL
1989/// line and a review page. The value is not load-bearing — the
1990/// collision check is — so it can be raised later without a migration.
1991const HANDLE_CHARS: usize = 12;
1992
1993// Handles used to be minted here, for an issuer who sent a
1994// `display_name` (D46). Nothing mints one any more: the person redeeming
1995// picks their own username (D75), which satisfies D46's rule more
1996// directly than choosing for them ever did. `HANDLE_CHARS` and
1997// `looks_like_a_handle` stay, because accounts issued before D75 carry
1998// handles and a display name must still not be spelled like one.
1999
2000/// One enrolment request, validated into the record it becomes.
2001///
2002/// Shared by `POST /api/accounts/passkey` and by redemption (D75), which
2003/// enrols the first passkey as part of creating the account. Two copies
2004/// of these four checks would be two places for a credential id's length
2005/// bound to be right.
2006fn parse_enrolment(body: &serde_json::Value) -> Result<Passkey, String> {
2007    let field = |name: &str| {
2008        body.get(name)
2009            .and_then(serde_json::Value::as_str)
2010            .map(str::trim)
2011            .filter(|s| !s.is_empty())
2012    };
2013    let (Some(credential_id), Some(public_key)) = (field("credential_id"), field("public_key"))
2014    else {
2015        return Err("`credential_id` and `public_key` are required".to_string());
2016    };
2017    let label = field("label").unwrap_or("passkey");
2018    validate_passkey_id(credential_id)?;
2019    validate_label(label)?;
2020    validate_p256_spki(public_key)?;
2021    Ok(Passkey {
2022        credential_id: credential_id.to_string(),
2023        public_key: public_key.to_string(),
2024        label: label.to_string(),
2025        created_at: now_secs(),
2026    })
2027}
2028
2029/// Whether any account on this node already holds `credential_id`.
2030///
2031/// Store-wide, not per account. A credential id names one credential on
2032/// one authenticator, so the same id under two accounts is a claim that
2033/// cannot be true of both. It also has a consequence at sign-in, where
2034/// the credential id is the only name in the assertion and the account
2035/// is looked up from it: scoped per account, enrolling somebody else's
2036/// credential id together with their public key -- neither of which is
2037/// secret, and the roster prints both -- would let their next sign-in
2038/// verify correctly and open a session on the wrong account.
2039fn credential_enrolled(state: &State, credential_id: &str) -> bool {
2040    state
2041        .accounts
2042        .values()
2043        .any(|a| a.passkeys.iter().any(|k| k.credential_id == credential_id))
2044}
2045
2046/// BLAKE3 of a secret, hex, with its codec byte — the same envelope
2047/// every other hash on this node carries (invariant 2).
2048fn hash(secret: &str) -> String {
2049    ContentHash::blake3(secret.as_bytes()).to_hex()
2050}
2051
2052/// Compares without an early exit, so timing does not leak how much of a
2053/// presented secret matched.
2054fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2055    let mut diff = a.len() ^ b.len();
2056    for i in 0..a.len().min(b.len()) {
2057        diff |= (a[i] ^ b[i]) as usize;
2058    }
2059    diff == 0
2060}
2061
2062/// Seconds since the Unix epoch, or 0 if the clock is before it.
2063///
2064/// The one clock authorization reads. An invite's expiry (D36) and a
2065/// grant's deadline (D66) are the same kind of statement about the same
2066/// timeline, so they are answered by the same function rather than by
2067/// two that could drift.
2068pub(crate) fn now_secs() -> u64 {
2069    std::time::SystemTime::now()
2070        .duration_since(std::time::UNIX_EPOCH)
2071        .map_or(0, |d| d.as_secs())
2072}
2073
2074/// Writes `bytes` to `path` at 0600 through a temporary file and a
2075/// rename, so a reader never sees a half-written store and a crash
2076/// leaves the previous one intact.
2077fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
2078    if let Some(parent) = path.parent() {
2079        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
2080    }
2081    let name = path
2082        .file_name()
2083        .ok_or_else(|| format!("{}: not a file path", path.display()))?;
2084    let temp = path.with_file_name(format!("{}.tmp", name.to_string_lossy()));
2085    std::fs::write(&temp, bytes).map_err(|e| format!("{}: {e}", temp.display()))?;
2086    #[cfg(unix)]
2087    {
2088        use std::os::unix::fs::PermissionsExt;
2089        std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))
2090            .map_err(|e| format!("{}: {e}", temp.display()))?;
2091    }
2092    std::fs::rename(&temp, path).map_err(|e| format!("{}: {e}", path.display()))
2093}
2094
2095/// The store as JSON.
2096fn render_state(state: &State) -> String {
2097    let accounts: Vec<serde_json::Value> = state
2098        .accounts
2099        .iter()
2100        .map(|(user, account)| {
2101            let mut record = serde_json::json!({
2102                "user": user,
2103                // `null` on a passwordless account (D75). Written
2104                // rather than omitted, unlike the fields above: this one
2105                // is the difference between an account with a password
2106                // and one without, and an operator reading the store to
2107                // answer that question should find it stated.
2108                "token_hash": account.token_hash,
2109                "grants": account.grants,
2110                "ssh_keys": account.ssh_keys,
2111                "passkeys": account.passkeys.iter().map(|k| serde_json::json!({
2112                    "credential_id": k.credential_id,
2113                    "public_key": k.public_key,
2114                    "label": k.label,
2115                    "created_at": k.created_at,
2116                })).collect::<Vec<_>>(),
2117                "created_at": account.created_at,
2118            });
2119            // Emitted only when set, so an account issued before D46
2120            // renders exactly the record it always did. The store is a
2121            // node-owned file rather than hashed bytes, so this is
2122            // tidiness rather than an invariant -- but a `null` on every
2123            // legacy row is noise an operator has to learn to ignore,
2124            // and things operators learn to ignore stop being read.
2125            if let Some((channel, hex)) = account.actor_key.as_ref() {
2126                record["actor_key_channel"] = serde_json::json!(channel);
2127                record["actor_key"] = serde_json::json!(hex);
2128            }
2129            if let Some(name) = account.display_name.as_deref() {
2130                record
2131                    .as_object_mut()
2132                    .expect("account record is an object")
2133                    .insert("display_name".into(), serde_json::json!(name));
2134            }
2135            record
2136        })
2137        .collect();
2138    let invites: Vec<serde_json::Value> = state
2139        .invites
2140        .iter()
2141        .map(|(id, invite)| {
2142            let mut record = serde_json::json!({
2143                "invite_id": id,
2144                "secret_hash": invite.secret_hash,
2145                "grants": invite.grants,
2146                "expires_at": invite.expires_at,
2147                "issued_by": invite.issued_by,
2148                "issued_at": invite.issued_at,
2149            });
2150            // Emitted only when the issuer fixed a seat (D75), so an
2151            // open one renders the record it always did minus a key
2152            // rather than plus a `null`.
2153            if let Some(user) = invite.user.as_deref() {
2154                record
2155                    .as_object_mut()
2156                    .expect("invite record is an object")
2157                    .insert("user".into(), serde_json::json!(user));
2158            }
2159            if let Some(name) = invite.display_name.as_deref() {
2160                record
2161                    .as_object_mut()
2162                    .expect("invite record is an object")
2163                    .insert("display_name".into(), serde_json::json!(name));
2164            }
2165            record
2166        })
2167        .collect();
2168    let requests: Vec<serde_json::Value> = state
2169        .requests
2170        .iter()
2171        .map(|(id, request)| {
2172            serde_json::json!({
2173                "request_id": id,
2174                "secret_hash": request.secret_hash,
2175                "display_name": request.display_name,
2176                "about": request.about,
2177                "asked_at": request.asked_at,
2178                "expires_at": request.expires_at,
2179            })
2180        })
2181        .collect();
2182    format!(
2183        "{}\n",
2184        serde_json::json!({
2185            "format_version": FORMAT_VERSION,
2186            "accounts": accounts,
2187            "invites": invites,
2188            // D72, and additive: a store written before it has no such
2189            // key and parses as an empty queue. The version is not bumped
2190            // because nothing here changes how an older field decodes,
2191            // which is the test invariant 1 actually sets.
2192            "requests": requests,
2193            "retired": state.retired,
2194        })
2195    )
2196}
2197
2198/// Parses the store, refusing anything it does not fully understand.
2199fn parse_state(text: &str) -> Result<State, String> {
2200    if text.trim().is_empty() {
2201        return Ok(State::default());
2202    }
2203    let value: serde_json::Value =
2204        serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
2205    match value
2206        .get("format_version")
2207        .and_then(serde_json::Value::as_u64)
2208    {
2209        Some(FORMAT_VERSION) => {}
2210        Some(other) => {
2211            return Err(format!(
2212                "format_version {other} is newer than this binary understands ({FORMAT_VERSION}); \
2213                 refusing to load rather than drop the fields it does not know"
2214            ))
2215        }
2216        None => return Err("missing format_version".to_string()),
2217    }
2218    let strings = |value: Option<&serde_json::Value>| -> Vec<String> {
2219        value
2220            .and_then(serde_json::Value::as_array)
2221            .map(|items| {
2222                items
2223                    .iter()
2224                    .filter_map(serde_json::Value::as_str)
2225                    .map(str::to_string)
2226                    .collect()
2227            })
2228            .unwrap_or_default()
2229    };
2230    let mut state = State {
2231        retired: strings(value.get("retired")).into_iter().collect(),
2232        ..State::default()
2233    };
2234    for entry in value
2235        .get("accounts")
2236        .and_then(serde_json::Value::as_array)
2237        .map(Vec::as_slice)
2238        .unwrap_or_default()
2239    {
2240        let Some(user) = entry.get("user").and_then(serde_json::Value::as_str) else {
2241            return Err("an account record is missing `user`".to_string());
2242        };
2243        // Absent on a passwordless account (D75), and that decodes as
2244        // "there is no password" rather than as a broken record. Every
2245        // account written before it has one, so this stays additive.
2246        let token_hash = entry
2247            .get("token_hash")
2248            .and_then(serde_json::Value::as_str)
2249            .map(ToString::to_string);
2250        state.accounts.insert(
2251            user.to_string(),
2252            Account {
2253                token_hash,
2254                // Absent on every account issued before D46, and that
2255                // decodes as `None` rather than as the username: the
2256                // point of the field is that it can be deleted, and a
2257                // value invented at load could not be.
2258                display_name: entry
2259                    .get("display_name")
2260                    .and_then(serde_json::Value::as_str)
2261                    .map(ToString::to_string),
2262                grants: strings(entry.get("grants")),
2263                ssh_keys: strings(entry.get("ssh_keys")),
2264                passkeys: parse_passkeys(entry.get("passkeys")),
2265                // Both halves or neither: a channel with no key names
2266                // nothing, and a key with no channel says the account
2267                // arrived with a binding while refusing to say to what.
2268                actor_key: entry
2269                    .get("actor_key_channel")
2270                    .and_then(serde_json::Value::as_str)
2271                    .zip(entry.get("actor_key").and_then(serde_json::Value::as_str))
2272                    .map(|(channel, hex)| (channel.to_string(), hex.to_string())),
2273                created_at: entry
2274                    .get("created_at")
2275                    .and_then(serde_json::Value::as_u64)
2276                    .unwrap_or_default(),
2277            },
2278        );
2279    }
2280    for entry in value
2281        .get("invites")
2282        .and_then(serde_json::Value::as_array)
2283        .map(Vec::as_slice)
2284        .unwrap_or_default()
2285    {
2286        let (Some(id), Some(secret_hash)) = (
2287            entry.get("invite_id").and_then(serde_json::Value::as_str),
2288            entry.get("secret_hash").and_then(serde_json::Value::as_str),
2289        ) else {
2290            return Err("an invite record is missing `invite_id` or `secret_hash`".to_string());
2291        };
2292        // Absent is an open seat (D75). Every invite written before it
2293        // names one, so an older store decodes exactly as it always did.
2294        let user = entry
2295            .get("user")
2296            .and_then(serde_json::Value::as_str)
2297            .map(ToString::to_string);
2298        state.invites.insert(
2299            id.to_string(),
2300            Invite {
2301                secret_hash: secret_hash.to_string(),
2302                user,
2303                display_name: entry
2304                    .get("display_name")
2305                    .and_then(serde_json::Value::as_str)
2306                    .map(ToString::to_string),
2307                grants: strings(entry.get("grants")),
2308                expires_at: entry
2309                    .get("expires_at")
2310                    .and_then(serde_json::Value::as_u64)
2311                    .unwrap_or_default(),
2312                issued_by: entry
2313                    .get("issued_by")
2314                    .and_then(serde_json::Value::as_str)
2315                    .unwrap_or_default()
2316                    .to_string(),
2317                issued_at: entry
2318                    .get("issued_at")
2319                    .and_then(serde_json::Value::as_u64)
2320                    .unwrap_or_default(),
2321            },
2322        );
2323    }
2324    for entry in value
2325        .get("requests")
2326        .and_then(serde_json::Value::as_array)
2327        .map(Vec::as_slice)
2328        .unwrap_or_default()
2329    {
2330        let (Some(id), Some(secret_hash), Some(display_name)) = (
2331            entry.get("request_id").and_then(serde_json::Value::as_str),
2332            entry.get("secret_hash").and_then(serde_json::Value::as_str),
2333            entry
2334                .get("display_name")
2335                .and_then(serde_json::Value::as_str),
2336        ) else {
2337            return Err(
2338                "a request record is missing `request_id`, `secret_hash` or `display_name`"
2339                    .to_string(),
2340            );
2341        };
2342        state.requests.insert(
2343            id.to_string(),
2344            Request {
2345                secret_hash: secret_hash.to_string(),
2346                display_name: display_name.to_string(),
2347                about: entry
2348                    .get("about")
2349                    .and_then(serde_json::Value::as_str)
2350                    .unwrap_or_default()
2351                    .to_string(),
2352                asked_at: entry
2353                    .get("asked_at")
2354                    .and_then(serde_json::Value::as_u64)
2355                    .unwrap_or_default(),
2356                expires_at: entry
2357                    .get("expires_at")
2358                    .and_then(serde_json::Value::as_u64)
2359                    .unwrap_or_default(),
2360            },
2361        );
2362    }
2363    Ok(state)
2364}
2365
2366/// The enrolled credentials of one account, as the roster shows them.
2367///
2368/// The public key is included: it is a public key, and an operator
2369/// auditing what can sign for an account needs to see the thing that
2370/// signs, not a count of them.
2371fn render_passkeys(passkeys: &[Passkey]) -> Vec<serde_json::Value> {
2372    passkeys
2373        .iter()
2374        .map(|k| {
2375            serde_json::json!({
2376                "credential_id": k.credential_id,
2377                "public_key": k.public_key,
2378                "label": k.label,
2379                "created_at": k.created_at,
2380            })
2381        })
2382        .collect()
2383}
2384
2385/// Reads the `passkeys` array of one stored account record.
2386///
2387/// A record written before D39 has no such member, which parses to an
2388/// empty list rather than an error: the field is additive, so an older
2389/// store still loads (invariant 1). A malformed entry is dropped rather
2390/// than failing the whole load, because the alternative — refusing to
2391/// start — would turn one bad credential into a node-wide outage, and a
2392/// dropped passkey is visible in the roster.
2393fn parse_passkeys(value: Option<&serde_json::Value>) -> Vec<Passkey> {
2394    value
2395        .and_then(serde_json::Value::as_array)
2396        .map(Vec::as_slice)
2397        .unwrap_or_default()
2398        .iter()
2399        .filter_map(|entry| {
2400            let text = |name: &str| entry.get(name).and_then(serde_json::Value::as_str);
2401            Some(Passkey {
2402                credential_id: text("credential_id")?.to_string(),
2403                public_key: text("public_key")?.to_string(),
2404                label: text("label").unwrap_or("passkey").to_string(),
2405                created_at: entry
2406                    .get("created_at")
2407                    .and_then(serde_json::Value::as_u64)
2408                    .unwrap_or_default(),
2409            })
2410        })
2411        .collect()
2412}
2413
2414/// Base64url, the encoding WebAuthn uses everywhere, decoded by
2415/// translating into the standard alphabet and reusing the node's one
2416/// decoder rather than writing a second.
2417fn base64url_decode(input: &str) -> Option<Vec<u8>> {
2418    if input.contains(['+', '/']) {
2419        // Standard-alphabet input is refused rather than accepted as a
2420        // courtesy: two spellings of one credential id would let the
2421        // same key enrol twice and be removed once.
2422        return None;
2423    }
2424    crate::base64_decode(&input.replace('-', "+").replace('_', "/"))
2425}
2426
2427/// Refuses a credential id that is not a plausible base64url handle.
2428fn validate_passkey_id(id: &str) -> Result<(), String> {
2429    if id.len() > MAX_CREDENTIAL_ID_CHARS {
2430        return Err(format!(
2431            "credential id is longer than {MAX_CREDENTIAL_ID_CHARS} characters"
2432        ));
2433    }
2434    if base64url_decode(id).is_none() {
2435        return Err("credential id is not base64url".to_string());
2436    }
2437    Ok(())
2438}
2439
2440/// Refuses a label that would make a roster unreadable or smuggle a
2441/// control character into one.
2442fn validate_label(label: &str) -> Result<(), String> {
2443    if label.chars().count() > MAX_LABEL_CHARS {
2444        return Err(format!("label is longer than {MAX_LABEL_CHARS} characters"));
2445    }
2446    if label.chars().any(char::is_control) {
2447        return Err("label contains a control character".to_string());
2448    }
2449    Ok(())
2450}
2451
2452/// Refuses anything that is not exactly a P-256 SubjectPublicKeyInfo.
2453///
2454/// Checked at enrolment rather than at first use, because the two fail in
2455/// different places: a bad key caught here is a 400 on the request that
2456/// sent it, while the same key caught later is a signature that will not
2457/// verify for a reason the holder cannot see. The shape is the fixed
2458/// 26-byte SPKI prefix followed by a 65-byte uncompressed point, which is
2459/// what [`choir_identity::p256_point_to_spki`] builds and what `openssl`
2460/// emits.
2461fn validate_p256_spki(encoded: &str) -> Result<(), String> {
2462    let Some(der) = base64url_decode(encoded) else {
2463        return Err("public key is not base64url".to_string());
2464    };
2465    let prefix = choir_identity::P256_SPKI_PREFIX;
2466    if der.len() != prefix.len() + 65 {
2467        return Err(format!(
2468            "public key is {} bytes; a P-256 SubjectPublicKeyInfo is {}",
2469            der.len(),
2470            prefix.len() + 65
2471        ));
2472    }
2473    if der[..prefix.len()] != prefix[..] {
2474        return Err("public key is not a P-256 SubjectPublicKeyInfo".to_string());
2475    }
2476    if der[prefix.len()] != 0x04 {
2477        return Err("public key is not an uncompressed point".to_string());
2478    }
2479    Ok(())
2480}
2481
2482/// One error body, in the shape every other API error uses.
2483fn error_json(reason: &str) -> String {
2484    serde_json::json!({ "error": reason }).to_string()
2485}
2486
2487fn bad_request(reason: &str) -> (u16, String) {
2488    (400, error_json(reason))
2489}
2490
2491fn conflict(reason: &str) -> (u16, String) {
2492    (409, error_json(reason))
2493}
2494
2495fn server_error(reason: &str) -> (u16, String) {
2496    (500, error_json(reason))
2497}