pub struct Accounts { /* private fields */ }Expand description
The credential store: accounts, invites, and the two files it owns.
Implementations§
Source§impl Accounts
impl Accounts
Sourcepub fn open(
path: PathBuf,
keys_out: Option<SshKeysOut>,
reserved: BTreeSet<String>,
) -> Result<Self, String>
pub fn open( path: PathBuf, keys_out: Option<SshKeysOut>, reserved: BTreeSet<String>, ) -> Result<Self, String>
Opens the store at path, creating an empty one if absent, and
writes the generated authorized_keys if one is configured.
reserved is the set of names that already exist elsewhere —
the --auth-file users — and can therefore never be issued.
§Errors
Returns a message when the file cannot be read, does not parse, or cannot be written back. Fatal by design: a store that half-loaded would silently drop somebody’s credential, and the failure would look like a revocation nobody performed.
Sourcepub fn binding_actor_keys_into(self, path: PathBuf) -> Self
pub fn binding_actor_keys_into(self, path: PathBuf) -> Self
Lets a redemption bind one actor key by appending it to the
operator’s trusted-keys file at path (D51).
Opt-in, and off unless the operator passed --invite-binds-keys.
What it removes is the clerical half of admission: the operator
still decides who is admitted, by issuing the invite, and the
invite still carries the grants. What it stops requiring is a
second out-of-band round trip in which a newcomer pastes a hex
string to a human who pastes it into a file.
The key lands in the same file an operator would have edited, so
nothing downstream learns a new source of trust: the existing
mtime reload picks it up, allowed_signers is regenerated from
it, and revoking is still deleting one line.
Sourcepub fn path(&self) -> &Path
pub fn path(&self) -> &Path
Where the store is persisted. The choir-ssh shim is pointed at
it through the D31 handoff file.
Sourcepub fn generation(&self) -> u64
pub fn generation(&self) -> u64
Counter bumped by every mutation, for callers caching anything derived from the store.
Sourcepub fn acl(&self) -> Acl
pub fn acl(&self) -> Acl
The grants this store holds, as an Acl the node merges with
the file table.
Built by rendering ACL lines and parsing them, rather than by constructing grants directly, so the two paths cannot drift into meaning different things by the same words.
Sourcepub fn authenticate(&self, user: &str, secret: &str) -> Option<Principal>
pub fn authenticate(&self, user: &str, secret: &str) -> Option<Principal>
Identifies a presented user:secret pair, in constant time
against the stored hash, or None when it matches nothing.
An expired invite matches nothing, so expiry needs no sweep to take effect.
Sourcepub fn invite(&self, issuer: &str, body: &Value) -> (u16, String)
pub fn invite(&self, issuer: &str, body: &Value) -> (u16, String)
Mints an invite for the account described by body, as issuer.
Returns the API’s (status, json). The secret half is in the
response and nowhere else: only its hash is stored, so an invite
that is lost is reissued rather than recovered.
Sourcepub fn request_access(&self, body: &Value) -> (u16, String)
pub fn request_access(&self, body: &Value) -> (u16, String)
Records an access request and returns its claim link halves (D72).
The one write on this node an unauthenticated caller can perform.
What makes that safe is that the row it writes authorizes
nothing: it is not a credential, it does not appear in
Accounts::acl, and Accounts::authenticate will not return
it. Until an operator grants it, holding the secret proves only
that you are the person who asked.
The queue is capped and swept here rather than by a timer, so the bound holds without anything having to be running.
Sourcepub fn pending_request(&self, id: &str, secret: &str) -> Option<RequestSummary>
pub fn pending_request(&self, id: &str, secret: &str) -> Option<RequestSummary>
The request id, if it is pending and secret is its secret (D72).
Deliberately not part of Accounts::authenticate. That
function answers “who is this”, and the honest answer for a
pending request is nobody: it reaches no route, carries no grant
and passes no check. Keeping it out means no arm anywhere else on
this node has to remember to refuse it.
Sourcepub fn pending_requests(&self) -> Vec<RequestSummary>
pub fn pending_requests(&self) -> Vec<RequestSummary>
Every pending request, oldest first, for the operator’s queue (D72).
Oldest first because a queue is worked from the front, and because the id order is random — sorting by it would shuffle the list on every render for no reason a reader could follow.
Sourcepub fn grant_request(&self, issuer: &str, body: &Value) -> (u16, String)
pub fn grant_request(&self, issuer: &str, body: &Value) -> (u16, String)
Turns a pending request into a live invite, as issuer (D72).
The id and the secret hash are carried over unchanged. That is the whole mechanism: the link the asker already holds is the link that starts working, so a grant needs no message sent, no address stored, and no second artefact to lose.
The account handle is minted here rather than at asking, so the string the op log carries forever is chosen by this node at the moment an operator said yes – never by the stranger, and never before anybody agreed to it (D46).
Sourcepub fn decline_request(&self, body: &Value) -> (u16, String)
pub fn decline_request(&self, body: &Value) -> (u16, String)
Drops a pending request (D72).
The asker’s link then renders exactly the page a link that was never valid renders – the one refusal the join page gives for every reason an invite does not work. Declining says nothing back: a node that distinguished “declined” from “never existed” would let a stranger probe which of their guesses had been read.
Sourcepub fn redeem(&self, invite_id: &str, body: &Value) -> (u16, String)
pub fn redeem(&self, invite_id: &str, body: &Value) -> (u16, String)
Redeems invite_id, creating its account and minting its token.
The token is in the response and nowhere else. An optional
ssh_key registers a key at the same time, which is what makes
the SSH transport self-service rather than a second errand.
Sourcepub fn mint_token(&self, user: &str) -> (u16, String)
pub fn mint_token(&self, user: &str) -> (u16, String)
Mints a fresh token for user, replacing any it had (D75).
The credential git and the CLI need, for an account whose sign-in credential is a passkey. Made on request rather than at redemption, because a secret nobody asked for is a secret nobody looks after – and because the person who wants one is, by then, somebody this node has already authenticated.
It replaces. One account, one token, so a rotation is the same act as a first mint and there is no set of live credentials to keep track of. The caller is told, because the old one stops working the moment this returns.
Sourcepub fn revoke(&self, body: &Value) -> (u16, String)
pub fn revoke(&self, body: &Value) -> (u16, String)
Removes an account and any invite outstanding for the same name.
Deletion rather than a tombstone: the token stops authenticating
on the next request, the grants leave the merged table, and the
key leaves the generated authorized_keys. That is the property
an append-only log could not have provided.
Sourcepub fn enroll_passkey(&self, user: &str, body: &Value) -> (u16, String)
pub fn enroll_passkey(&self, user: &str, body: &Value) -> (u16, String)
Enrols a WebAuthn credential on user’s own account (D39).
The caller is the account: this never takes a user from the
body, so holding a credential is the whole authorization story
and there is no way to spell “enrol a key on someone else”.
What this does not check, said plainly rather than implied by silence: possession. D39 scoped out a CBOR reader, so nothing here parses or verifies an attestation object; the node takes the public key the authenticated caller sends. Enrolling a key you do not hold gains you nothing — you still cannot sign with it — but a stolen token can enrol an attacker’s own authenticator and keep it. Two things bound that: the roster lists every enrolled credential, so it is visible rather than silent, and revocation deletes the account and its keys with it.
§Errors
400 for a missing or malformed field, a public key that is not a
P-256 SubjectPublicKeyInfo, or 409 for a credential id already
enrolled anywhere on this node; 404 when the caller has no account
record, which is the case for an --auth-file operator.
Sourcepub fn remove_passkey(&self, user: &str, body: &Value) -> (u16, String)
pub fn remove_passkey(&self, user: &str, body: &Value) -> (u16, String)
Removes one of user’s own enrolled credentials (D39).
The mirror of enrolment, and it exists for the same reason revocation does: a credential that cannot be withdrawn is not a credential, it is a permanent fact. A lost authenticator has to be removable by the person who still holds the token.
§Errors
400 without credential_id, and 404 when this account has no such
credential enrolled.
Sourcepub fn account_for_credential(
&self,
credential_id: &str,
) -> Option<(String, Vec<u8>)>
pub fn account_for_credential( &self, credential_id: &str, ) -> Option<(String, Vec<u8>)>
The SubjectPublicKeyInfo DER of one enrolled credential, ready for
choir_identity::verify_webauthn_assertion (D39).
Keyed by (user, credential_id) rather than by credential id
alone: an assertion names a credential, but the request already
names a principal, and looking the key up under that principal is
The account holding credential_id, and that credential’s public
key (D71).
Sign-in runs the lookup the other way round from every other passkey path: an assertion arrives before anyone has said who they are, and the credential id is the only name in it. WebAuthn’s own answer is a discoverable credential carrying a user handle, which this deliberately does not depend on – a credential enrolled before that was asked for would then be one its owner could sign with but not sign in with, and the person holding it would have no way to tell those apart.
A credential id is enrolled at most once across the store, which
Accounts::enroll_passkey enforces, so the first match is the
only match.
Sourcepub fn passkey_spki(&self, user: &str, credential_id: &str) -> Option<Vec<u8>>
pub fn passkey_spki(&self, user: &str, credential_id: &str) -> Option<Vec<u8>>
what stops one account’s assertion from being spent as another’s.
Sourcepub fn passkeys_json(&self, user: &str) -> Vec<Value>
pub fn passkeys_json(&self, user: &str) -> Vec<Value>
One account’s own enrolled credentials, for the page that account manages them on (D39).
Separate from Accounts::list_json because the questions differ:
the roster is the operator’s view of everyone and needs a node-wide
read, while this is a person looking at their own keys and needs
only their own credential. Returning an empty list for an unknown
name is deliberate — an --auth-file operator has no account
record, and that is “nothing enrolled”, not an error.
Sourcepub fn has_token(&self, user: &str) -> bool
pub fn has_token(&self, user: &str) -> bool
Whether user holds a token at all (D75).
False for a passwordless account, and false for a name with no account record. The account page asks so it can say “make one” rather than “replace the one you have”.
Sourcepub fn has_account(&self, user: &str) -> bool
pub fn has_account(&self, user: &str) -> bool
Whether this name has an account record at all, which is what decides between “you have no passkeys yet” and “this credential cannot hold one”.
Sourcepub fn invite_summary(&self, invite_id: &str) -> Option<InviteSummary>
pub fn invite_summary(&self, invite_id: &str) -> Option<InviteSummary>
What an invite promises, for the page that shows a holder what they are about to accept (D57).
Only ever call this for an invite whose secret has already
authenticated. Nothing here checks possession, so a caller that
reaches it with an id alone has built an oracle: Some versus
None would tell an anonymous stranger which invite ids exist and
which accounts are pending on this node. The one caller is the join
page, which gets the id from a successful
Accounts::authenticate and never from the request.
Expiry is re-checked rather than assumed, so this cannot be the place a stale invite is presented as a live one.
Sourcepub fn display_name(&self, user: &str) -> Option<String>
pub fn display_name(&self, user: &str) -> Option<String>
What to call user in something a person reads (D46).
None means “call it by its own name”, which is the answer in
three different situations a caller must not try to tell apart:
an account issued before D46, one issued with an explicit user,
and one whose display name has been deleted. The third is the
point of the field, so a caller that renders “unknown” or
“deleted” for it undoes the deletion by announcing it. Render the
handle and say nothing.
Sourcepub fn roster(&self) -> BTreeMap<String, String>
pub fn roster(&self) -> BTreeMap<String, String>
Every handle the store can name, as (handle, display name).
The rendering half of the D46 ACL decision: grants are written
against handles, and choir acl render regenerates the trailing
comments from this. Accounts with no display name are omitted
rather than listed as themselves — a comment repeating the handle
is noise, and the file already says it.