Skip to content

Authentication

Draft

Authentication binds a user identity to their master key, which is the root of every encryption and decryption operation in Capsule. The server can prove “this request is from a session it issued” but cannot prove “this user is who they say they are” — the master key, owned client-side, is the actual identity root. Everything below works to keep that binding intact through the lifetime of a session and across server moves.

Planned in capsule-server::auth: OIDC handling, the session ledger, claim validation, and per-device records. The retired Salvo implementation remains under legacy-review/server-salvo/auth/ as review material, not an active server. The session token format and OIDC discovery surface below are the contracts other components — including federated peers — will depend on.

  • Two first-class auth paths. Local auth (password, with TOTP as a second factor) and OpenID Connect are both first-class login methods — see Choosing an Auth Path. The OIDC relying-party implementation is slice S-N1; local auth is the default path a deployment gets without configuring an IdP.
  • Cryptographic binding. The user’s identity is cryptographically bound to their master key. The server never sees the plaintext master key.
  • Registered accounts. Associated with a unique identity and have their own master key. Authenticated using password+TOTP or OIDC (Choosing an Auth Path); the login credential authenticates the session while the master key stays cryptographically bound to the user.
  • Delegated/sponsored accounts. Encrypted with keys derived from a registered account’s master key. They do not have their own identity and rely on the registered account for authentication and key management. Owners of the sponsored account have full access. See Cryptography — Keys: Delegated/Sponsored accounts for the key derivation.
  • Non-registered accounts. No associated identity or master key — used for share links, where the decryption keys are encapsulated around the secret stored in the link, and for web-upload links, where a guest seals contributions to a link-scoped key without read access.

POST /v1/auth/register takes an address and a password, and nothing else (slice S-C53), and answers with a token pair — registering signs you in, because the alternative is a second round trip that exists only to fail differently.

  • No display name, no username, no invitation code. Each would be a fact the server stores about a person, and this server stores as little as it can. A display name belongs to a profile surface, which is owed rather than assumed.
  • A length floor and no composition rule. Twelve characters. The password authenticates a session — the master key never derives from it and is never visible to the credential verifier — so this is ordinary sign-in security, and composition rules measurably push people towards shorter, more guessable passwords.
  • A taken address is 409 error.auth.user_already_exists, and that is an account oracle. It is the decided contract: answering success and creating nothing leaves a client that then cannot sign in and no way to tell it why. What bounds the oracle is a rate limiter, and there is none — registration is the one unauthenticated write on the surface, and limiting it means limiting a source, which needs a trusted client address this server does not have behind an unconfigured proxy chain. The same missing fact the share and drop source limiters are waiting on.
  • A new account has no device directory, and therefore cannot write. That is deliberate: invariant 7’s floor is the writing device’s added_at in the account’s published directory, with no account-creation fallback, so “was this device in the directory” has an honest answer for a brand-new account and the answer is no. A client’s first act after registering is publishing one.
  • The status is 200, where the retired surface answered 201. A 201 must say where the new thing lives, and this server exposes no URL for an account. Inventing one to satisfy a status would be inventing a surface.

Deliberately not ported from the retired surface: POST /v1/auth/validate — a token-introspection endpoint is what a server without a session ledger needs, and S-C48 put the ledger on every request, so validation is the request — and password reset, which on an end-to-end-encrypted account is not a password reset at all: the server cannot re-wrap a master key it has never seen, and the real recovery path is the escrow blob. Both are gone rather than owed.

Slice S-C56, and it is a removal decided on evidence rather than a deferral for want of time. The retired surface had six passkey operations and none of them could work:

  • They were Salvo #[handler]s rather than #[endpoint]s, so they appeared in no OpenAPI document and no generated client could reach them.
  • The authentication ceremony was started with an empty allow-list and set_allowed_credentials was never called, so webauthn-rs answered CredentialNotFound to every assertion. Not a bypass — simply an authentication that could never succeed.
  • Registration stored the credential’s serialized public key, not the credential, so nothing it wrote could be turned back into something authentication could use.
  • The code said so: “Using allow_credentials requires constructing webauthn_rs::prelude::Passkey, which currently presents integration challenges … For now, an empty list allows any credential for this RP.”

