package main import ( "errors" "github.com/go-webauthn/webauthn/webauthn" ) // blobKeepVersions is how many blob versions the store retains per user — // overwrite protection against a stolen link token trashing the backup (the // token can write, but the previous versions survive until pushed out). const blobKeepVersions = 5 // lastSeenGranularity is how stale a link token's last_seen must be before a // lookup rewrites it (seconds). Day-coarse keeps the devices list honest while // sparing an object-store backend a write per authenticated read. const lastSeenGranularity = int64(24 * 60 * 60) // errBadVersion reports an If-Match version string the store cannot even // interpret (as opposed to one that merely mismatches, which is a conflict). // The handler maps it to 400; a mismatch maps to 409. var errBadVersion = errors.New("bad version") // errAbsorbedHasBlob is mergeAccounts refusing to touch an account that still // holds a vault blob — the belt under the merge handler's own precondition // check (the client must move the vault contents over and delete the absorbed // blob FIRST; see merge.go). The handler maps it to 409. var errAbsorbedHasBlob = errors.New("absorbed account still has a blob") // vaultStore is home's persistence seam. Its method shapes deliberately mirror // an object store — point reads by key, conditional writes (CAS on an opaque // version), keep-last-N versioning, prefix deletes, and write-once pointers — // so that a bucket can implement it directly (issue #550: blobs/ for the // vault, users/ for metadata, creds/ and tokens/ as // pointers back to the uid). SQLite is implementation #1 and the zero-config // default; the interface exists so an s3Store can be implementation #2 without // the handlers changing. // // Versions are OPAQUE STRINGS at this boundary. The wire contract is already // ETag-opaque; a backend may use integers, ETags, or generation numbers // internally, but callers only ever echo a version they were handed — plus the // one universal sentinel "0", meaning "no blob yet" (create-only). // // Pointers are never authoritative: a backend resolving a credential id or // token hash must verify the target user still lists it, so a stale pointer // left behind by a revocation denies by construction. In SQLite that falls out // of single-table deletes; an object store must uphold it explicitly. type vaultStore interface { // ---- the vault blob (blobs/, versioned, CAS) ---- // getBlob is a point read of the latest version. found=false when the user // has no blob. getBlob(uid string) (data []byte, version string, found bool, err error) // putBlob writes a new version iff ifMatch names the current one ("0" = // create-only, i.e. no blob may exist yet). On mismatch it reports // conflict=true and returns the CURRENT version ("0" when none exists) so // the caller can 409 + ETag; the client merges and retries. Only the last // blobKeepVersions versions are retained. An ifMatch the store cannot // interpret at all returns errBadVersion. putBlob(uid string, data []byte, ifMatch string) (version string, conflict bool, err error) // listBlobVersions returns the retained version identifiers, newest first — // the observable face of the keep-last-N contract. listBlobVersions(uid string) ([]string, error) // deleteBlob is the prefix delete: every retained version goes, history // included — that's the point of the ceremony gate in front of it. deleteBlob(uid string) error // ---- user metadata (users/) ---- createUser(uid string) error // credentialsFor returns the user's WebAuthn credentials in enrolment order. credentialsFor(uid string) ([]webauthn.Credential, error) // insertCredential stores a verified credential and the creds/ // pointer back to its user. insertCredential(uid string, cred *webauthn.Credential) error // updateCredential persists post-ceremony state (sign count, backup flags), // located by credential id. updateCredential(cred *webauthn.Credential) error // deleteCredential removes one passkey. It refuses to remove the account's // LAST sign-in method (last=true, nothing deleted) — counting passkeys // PLUS siwa/email anchors, so an account holding another anchor may shed // its final passkey (#1089 reversed the passkey-only invariant), but no // removal may ever leave zero ways in: the blob would be orphaned while // staying live — the explicit wind-down path is the ceremony-gated blob // delete. deleted=false with last=false means the id is unknown (or // someone else's). deleteCredential(uid, credID string) (deleted, last bool, err error) // listCredentialMeta returns the user's passkeys as listing metadata — // base64url credential id + enrolment time, enrolment order — what the // anchors list (#1089) shows without hauling public keys to the client. listCredentialMeta(uid string) ([]Anchor, error) // credentialUser resolves the creds/ pointer: which user enrolled // this credential. ok=false for an unknown (or revoked) id. credentialUser(credID string) (uid string, ok bool) // ---- device link tokens (tokens/ pointers) ---- // createLinkToken mints a bearer token for a user and returns the plaintext // (shown/stored client-side once; only the hash persists in the store). createLinkToken(uid string) (token string, err error) // linkTokenUser resolves a token HASH to its user, bumping last_seen at // most once per lastSeenGranularity (day-coarse — the devices list doesn't // need better, and an object store shouldn't pay a write per read). // Callers hash the presented plaintext (hashToken) — the store is keyed by // hash and never sees a live token after minting. linkTokenUser(tokenHash string) (uid string, ok bool) // linkTokenID returns the device id behind a token hash, so the devices // list can mark "this device". "" when unknown. linkTokenID(tokenHash string) string listDevices(uid string) ([]Device, error) // deleteDevice revokes one of the user's link tokens. ok=false when the id // doesn't exist or belongs to someone else. deleteDevice(uid, id string) (ok bool, err error) // ---- Sign in with Apple anchors (siwa/ pointers) ---- // siwaUser resolves a hashed Apple sub (HMAC(pepper, sub), hex) to its // user. ok=false when no account carries that anchor. siwaUser(subHash string) (uid string, ok bool) // insertSiwaAnchor stores the siwa/ pointer. Idempotent for the // same user; conflict=true (nothing written) when the sub is already // anchored to a DIFFERENT user — an Apple ID signs into one account. An // anchor removed by deleteSiwaAnchor is genuinely gone: the same sub may // anchor a different account afterwards. insertSiwaAnchor(uid, subHash string) (conflict bool, err error) // listSiwaAnchors returns the user's SiwA anchors (removal id + creation // time), creation order. The id IS the stored sub hash — see the Anchor // doc for why handing it to the account's own client is fine. listSiwaAnchors(uid string) ([]Anchor, error) // deleteSiwaAnchor removes one SiwA anchor, under the same last-method // guard as deleteCredential (last=true, nothing deleted). deleted=false // with last=false means the hash is unknown (or someone else's). deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error) // ---- email anchors (emails/ pointers) ---- // emailUser resolves an address's blind index (HMAC(pepper, // lowercase(email)), hex) to its user. ok=false when unanchored. emailUser(index string) (uid string, ok bool) // insertEmailAnchor stores the emails/ pointer. Idempotent for the // same user; conflict=true (nothing written) when the address is already // anchored to a DIFFERENT user. Like siwa, a removed anchor's index may // re-anchor a different account later. insertEmailAnchor(uid, index string) (conflict bool, err error) // listEmailAnchors / deleteEmailAnchor mirror the siwa pair for the email // blind indexes. listEmailAnchors(uid string) ([]Anchor, error) deleteEmailAnchor(uid, index string) (deleted, last bool, err error) // ---- OIDC provider anchors (oidc// pointers) ---- // // The siwa set generalized (oidc.go): provider is a fixed in-code name // ("google", "microsoft" — oidcProviderNames), never input, and the hash // is HMAC(that provider's pepper, sub). Apple deliberately stays on the // siwa methods above — deployed data and wire behavior are untouched. // Semantics match the siwa set one for one: insert is idempotent for the // same user and conflict=true for a different one; a removed anchor's // identifier may re-anchor a different account later; delete sits under // the same last-method guard as every other sign-in method. oidcUser(provider, subHash string) (uid string, ok bool) insertOidcAnchor(uid, provider, subHash string) (conflict bool, err error) listOidcAnchors(uid, provider string) ([]Anchor, error) deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) // ---- account merge (#1098) ---- // mergeAccounts folds the absorbed account into the survivor: EVERY // sign-in anchor (siwa, email, oidc), passkey credential, and device link // token moves from absorbed to survivor, then the absorbed user record is // deleted. Identifiers cannot collide across accounts by construction — // each anchor hash / credential id / token hash is a globally-unique // pointer to exactly one account — so a move never conflicts. Link tokens // move (rather than die) so the absorbed account's other devices keep // working, now signed into the survivor. The op is atomic per backend // (one transaction / a crash-safe write ordering); it refuses with // errAbsorbedHasBlob while the absorbed account still holds a blob — the // belt under the handler's own check, so a merge can never orphan vault // data. An absorbed account that no longer exists is a zero-count // success, which is what makes a retried merge (the ceremony proof // outlives the deleted account by a few minutes) harmless. mergeAccounts(survivor, absorbed string) (mergeMoved, error) // Ceremony proofs are deliberately NOT here: they became stateless HMAC // tokens (stateless.go) — a store that persists nothing needs no methods. } // mergeMoved reports what mergeAccounts relocated — the counts the merge // endpoint answers with, so the client can say what happened in plain words. type mergeMoved struct { Anchors int `json:"anchors"` Passkeys int `json:"passkeys"` Devices int `json:"devices"` } // Anchor is one sign-in method's listing metadata — an opaque, stable removal // id plus creation time, nothing else (cf. Device). For siwa/email the id is // the stored HMAC-peppered hash ITSELF: it reverses to nothing without the // pepper, it never changes, and it's exactly the key removal needs — minting // a surrogate id per anchor would add store state for zero privacy gain. // What must never appear here is anything reversible (an Apple sub, an email // address — the server never holds those anyway). type Anchor struct { ID string `json:"id"` CreatedAt int64 `json:"created_at"` } // Device is link-token metadata for the devices list — ids and timestamps // only, never token values (or anything derived from them). type Device struct { ID string `json:"id"` CreatedAt int64 `json:"created_at"` LastSeen int64 `json:"last_seen"` Current bool `json:"current,omitempty"` }