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}