So there was nothing to port, and shipping passkeys means building them. That is a slice of its own, and it carries a real cost worth naming here: webauthn-rs is the only reason openssl is in the dependency tree — its attestation-CA parser links it. With passkeys deferred, the rustls-only rule in Dependencies holds with no exception at all, and the rebuild reopens both questions together.

Slice S-C55. Password + TOTP is a first-class local auth path, and on the retired surface it did not work: all four TOTP operations existed, and login never issued a challenge. An account could enroll a second factor, see it confirmed, and still be signed into with a password alone — a control that reported success and gated nothing.

  • POST /v1/auth/login answers 202 Accepted when the account has a confirmed second factor. That is what it is: the credentials were accepted and the request is not complete. No session is opened, no cohort is recorded and no refresh token is minted, because none of those may exist for an authentication that has not finished.
  • POST /v1/auth/login/verify-totp takes the challenge and a code, and that is where the session is opened — so the advisory cohort_hash and device_id ride this request rather than the first one. Five attempts per challenge, keyed on the challenge and not the account: a per-account budget would let anyone who knows an address lock its owner out with sign-ins they cannot complete.
  • POST /v1/auth/totp/enroll issues a secret and the otpauth:// URI an app scans. Nothing is gated until a code confirms it — a mis-scanned QR code must not lock somebody out of their own account. Enrolling over a confirmed factor is refused; enrolling over a pending one replaces it, because nothing is protecting an unconfirmed secret.
  • POST /v1/auth/totp/verify-enrollment confirms it, and the confirming code is spent: its step goes into the replay ledger so it cannot also complete a sign-in a moment later.
  • POST /v1/auth/totp/disable needs a live code, not just a session. The whole point of the factor is that a stolen access token is insufficient, and a disable that took only a token would let the token switch off the control that makes it so.

A code is accepted at most once (RFC 6238 §5.2). It stays valid for ninety seconds with drift, so “somebody read the six digits over your shoulder” is a real attack that verification alone cannot see; the defence is a compare-and-set on the highest step the account has used. The parameters are fixed and published — SHA-1, six digits, a thirty-second step, one step of drift — because every authenticator app assumes all four.

A store outage fails a sign-in closed. A login that proceeded because the enrollment store was unreachable would be a second factor an attacker turns off by loading that store.

Every client reads the 202 from the status (slice S-C63). capsule-sdk’s login returns a LoginOutcome rather than a session, so a caller must decide what to do about a challenge instead of receiving one shaped like a failure; capsule auth login prompts for the code, and a caller that cannot prompt gets a typed SecondFactorRequired. The advisory cohort_hash rides the completing request in every client, because that is the one that opens the session.

POST /v1/auth/reauthenticate still takes a password alone. The second factor guards becoming a session; re-authentication is performed by a session that already exists, and demanding a code there would protect nothing an attacker holding that session has not already got past.

Slice S-C54. Three operations, where the retired surface had one handler that branched on which fields a body happened to carry.

  • GET /v1/auth/profile — the caller’s own account: its id, the address it signs in with, its display name if it set one, and when it was created. That list is the whole of what this server stores about a person. There is no {user_id} segment, so reading somebody else’s profile is not a forbidden request but an unrepresentable one; the public facts of other accounts are the device directory, which publishes keys and nothing else.
  • PATCH /v1/auth/profile — the display name, and only the display name. The body is a partial: an absent key leaves the name alone and an explicit null clears it, which are different requests. A name is trimmed, capped at 128 characters, and refused rather than rewritten when it carries control characters — one that renders as something other than what was typed is worse than one the server declines.
  • POST /v1/auth/password — a password change, authenticated by the password it replaces. It verifies through the same directory call a sign-in uses, so a locked account is locked here too, and it then closes every session of the account and re-opens the caller’s own under its own session id: the leaked credential’s sessions stop working, and the person doing the rotation is not signed out of the device they are doing it on. 403 for a wrong current password, never 401 — the caller is authenticated, and a 401 would send a client to a sign-in its live session does not need.

The login address cannot be changed by any of them. The retired surface could change it, with no proof that the caller controlled the new address and no mail path in the deployment to obtain one. That is not a profile edit but the first step of an account takeover: a live token moves the account onto an address the attacker owns, and every later recovery flow then addresses them. The address is fixed at registration until there is a way to prove control of a new one, and the port has no method for it rather than a method that refuses.

