Authentication
DraftThe server can prove “this request is from a session it issued”; it cannot prove who the user is. The master key, held client-side, is the identity root: every login credential authenticates a session only, and the master key never derives from, and is never visible to, the credential verifier.
Account Types
Section titled “Account Types”- Registered accounts. A unique identity and their own master key. Sign in with password (+ optional TOTP) or OIDC (Choosing an Auth Path).
- Delegated/sponsored accounts. Keys derived from a registered account’s master key; no identity of their own, authenticated and managed by the sponsor, who has full access. See Keys — Delegated/Sponsored accounts.
- Non-registered accounts. No identity or master key: share links (keys encapsulated to the link secret) and web-upload links (guest seals to a link-scoped key, no read access).
Creating an Account
Section titled “Creating an Account”POST /v1/auth/register takes an address and a password, nothing else, and answers 200 with a token pair (registering signs you in).
- No display name, username or invitation code — the server stores as little about a person as it can.
- Password: a 12-character floor, no composition rule (it guards a session, not the master key).
- A taken address is
409 error.auth.user_already_exists. This is an account oracle by decision, bounded per source: every attempt whose body parses, a refused one included, is charged 10/hour against the resolved client address (IPv6 per /64, throughTRUSTED_PROXIES/TRUSTED_PROXY_HOPS), and a spent budget is429 error.auth.rate_limitedwhile a full limiter partition is429 error.auth.registration_at_capacity, both withRetry-After. The limiter fails closed: an unreachable counter store is500, never an admission. Behind an unconfigured proxy every registrant shares the proxy’s budget, as with the share and drop source limiters. - A new account has no device directory and therefore cannot write: invariant 7’s floor is the writing device’s
added_atin the published directory, with no account-creation fallback. The client’s first act is publishing a directory. - Not provided: token introspection (the session ledger is read on every request, so validation is the request) and password reset (the server cannot re-wrap a master key it never saw; recovery is the escrow blob).
Passkeys Are Not in v1
Section titled “Passkeys Are Not in v1”No passkey surface ships. Building one is new work, and it reopens the Dependencies rustls-only rule: webauthn-rs links openssl for attestation parsing.
The Second Factor
Section titled “The Second Factor”TOTP is the local path’s second factor.
| Endpoint | Contract |
|---|---|
POST /v1/auth/login | 202 Accepted with a challenge when the account has a confirmed factor. No session, cohort or refresh token exists until the challenge completes. |
POST /v1/auth/login/verify-totp | Challenge + code; opens the session (so advisory cohort_hash/device_id ride this request). 5 attempts per challenge, not per account — a per-account budget lets anyone lock an owner out. |
POST /v1/auth/totp/enroll | Issues secret + otpauth:// URI. Gates nothing until confirmed. Refused over a confirmed factor; replaces a pending one. |
POST /v1/auth/totp/verify-enrollment | Confirms; the confirming code is spent (enters the replay ledger). |
POST /v1/auth/totp/disable | Requires a live code, not just a session — a stolen access token must not switch off the control. |
- A code is accepted at most once (RFC 6238 §5.2): compare-and-set on the highest step used. Parameters are fixed: SHA-1, 6 digits, 30 s step, ±1 step drift.
- A store outage fails sign-in closed.
- Clients read the
202from the status; the SDK returns aLoginOutcome, never a challenge shaped like a failure. POST /v1/auth/reauthenticatetakes a password alone: it is performed by an existing session, which has already passed the factor.
The Profile Surface
Section titled “The Profile Surface”GET /v1/auth/profile— the caller’s own id, address, optional display name, creation time: the whole of what the server stores about a person. No{user_id}segment, so reading another’s profile is unrepresentable; other accounts’ public facts are the device directory.PATCH /v1/auth/profile— display name only. Absent key = unchanged, explicitnull= clear. Trimmed, ≤128 chars, control characters refused, not rewritten.POST /v1/auth/password— change, authenticated by the current password through the same path as sign-in (lockout applies). Closes every session of the account, then re-opens the caller’s. Wrong current password is403, never401.- The login address is immutable. Changing it without proof of control of the new address is an account takeover; the port has no method for it until such proof exists.
Choosing an Auth Path
Section titled “Choosing an Auth Path”Both paths mint the same sessions; they differ only in who verifies the credential. A deployment may enable either or both.
- Local auth (password + optional TOTP) — default; no external dependency. For personal and household servers.
- OIDC (authorization code + PKCE; Capsule is relying party only) — for deployments with an IdP or wanting SSO. Account lifecycle policy lives at the IdP.
Signing In Through an Identity Provider
Section titled “Signing In Through an Identity Provider”Authorization code + PKCE, plus the device grant (below). No implicit or hybrid flow.
Ceremony. POST /v1/auth/oidc/authorize takes the client’s redirect_uri and binding_challenge; answers the provider URL, state, and a 10-minute deadline. The client receives the redirect itself. POST /v1/auth/oidc/callback takes state, code, binding_verifier and answers exactly as POST /v1/auth/login does (token pair or 202 TOTP challenge), via the same session code.
Rules:
- Redirect allow-list. Exactly
OIDC_REDIRECT_URL, or — only whenOIDC_ALLOW_LOOPBACK_REDIRECTis on (default off) —httpon127.0.0.1/[::1], any port (RFC 8252 §7.3;localhostrefused per §8.3). Stored with the ceremony and replayed byte-for-byte to the token endpoint. Refusal:400 error.auth.oidc_redirect_invalid. - Begin is rate-bounded (unauthenticated write): 60/min per admitted redirect host (
429 error.auth.rate_limited), and the ceremony store’s capacity (503 error.auth.oidc_at_capacity). The limiter key is chosen after redirect validation; all refusals share one deployment-wide bucket, so a caller cannot mint counter keys. Limiter ceilings are partitioned per key kind so a flood on one surface cannot starve another. stateis single-use, burned on the first callback whatever the outcome. Unknown, spent and expired are one answer:401 error.auth.oidc_state_invalid.- Client binding (login-CSRF defence).
binding_challengeis the S256 hash (43-char unpadded base64url) of a ≥256-bit client secret; malformed →400 error.request.malformedbefore any budget is charged. The callback’sbinding_verifieris compared in constant time after burningstateand before contacting the provider; mismatch → the same401 error.auth.oidc_state_invalid. The SDK also refuses a foreignstatelocally (RFC 6749 §10.12). - ID-token checks: alg ∈ {RS256, ES256, EdDSA} (never
none/HMAC); signature against provider JWKS;issexact-equal toOIDC_ISSUER;audcontains client id;azpif present;exp/nbf/iatwith 60 s skew;noncematches; boundedsub. Every failure is one wire code,401 error.auth.oidc_token_invalid(detail goes to the log). Provider refuses exchange →401 error.auth.oidc_exchange_failed; unreachable →500 error.auth.oidc_unavailable(distinct fromerror.auth.unavailable). - Discovery is lazy (first use, cached 1 day; an IdP outage never blocks local auth). Refused if its
issuerdiffers from the configured one (mix-up defence). Endpoints must behttpsunless the issuer is a loopback IP literal, in which case plain-HTTP endpoints must also be loopback. JWKS refetched on unknownkidat most once a minute, and unconditionally after an hour (to drop revoked keys).OIDC_CA_BUNDLEadds trust anchors. - Accounts are keyed on
(issuer, subject), never linked by address. First sign-in creates a password-less account. A verified IdP email already held by any account →409 error.auth.oidc_address_taken; unverified emails reserve and collide with nothing. Explicit linking is a separate, out-of-scope ceremony. - A federated account is a row in the one account table with a null credential. Registering over its verified address is
409 error.auth.user_already_exists. A null credential never authenticates and is never charged to lockout. A profile with no verified address omitsemail. - OIDC ignores the password lockout — otherwise password guessing could lock a person out of SSO too.
- A confirmed TOTP factor is honoured: the callback answers the same
202challenge. - Scopes are
openid email, fixed.profileis not requested. server-infopublishesauth.oidc: { authorize, callback }ornull— endpoints only, never issuer or client id. Unconfigured →404 error.auth.oidc_not_configured.
Configuration (capsule-server/src/config.rs): OIDC_ISSUER (absent = off), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (optional; absent = public PKCE-only client, RFC 8252 §8.5), OIDC_REDIRECT_URL, OIDC_ALLOW_LOOPBACK_REDIRECT, OIDC_CA_BUNDLE. Issuer without client id, or vice versa, is a startup fault. Ceremonies live on Valkey (in-memory in development); capsule-server/compose.yaml ships a dex service (--profile oidc).
OIDC-Only Deployments
Section titled “OIDC-Only Deployments”PASSWORD_AUTH_DISABLED (default off) narrows the audited surface to the identity provider:
serverefuses it withoutOIDC_ISSUER: a server with neither door admits nobody.- Register, login and reauthenticate answer
403 error.auth.password_disabledbefore anything else: no rate-limit charge, no body validation, no directory lookup, no Argon2. A malformed body, an unknown address and a locked account get the same answer. - Every local account is refused, including one created before the switch. There is no break-glass account: a named exception keeps a password door open on the account an attacker most wants, and the operator’s recovery is turning the switch off.
- Existing sessions are not revoked. A session opened with a password lives out its sliding and hard expiry; it cannot re-authenticate with a password, so freshness-gated operations are closed to it.
server-infopublishesauth.password_disabled: truebesideauth.oidc, so a client hides its password form. Absent meansfalse, which is what a server predating the switch does.
Signing In From the CLI
Section titled “Signing In From the CLI”capsule auth login --oidc is the RFC 8252 native-app flow; no extra server surface.
- Listener on
127.0.0.1, ephemeral port, redirecthttp://127.0.0.1:{port}/callback(server must enable loopback redirects; the CLI prints that remedy on refusal). - The URL is always printed; opened too unless
--no-browser, only if it parses as absolutehttp(s), and on Linux/BSD only under a graphical session. - Wait ≤5 min, never past the ceremony deadline.
- Only a loopback callback request carrying this ceremony’s
stateends the wait; anything else is ignored, so another local process cannot cut the sign-in short. A providererrorfor thisstateends it;error_descriptionis printed with control characters stripped. - The session is settled by the same function as a password sign-in, TOTP prompt included.
Signing In Without a Browser
Section titled “Signing In Without a Browser”Device authorization grant (RFC 8628) for headless clients: capsule auth login --oidc --device.
-
The server proxies the provider; the
device_codenever leaves the server.POST /v1/auth/oidc/devicetakes abinding_challengeand answers a server-minted 128-bithandle,user_code,verification_uri(+_completeif given),interval, andexpires_by(min of providerexpires_inand 15 min). -
POST /v1/auth/oidc/device/polltakeshandle,binding_verifier, advisorycohort_hash/device_id. Success answers as the callback does (tokens or202). Otherwise:Provider answer Capsule answer Ceremony authorization_pending400 error.auth.oidc_device_pendingkept slow_down, or poll sooner than one interval429 error.auth.oidc_device_slow_down, interval += 5 skept access_denied401 error.auth.oidc_device_deniedburned expired_token; unknown/spent handle; binding mismatch401 error.auth.oidc_device_expiredburned other refusal 401 error.auth.oidc_exchange_failedburned unreachable 500 error.auth.oidc_unavailablekept -
Interval is server-enforced: provider’s value, ≥5 s; an early poll is refused without asking the provider. A client waiting one interval before every poll and adding 5 s per
slow_downstays in step. -
ID token: every callback check except the nonce, which MUST be absent (a token carrying a nonce was minted for some other authorization request). The back-channel request with a server-held device code replaces the nonce’s binding.
-
Binding:
binding_verifierchecked in constant time on every poll before the provider is asked; mismatch burns the ceremony. -
A session, TOTP challenge, refused ID token or taken address also burns the ceremony (burned before opening the session, so racing polls open one).
-
No grant support (no
device_authorization_endpoint, orunauthorized_client/unsupported_grant_type) →404 error.auth.oidc_device_unsupported; other device-endpoint refusals →500 error.auth.oidc_unavailable.verification_uriheld to the endpoint scheme rule;user_code≤64 chars, no control characters. -
Begin bound: binding shape first, then one deployment-wide 60/min budget, then store capacity. Polls are bounded by the begin budget × one provider call per interval.
-
The CLI prints the address and code, never opens a browser, and gives up at
expires_by.
Identity and Discovery
Section titled “Identity and Discovery”Handles are [email protected] (Matrix-style). .well-known/ never enumerates users.
.well-known/capsule/server-infois public and server-scoped only (API base, auth and federation endpoints, server signing key,protocol_versionrange,min_protocol_versioncutoffs).- User lookup is authenticated: local clients by session token; federated peers by a federation capability token, rate-limited per peer.
- Anonymous WebFinger returns only what the user opted into (default: nothing), limited to handle and display name — never keys, devices or album hints. This follows the deny-by-default rule.
The .well-known/capsule/* Registry
Section titled “The .well-known/capsule/* Registry”All are public and take no credential. A new path MUST add a row.
| Path | Contents | Owner |
|---|---|---|
server-info | Server-scoped facts above. Never a user list. | Identity and Discovery |
moved/{user} | IK-signed moved certificate (post-v1). | Account Portability |
revoked-jti | Federation capability revocations (≤24 h). | Federation |
deprecation | Min-supported-client announcements. | Schema Rules |
attestation-keys | Storage-attestation public keys + append-only history. | Transfer — Durability |
Account Portability
Section titled “Account Portability”Post-v1. The User IK is already a server-independent root; only the handle is host-bound. No DID system.
- The new server registers the account under the same IK; the key hierarchy is unchanged.
- The old server publishes
.well-known/capsule/moved/{user}:{ old_handle, new_handle, moved_at, ik_sig }, cacheable 24 h. It is the only user-naming well-known record, admissible because the user signs and initiates it. - Resolvers verify
ik_sigagainst the IK from the signed device directory and re-resolve. An unverifiable certificate is ignored (old handle keeps resolving) and surfaced.
No server can forge a migration: the IK signs the move.
Session and Access Tokens
Section titled “Session and Access Tokens”Session ID
Section titled “Session ID”A server-generated UUIDv7 identifying the session record.
Session Token
Section titled “Session Token”An opaque 128-bit bearer secret (not a JWT), stored on the client. Its only use is obtaining access tokens.
Access Token
Section titled “Access Token”A short-lived EdDSA JWT signed with the server’s Ed25519 key, issued against the session token. Classical only, per the operational-signature carve-out.
Session Expiry and Revocation
Section titled “Session Expiry and Revocation”A session token stops being honoured on sliding expiry, hard expiry, or explicit revocation — nothing else. Expiries are enforced at access-token issuance.
Sliding Inactivity Expiry
Section titled “Sliding Inactivity Expiry”180 days (SESSION_TTL_DAYS) without an access-token issuance. Each issuance resets the clock. Activity alone does not: a request records last-used time and never extends the session.
Hard Expiry
Section titled “Hard Expiry”365 days (SESSION_HARD_CAP_DAYS) from sign-in; never reset. Caps the window for a silently exfiltrated token on an active device (malicious keyholder).
Both are whole days, set per deployment and never per session. Zero, a negative count, or a cap shorter than the sliding window refuses the boot as a configuration fault. A lowered value bounds a live session from its next refresh onward: a session idle longer than the new window, or a lineage older than the new cap, is refused there as expired, while one inside both rotates and is issued only what the new values leave it. A raised value lengthens nothing until that refresh, because a stored session keeps the deadline it was opened under.
Invariants:
- The cap survives rotation. The session record carries its lineage’s sign-in instant; a refresh carries it forward unchanged and a re-authentication does not move it.
- A session lives until the earlier of the two expiries, and its refresh token is issued for exactly that remaining life, never a whole window past the cap.
- A deploy never shortens a live session. A record that predates the sign-in instant measures its cap from its own creation.
Explicit Revocation
Section titled “Explicit Revocation”The session ledger supports:
- List active sessions with last-used time.
- Revoke one session — authenticated by any active session token.
- Revoke all — authenticated by proof of master-key possession, not a session token, so a stolen token cannot lock the user out of every device.
Revoke-all is two requests: authenticated POST /v1/auth/logout/all/challenge issues a single-use challenge (authenticated so it is not an account-existence oracle); unauthenticated POST /v1/auth/logout/all redeems an IK signature over a domain-separated message containing it, verified against the account’s identity anchor, never a request-supplied key. The challenge is burned on every attempt.
Invariants:
- Revocation is immediate. The bearer scheme reads the ledger on every authenticated request, so an in-flight access token is refused on next use. The access-token lifetime bounds a lost token, not a revoked one.
- An unreadable ledger refuses (fail closed). It currently renders
401;POST /v1/auth/refreshreturnserror.auth.unavailableso clients can tell outage from expiry. Open: the honest503. - Last-used is coarse: at most one write per minute per session.
- The server MUST NOT revoke sessions on its own initiative; doing so bypasses the user-initiated audit trail and is a bug.
Device Cohorts
Section titled “Device Cohorts”Reinstalling re-enrolls with a new device_id (device keys are hardware-bound, Add-id Binding), so one phone accumulates ledger entries. The cohort hash groups sessions from one physical device. It is a grouping aid only.
Per-Platform Primary Identifier
Section titled “Per-Platform Primary Identifier”| Platform | Primary identifier | App reinstall | OS reinstall | Factory reset |
|---|---|---|---|---|
| iOS | Keychain random 128-bit seed, ThisDeviceOnly, non-synchronized | yes (not guaranteed) | no | no |
| Android | SSAID (signing-key-scoped ANDROID_ID) | yes | no | no |
| macOS | IOPlatformUUID | yes | yes | yes |
| Windows | MachineGuid | yes | no | no |
| Linux | /etc/machine-id (only ever hashed) | yes | no | no |
Promise: reinstall-stable everywhere; reset-stable only on macOS. A factory-reset phone is a new cohort.
Encoding
Section titled “Encoding”cohort_hash = SHA-256( canonical-CBOR([ "capsule-device-cohort/v1", user_id, platform_tag, primary_id ]) )Canonical CBOR, closed platform_tag enum. user_id is folded in so one device under two accounts yields unlinkable hashes. Implemented once in capsule-core (cohort).
Privacy and Scope
Section titled “Privacy and Scope”- Sent only in the session-opening request body. Never in signed artifacts, to federated peers, or in
.well-known; registered in the header census as a body field. - Advisory only: client-asserted and unverifiable, so no authorization or capability decision may read it. Never substitutes for
device_idor the DSK. Absent or garbage values behave identically to valid ones.
Server Storage and Surfacing
Section titled “Server Storage and Surfacing”- The session record carries
cohort_hash; a durabledevice_cohorts(user_id, cohort_hash, first_seen, last_seen)map outlives session expiry. - Recording a cohort never fails a sign-in (written after the session; failure logged and dropped).
- It appears only in the session listing: no filter by cohort, no “revoke cohort” verb — revocation names a
session_id. - UX asserts (“a device you’ve used before, last seen date”) with no “not my device” toggle; disputes go via a support report bundling
{cohort_hash, [(device_id, session_id, first_seen, last_seen)]}.
Validation
Section titled “Validation”- Token round-trip (unit): session token → access JWT → verify; after key rotation, old JWTs verify within their grace window.
- Expiry (unit, mock clock): sliding refreshes on each issuance, hard does not and survives rotation; rejection happens at access-token issuance.
- Revoke-all (unit): without master-key proof rejected; with it, every other session invalidated.
- Revocation immediacy (unit): a revoked session’s live access token is refused on next use.
- TOTP:
202on confirmed factor; code replay refused; per-challenge attempt cap; store outage fails closed. - OIDC (integration): in-process loopback mock provider (offline, container-free) covering key rotation, refetch floor, wrong PKCE verifier, every claim refusal, binding mismatch and each device-grant answer; gated round trips against dex (
CAPSULE_TEST_DEX=1). - Account portability (smoke, post-v1): moved certificate from A; B registers the same IK; peers follow the move.
- Cohort: cross-language known-answer vectors; same
primary_idunder twouser_ids → distinct hashes; absent/malformed/colliding values behave identically; a tripwire asserts no signed schema has a cohort field; durable map outlives sessions.
The auth → library-query E2E case is in Module Map.
Related
Section titled “Related”- Authorization — the closed lifecycle-action set every write proves against.
- Device Enrollment — how a device joins the account and the device directory.
- Backup & Recovery — recovering the master key after device loss.