Both paths mint the same Capsule sessions and bind identity to the master key the same way; the difference is who verifies the login credential (decision 2026-07-12):

  • Local auth (password, with TOTP as a second factor) — recommended for personal and self-hosted single-user or household servers: no external dependency, the server is self-contained.
  • OIDC (external identity provider, authorization-code + PKCE) — recommended for enterprise and organizational deployments that already run an IdP, and for anyone wanting SSO. Capsule is a relying party only; account lifecycle policy lives at the IdP.

A deployment may enable either or both. Neither path weakens the cryptographic binding: the IdP (or password) authenticates the session; the master key never derives from, and is never visible to, the credential verifier.

Slice S-N1 (the server) and the SDK half of S-N2 (capsule-sdk’s begin_oidc_login / complete_oidc_login). Authorization code + PKCE, and nothing else: no implicit flow, no hybrid flow, and — until the CLI’s loopback listener and the device grant land (issue #461) — no device authorization grant.

  • Two requests, one ceremony. POST /v1/auth/oidc/authorize takes the client’s own redirect_uri and answers the provider’s authorization URL, a state, and the ceremony’s deadline (ten minutes). The client sends the person there and receives the provider’s redirect itself — a web app’s callback route, a CLI’s loopback listener, ASWebAuthenticationSession on iOS. POST /v1/auth/oidc/callback takes the redirect’s state and code and answers exactly what POST /v1/auth/login answers: a token pair, or a 202 second-factor challenge. The session is opened by the same code the password path uses, so a federated sign-in is in every respect the same session.
  • The redirect URI is client-supplied and allow-listed. Admitted if it equals OIDC_REDIRECT_URL exactly, or — when OIDC_ALLOW_LOOPBACK_REDIRECT is on, which it is not by default — is an http URI whose host is the loopback IP literal 127.0.0.1 or [::1] on any port (RFC 8252 §7.3; localhost is deliberately not admitted, per §8.3). The loopback arm is opt-in because it is the one knob that widens where the server will send a person back to; a deployment with a CLI or desktop client turns it on, and the CLI flow (issue #461) tells the operator so. The admitted value is stored with the ceremony and replayed byte for byte to the token endpoint, as RFC 6749 §4.1.3 requires. This one field is what lets a native client complete the flow without a second server surface. A refused URI is 400 error.auth.oidc_redirect_invalid.
  • Beginning a ceremony is bounded twice, because it is an unauthenticated write into a store: sixty a minute per redirect host (429 error.auth.rate_limited; the policy admits three hosts at most, so this is close to a deployment-wide ceiling), and the pending-ceremony store’s own capacity — ten thousand in memory, expired records purged on every write — answered as 503 error.auth.unavailable, the retryable code, when reached.
  • The state is burned on the first callback, successful or not. The nonce, the PKCE verifier and the redirect URI live in a single-use ceremony store between the two legs; a replayed state — and therefore a stolen code arriving on it — finds nothing. Unknown, spent and expired are one answer, 401 error.auth.oidc_state_invalid, so the callback is not an oracle.
  • Every ID-token refusal is one code on the wire. The relying party checks the header algorithm (RS256, ES256 or EdDSA; never none, never HMAC), the signature against the provider’s published keys, iss for exact string equality with OIDC_ISSUER, aud containing the client id, azp when present, exp/nbf/iat with a sixty-second skew, the nonce against the one this ceremony issued, and a bounded sub. Which check failed reaches the server log; the wire says 401 error.auth.oidc_token_invalid for all of them. A provider that refuses the exchange is 401 error.auth.oidc_exchange_failed; a provider that cannot be reached is 500 error.auth.oidc_unavailable, distinct from error.auth.unavailable because “your identity provider is down” and “our session store is down” are different operator actions.
  • Discovery is lazy, and a provider that names another issuer is refused. The provider’s metadata is fetched on first use and cached for a day; nothing is resolved at boot, so an identity provider that is down does not stop a server from serving local auth. A discovery document whose issuer is not the configured one is refused (the mix-up defence), and every endpoint must be https unless the issuer itself is a loopback IP literal — the development carve-out — under which every plain-HTTP endpoint must itself be loopback, so a provider on this machine cannot send the code off-box in the clear. Signing keys are refetched on an unknown kid, at most once a minute, so a stream of forged key ids cannot make the server hammer the provider — and re-read after an hour regardless, because a key the provider revoked never produces that evidence; the ceiling is what stops it being honoured. A provider behind a private CA is reached with OIDC_CA_BUNDLE, a PEM bundle of additional trust anchors read at boot.
  • Accounts are keyed on (issuer, subject), and never linked by address. The first sign-in for an unknown pair creates a password-less account. An IdP-asserted email that already belongs to an account is 409 error.auth.oidc_address_taken, never a link: the address is a claim the provider controls, and honouring it as a link key would hand the matching account to anyone who can set an email at the provider — the same class of takeover the profile surface refuses when it fixes the login address. The disclosure the 409 makes is the one registration already makes. Only a verified address counts, both ways: an address the provider asserts without email_verified reserves nothing and collides with nothing, or a person could register somebody else’s address at the provider, unverified, and hold its owner out. Deliberately linking an existing account to a provider identity is a separate, authenticated ceremony, out of scope.
  • Deviation, named rather than substituted (issue #460). Two of the properties above are owed rather than shipped, because the account port has no nullable credential yet and the federated rows do not share the password directory’s table: the 409 is checked against federated accounts’ verified addresses, not yet against password accounts’ — and a password-less OIDC account is one no password row exists for, not yet one whose null credential authenticate refuses structurally. The test fixture’s double encodes the intended contract; the Postgres adapter delivers it.
  • The OIDC door does not consult the password lockout. The lockout counts failed credential presentations against the local directory, and a federated sign-in presents none — the provider already authenticated the person. Refusing single sign-on on a locked local account would let anyone who can guess passwords at POST /v1/auth/login lock a person out of the other door too.
  • The second factor is honoured, not bypassed. A confirmed TOTP enrollment turns the callback into the same 202 challenge the password path issues, completed at POST /v1/auth/login/verify-totp with the advisory cohort_hash and device_id riding that completing request. Bypassing it would let an account that enrolled a factor be signed into without one through a second door.
  • Scopes are openid email, with no knob. The address is the one claim the relying party reads, for the one decision it makes with it. profile is not requested: the display name is something the person sets, and asking the provider for it would have the server store a fact it declined to collect at registration.
  • server-info publishes auth.oidc: { authorize, callback }, or null. Endpoints only — never the issuer, never the client id, never anything user-scoped. The presence of the record is how a login chooser decides whether to offer the path; without OIDC_ISSUER the authorize answers 404 error.auth.oidc_not_configured.

Configuration is six variables, read with the rest in capsule-server/src/config.rs: OIDC_ISSUER (absent means the path is off; https, or http on a loopback IP literal for development), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (optional — absent is a public client, PKCE-only, which is what RFC 8252 §8.5 requires of a native app), OIDC_REDIRECT_URL (optional; held to the issuer’s scheme rule), OIDC_ALLOW_LOOPBACK_REDIRECT (default off) and OIDC_CA_BUNDLE (optional; a PEM path, read at boot and refused by name if unusable). Half a relying party — an issuer with no client id, or the reverse — is a startup fault. Under the durable backends OIDC_ISSUER is refused by name until the Valkey ceremony store and the Postgres federated-account adapter land (issue #460); the development profile runs it on the in-memory adapters, and capsule-server/compose.yaml ships a dex service (--profile oidc) with a public capsule client to run it against.

Deviation from the validation plan, named rather than substituted. Validation asks for a testcontainer IdP. The suite uses an in-process mock provider on loopback instead, because mise run test-rust runs offline and container-free; it speaks the identical wire — discovery JSON, a JWK Set, a form-encoded token POST, a signed compact JWS — and exercises key rotation, the refetch floor, a wrong PKCE verifier at the token endpoint, and every claim refusal. The dex service is the manual run against a real provider.

Patterns borrowed from Matrix 2.0, with one critical departure: .well-known/ never enumerates the user list. A federated setting where a peer can list every user on a server is unacceptable — both from an abuse-surface perspective (spam, harassment-target discovery, account-enumeration attacks) and a privacy perspective.

  • All users have a handle like user@yourserver.tld (resembling Matrix’s MXID pattern).
  • .well-known/capsule/server-info is public and returns only server-scoped facts: the API base URL, auth endpoints, the federation endpoint, the server’s signing key, supported protocol_version range, and min_protocol_version cutoffs for active deprecation windows. It never returns a user list.
  • User lookup is authenticated. A client or peer server must present credentials to resolve user@server.tld:
    • Local client lookup (resolving another user on the same server, e.g. for sharing): authenticated by the looker’s session token.
    • Federated peer lookup (resolving a user across servers): authenticated by a federation capability token (see Federation — Federation Capabilities) and rate-limited per peer.
    • Anonymous WebFinger: returns only records the target user has explicitly opted into making public. The default is opt-out: no anonymous record. The opt-in-able record set is deliberately tiny — handle and display name only, never keys, device lists, or album hints; anything richer requires authenticated lookup. This is deliberately stricter than Matrix’s default and follows the deny-by-default rule from the threat model.

Every well-known path Capsule serves, in one census. Each path’s record format is owned by the linked doc; a new path MUST add a row here when introduced.

PathContentsOwner
.well-known/capsule/server-infoPublic server-scoped facts: API base URL, auth + federation endpoints, server signing key, supported protocol_version range, deprecation cutoffs. Never a user list.this doc (Identity and Discovery)
.well-known/capsule/moved/{user}The IK-signed moved certificate for a migrated account.this doc (Account Portability)
.well-known/capsule/revoked-jtiFederation capability revocation list (bounded to ≤ 24 h of revocations).Federation
.well-known/capsule/deprecationMin-supported-client deprecation announcements.Threat Model — Schema Rules
.well-known/capsule/attestation-keysThe server’s storage-attestation public keys + append-only key history.Storage Verification

Status note. Four of the five records are served today by the Kynos surface: attestation-keys with slice S-C15, and server-info, revoked-jti and deprecation with slice S-C18. Every one of them is public and takes no credential — a client deciding whether it can talk to this server at all has no token yet, a peer checking whether a capability token it holds is still good is by construction not authenticated here, and a client pinning the key that checks the server’s own liability must not need the server’s permission to fetch it. moved/{user} is post-v1 with Account Portability; it is the one record that names a user, admissible only because the user signs it and the user initiates the migration.

Status: post-v1 (decision 2026-07-12). No moved-certificate route or migration flow ships in v1; the contract below is normative for when it lands.

A user must be able to move servers without losing their identity. Capsule does not need a separate DID system: the user identity key (User IK — see Cryptography — Keys) is already a server-independent root of trust. Only the user@server.tld handle is host-bound.

Migration re-homes the handle while keeping the same IK:

  • The new server registers the account under the same IK; nothing in the key hierarchy changes.
  • The old server publishes an IK-signed moved certificate at .well-known/capsule/moved/{user} — a small signed record { old_handle, new_handle, moved_at, ik_sig }, cacheable for 24 hours. This is the one well-known record that names a specific user — opted-into (the user actively migrates) and carrying the user’s own signature, so it does not constitute the kind of enumeration leak we forbid.
  • Clients and federated peers that resolve the old handle fetch this certificate, verify ik_sig against the IK they already trust for the user (from the signed device directory), and re-resolve to the new handle it names. An unverifiable certificate is ignored — the old handle keeps resolving as before, and the failure is surfaced.

Because the IK signs the move and every device cross-signs to that IK, no server — old or new — can forge a migration or hijack the handle.

These are the two token shapes consumers depend on. Both will be issued by capsule-server::routes::sessions after a successful authentication ceremony.

Sessions are identified by a UUIDv7 generated by the server upon successful authentication. It tracks session state and associated metadata.

A long-lived 128-bit secret generated by the server upon successful authentication and stored securely on the client. It is not a JWT — it is an opaque bearer secret. The session token’s only purpose is to obtain access tokens for API requests.

Short-lived tokens issued against the session token (presented, not cryptographically derived), used to authenticate API requests. They have a limited lifespan and are refreshed using the session token without re-authenticating the user.

Capsule uses EdDSA JWTs as access tokens, signed under the server’s Ed25519 signing key — classical only, per the operational-signature carve-out (access tokens are short-lived, so PQ hybridization buys no margin).

Sessions expire in two ways: sliding inactivity expiry (automatic) and explicit revocation (user-initiated). They coexist; either causes the session token to stop being honored.

A session that has not been used for 180 days (default; deployment-configurable) expires automatically. “Used” means a successful access-token issuance against the session token — each issuance refreshes the inactivity clock. This bounds the lifetime of a session on a device the user has forgotten about (a phone in a drawer, a laptop given to a relative) without forcing re-authentication on actively-used devices.

Every session token has a hard expiry of 365 days from issuance (default; deployment-configurable). The hard expiry does not reset on use — it is the upper bound on the lifetime of a token regardless of activity.

The rationale is the malicious-keyholder class from Threat Model — Client Class Taxonomy: an attacker who silently exfiltrates a session token from a device the user actively uses would otherwise have an indefinite window of access. The hard expiry caps that window at one year; the user re-authenticates (password + TOTP) at most once a year per device — acceptable friction in exchange for a bounded leak-window.

Both expiries are enforced server-side at access-token issuance; the session token itself is not invalidated for any other reason than these expiries or an explicit revoke.

A common user session ledger supports:

  1. List all active sessions (with last-used timestamp, so an expiring session is visible).
  2. Revoke any single session by invalidating its session token — authenticated by any active session token.
  3. Revoke all sessions at once (“log out of all devices”) — authenticated by proof of master-key possession (a signature with the user’s IK over a server-issued challenge), not by an active session token.

The ceremony is two requests: an authenticated POST /v1/auth/logout/all/challenge issues a single-use challenge, and an unauthenticated POST /v1/auth/logout/all redeems a proof over it. Issuing the challenge takes a session token and the revoke does not, which is not a contradiction — a challenge is worthless without the identity key, so handing one to a stolen token costs nothing, while issuing them unauthenticated would make the endpoint an oracle for whether an account exists. The proof is an IK signature over a domain-separated message covering the challenge, verified against the account’s identity anchor rather than any key the request supplies. The challenge is burned on every attempt, successful or not, so an attacker cannot grind signatures against a live one.

What a revoke is immediate about. Both halves. Every session record is closed, so no refresh token can mint anything from that moment; and the bearer scheme reads the session ledger on every authenticated request, so an access token already in flight is refused on its next use rather than on its next deadline. The fifteen-minute access-token lifetime is therefore a bound on how long a lost token is useful, not on how long a revoked one is.

The price is one ledger read per authenticated request, against a store the deployment already requires. Two consequences follow from putting it on that path, and both are contract rather than implementation detail:

  • An unreadable ledger refuses. Failing open would suspend revocation at exactly the moment an attacker would choose to suspend it. The refusal renders 401, which is the only status the framework’s authentication rejection can carry; the honest 503 is owed and tracked on S-C36. A client that answers the 401 by refreshing gets error.auth.unavailable from POST /v1/auth/refresh and can tell an outage from an expiry there.
  • The last-used timestamp in (1) is coarse. Recording activity on every request would mean a store write on every request, so it is coalesced to at most one write per minute per session. A device that is actively syncing can therefore read as up to a minute idle. Session lifetime is unaffected: it is absolute from the moment the session opened, and activity never extends it.

The asymmetric authentication on (3) addresses a damage scenario that pure session-token auth opens up: an attacker holding a stolen session token could otherwise invoke “log out of all devices” and lock the legitimate user out of every other device. Requiring master-key proof for the global revoke means an attacker with a session token can only revoke that session — they cannot escalate to denial-of-service. A user who has lost their master key is no worse off: they can still revoke individual sessions one at a time. The single-session revoke (2) is the everyday tool; the global revoke (3) is the nuclear option, gated accordingly.

Note: the server can theoretically just kick off sessions because session tokens are stored server-side and the server holds the encrypted data. But this should not ever be implemented and an attempt to do so would be a bug — it bypasses the audit trail of a user-initiated revoke.

The session ledger has a legibility problem: reinstalling the app re-enrolls with a new device_id by design (device keys are hardware-bound and non-exportable — Metadata, Add-id Binding), so one physical phone accumulates several ledger entries over its life and the user cannot tell them apart. The device cohort hash groups sessions from the same physical device. It is a grouping aid, nothing more.

One primary identifier per platform — fewer, better-chosen inputs beat a concatenated fingerprint that splits whenever any component shifts:

PlatformPrimary identifierSurvives app reinstallOS reinstallFactory reset
iOSKeychain-persisted random 128-bit cohort seed (ThisDeviceOnly, non-synchronized — iCloud sync would merge distinct physical devices)yes in practice (not Apple-guaranteed)nono
AndroidSSAID (app-signing-key-scoped ANDROID_ID)yesnono
macOSIOPlatformUUIDyesyesyes
WindowsMachineGuidyesnono
Linux/etc/machine-id (never used raw — hashed below, per systemd guidance)yesnono

(IDFV was rejected for iOS: it resets on reinstall when no sibling app remains — precisely the case cohorts exist for.)

Honest scope. “The same device even after a factory reset” is technically impossible on iOS and Android: a reset destroys every app-accessible identifier by OS design, and post-reset attestation (DeviceCheck, Play Integrity) yields verdicts, not identifiers. The promise is therefore: reinstall-stable everywhere; reset-stable only where the OS allows (macOS). A factory-reset phone starts a new cohort, and the doc says so rather than pretending otherwise — the same honesty rule as platform limitations. (DeviceCheck’s per-device bits may later corroborate a boolean “this hardware enrolled before”; deliberately out of scope for v1.)

cohort_hash = SHA-256( canonical-CBOR([ "capsule-device-cohort/v1", user_id, platform_tag, primary_id ]) )

Domain-separated, canonical CBOR (never naive string concatenation), with a closed platform_tag enum. user_id is folded in so the same physical device under two accounts yields unlinkable hashes — the cross-account correlation surface is removed at the source. The pure function lives in capsule-core (cohort module) so every platform computes it identically.

  • Sent only in the session-creation request body during the auth ceremony; never in signed artifacts (manifests, sidecars, the device directory), never to federated peers, never in .well-known. It is registered in the X-Capsule-* header census by pointer as a body field so no header variant drifts into existence.
  • Advisory-only, structurally: the value is client-asserted and unverifiable, so no authorization or capability decision may read it — otherwise it becomes spoofable attack surface. It never substitutes for device_id (random UUIDv4, security-bearing) or the DSK. A server that receives an absent or garbage cohort value behaves identically to one that receives a valid one.

The session record carries cohort_hash, and a small durable device_cohorts(user_id, cohort_hash, first_seen, last_seen) map persists it beyond session expiry — session-store-only would forget cohorts exactly when the “seen before” question matters. The session-listing surface returns the cohort per session plus the cohort map; clients group the ledger by cohort. Recording a cohort is never allowed to fail a sign-in: it is written after the session and its failure is logged and dropped, because an advisory grouping aid must not take down the one operation an account cannot do without — the same reason a malformed value is dropped rather than rejected. The listing surface is shaped so the advisory-only rule cannot quietly erode: the cohort appears there and nowhere else, no parameter filters by one, and revocation names a session_id. A “revoke this cohort” verb would be an authorization decision made from a spoofable string.

The client asserts, it does not litigate: “a device you’ve used before (last seen date)” — there is deliberately no “this isn’t my device” toggle, because the user cannot adjudicate a hash and the value is advisory anyway. The dispute path is a support report: one tap bundles {cohort_hash, [(device_id, session_id, first_seen, last_seen)]} — the exact hash and device-id map — for a bug report.

  • Token issuance round-trip (unit). Generate a session token; issue an access JWT from it; verify the JWT under the server’s Ed25519 key. Repeat with rotated keys; assert old JWTs verify under the old key for their grace window.
  • Expiry enforcement (unit). Mock the clock; assert sliding expiry refreshes on use, hard expiry does not. Assert an expired token is rejected at access-token issuance, not earlier or later.
  • Revoke-all master-key proof (unit). Issue a revoke-all without master-key proof; assert rejection. With proof; assert success and invalidation of every other session.
  • Login flow (smoke). Full OIDC handshake against a testcontainer IdP; assert session token issued, persisted, and usable for an immediate access-token request. Re-run after a server restart; assert resilience.
  • Account portability (smoke). Issue a moved certificate from server A; assert server B can register the same IK; assert federated peers honor the move after fetching A’s well-known.
  • Cohort hash vectors (unit). Known-answer vectors for cohort_hash through the cross-language canonical-CBOR conformance gate; same primary_id under two user_ids → distinct hashes.
  • Cohort is advisory (unit). Session creation with an absent, malformed, or colliding cohort value behaves identically to a valid one; a tripwire test asserts no signed-structure schema contains a cohort field.
  • Cohort grouping (smoke). Two sessions with one cohort group together in the session listing; a reinstall (new device_id, same cohort) groups with “previously used”; the durable map outlives session expiry.

The cross-module case — auth → query library schema — is one bounded E2E test listed in Module Map.