| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | |
| 6 | "github.com/go-webauthn/webauthn/webauthn" |
| 7 | ) |
| 8 | |
| 9 | // blobKeepVersions is how many blob versions the store retains per user — |
| 10 | // overwrite protection against a stolen link token trashing the backup (the |
| 11 | // token can write, but the previous versions survive until pushed out). |
| 12 | const blobKeepVersions = 5 |
| 13 | |
| 14 | // lastSeenGranularity is how stale a link token's last_seen must be before a |
| 15 | // lookup rewrites it (seconds). Day-coarse keeps the devices list honest while |
| 16 | // sparing an object-store backend a write per authenticated read. |
| 17 | const lastSeenGranularity = int64(24 * 60 * 60) |
| 18 | |
| 19 | // errBadVersion reports an If-Match version string the store cannot even |
| 20 | // interpret (as opposed to one that merely mismatches, which is a conflict). |
| 21 | // The handler maps it to 400; a mismatch maps to 409. |
| 22 | var errBadVersion = errors.New("bad version") |
| 23 | |
| 24 | // errAbsorbedHasBlob is mergeAccounts refusing to touch an account that still |
| 25 | // holds a vault blob — the belt under the merge handler's own precondition |
| 26 | // check (the client must move the vault contents over and delete the absorbed |
| 27 | // blob FIRST; see merge.go). The handler maps it to 409. |
| 28 | var errAbsorbedHasBlob = errors.New("absorbed account still has a blob") |
| 29 | |
| 30 | // vaultStore is home's persistence seam. Its method shapes deliberately mirror |
| 31 | // an object store — point reads by key, conditional writes (CAS on an opaque |
| 32 | // version), keep-last-N versioning, prefix deletes, and write-once pointers — |
| 33 | // so that a bucket can implement it directly (issue #550: blobs/<uid> for the |
| 34 | // vault, users/<uid> for metadata, creds/<credId> and tokens/<sha256> as |
| 35 | // pointers back to the uid). SQLite is implementation #1 and the zero-config |
| 36 | // default; the interface exists so an s3Store can be implementation #2 without |
| 37 | // the handlers changing. |
| 38 | // |
| 39 | // Versions are OPAQUE STRINGS at this boundary. The wire contract is already |
| 40 | // ETag-opaque; a backend may use integers, ETags, or generation numbers |
| 41 | // internally, but callers only ever echo a version they were handed — plus the |
| 42 | // one universal sentinel "0", meaning "no blob yet" (create-only). |
| 43 | // |
| 44 | // Pointers are never authoritative: a backend resolving a credential id or |
| 45 | // token hash must verify the target user still lists it, so a stale pointer |
| 46 | // left behind by a revocation denies by construction. In SQLite that falls out |
| 47 | // of single-table deletes; an object store must uphold it explicitly. |
| 48 | type vaultStore interface { |
| 49 | // ---- the vault blob (blobs/<uid>, versioned, CAS) ---- |
| 50 | |
| 51 | // getBlob is a point read of the latest version. found=false when the user |
| 52 | // has no blob. |
| 53 | getBlob(uid string) (data []byte, version string, found bool, err error) |
| 54 | // putBlob writes a new version iff ifMatch names the current one ("0" = |
| 55 | // create-only, i.e. no blob may exist yet). On mismatch it reports |
| 56 | // conflict=true and returns the CURRENT version ("0" when none exists) so |
| 57 | // the caller can 409 + ETag; the client merges and retries. Only the last |
| 58 | // blobKeepVersions versions are retained. An ifMatch the store cannot |
| 59 | // interpret at all returns errBadVersion. |
| 60 | putBlob(uid string, data []byte, ifMatch string) (version string, conflict bool, err error) |
| 61 | // listBlobVersions returns the retained version identifiers, newest first — |
| 62 | // the observable face of the keep-last-N contract. |
| 63 | listBlobVersions(uid string) ([]string, error) |
| 64 | // deleteBlob is the prefix delete: every retained version goes, history |
| 65 | // included — that's the point of the ceremony gate in front of it. |
| 66 | deleteBlob(uid string) error |
| 67 | |
| 68 | // ---- user metadata (users/<uid>) ---- |
| 69 | |
| 70 | createUser(uid string) error |
| 71 | // credentialsFor returns the user's WebAuthn credentials in enrolment order. |
| 72 | credentialsFor(uid string) ([]webauthn.Credential, error) |
| 73 | // insertCredential stores a verified credential and the creds/<credId> |
| 74 | // pointer back to its user. |
| 75 | insertCredential(uid string, cred *webauthn.Credential) error |
| 76 | // updateCredential persists post-ceremony state (sign count, backup flags), |
| 77 | // located by credential id. |
| 78 | updateCredential(cred *webauthn.Credential) error |
| 79 | // deleteCredential removes one passkey. It refuses to remove the account's |
| 80 | // LAST sign-in method (last=true, nothing deleted) — counting passkeys |
| 81 | // PLUS siwa/email anchors, so an account holding another anchor may shed |
| 82 | // its final passkey (#1089 reversed the passkey-only invariant), but no |
| 83 | // removal may ever leave zero ways in: the blob would be orphaned while |
| 84 | // staying live — the explicit wind-down path is the ceremony-gated blob |
| 85 | // delete. deleted=false with last=false means the id is unknown (or |
| 86 | // someone else's). |
| 87 | deleteCredential(uid, credID string) (deleted, last bool, err error) |
| 88 | // listCredentialMeta returns the user's passkeys as listing metadata — |
| 89 | // base64url credential id + enrolment time, enrolment order — what the |
| 90 | // anchors list (#1089) shows without hauling public keys to the client. |
| 91 | listCredentialMeta(uid string) ([]Anchor, error) |
| 92 | // credentialUser resolves the creds/<credId> pointer: which user enrolled |
| 93 | // this credential. ok=false for an unknown (or revoked) id. |
| 94 | credentialUser(credID string) (uid string, ok bool) |
| 95 | |
| 96 | // ---- device link tokens (tokens/<sha256> pointers) ---- |
| 97 | |
| 98 | // createLinkToken mints a bearer token for a user and returns the plaintext |
| 99 | // (shown/stored client-side once; only the hash persists in the store). |
| 100 | createLinkToken(uid string) (token string, err error) |
| 101 | // linkTokenUser resolves a token HASH to its user, bumping last_seen at |
| 102 | // most once per lastSeenGranularity (day-coarse — the devices list doesn't |
| 103 | // need better, and an object store shouldn't pay a write per read). |
| 104 | // Callers hash the presented plaintext (hashToken) — the store is keyed by |
| 105 | // hash and never sees a live token after minting. |
| 106 | linkTokenUser(tokenHash string) (uid string, ok bool) |
| 107 | // linkTokenID returns the device id behind a token hash, so the devices |
| 108 | // list can mark "this device". "" when unknown. |
| 109 | linkTokenID(tokenHash string) string |
| 110 | listDevices(uid string) ([]Device, error) |
| 111 | // deleteDevice revokes one of the user's link tokens. ok=false when the id |
| 112 | // doesn't exist or belongs to someone else. |
| 113 | deleteDevice(uid, id string) (ok bool, err error) |
| 114 | |
| 115 | // ---- Sign in with Apple anchors (siwa/<subHash> pointers) ---- |
| 116 | |
| 117 | // siwaUser resolves a hashed Apple sub (HMAC(pepper, sub), hex) to its |
| 118 | // user. ok=false when no account carries that anchor. |
| 119 | siwaUser(subHash string) (uid string, ok bool) |
| 120 | // insertSiwaAnchor stores the siwa/<subHash> pointer. Idempotent for the |
| 121 | // same user; conflict=true (nothing written) when the sub is already |
| 122 | // anchored to a DIFFERENT user — an Apple ID signs into one account. An |
| 123 | // anchor removed by deleteSiwaAnchor is genuinely gone: the same sub may |
| 124 | // anchor a different account afterwards. |
| 125 | insertSiwaAnchor(uid, subHash string) (conflict bool, err error) |
| 126 | // listSiwaAnchors returns the user's SiwA anchors (removal id + creation |
| 127 | // time), creation order. The id IS the stored sub hash — see the Anchor |
| 128 | // doc for why handing it to the account's own client is fine. |
| 129 | listSiwaAnchors(uid string) ([]Anchor, error) |
| 130 | // deleteSiwaAnchor removes one SiwA anchor, under the same last-method |
| 131 | // guard as deleteCredential (last=true, nothing deleted). deleted=false |
| 132 | // with last=false means the hash is unknown (or someone else's). |
| 133 | deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error) |
| 134 | |
| 135 | // ---- email anchors (emails/<blind index> pointers) ---- |
| 136 | |
| 137 | // emailUser resolves an address's blind index (HMAC(pepper, |
| 138 | // lowercase(email)), hex) to its user. ok=false when unanchored. |
| 139 | emailUser(index string) (uid string, ok bool) |
| 140 | // insertEmailAnchor stores the emails/<index> pointer. Idempotent for the |
| 141 | // same user; conflict=true (nothing written) when the address is already |
| 142 | // anchored to a DIFFERENT user. Like siwa, a removed anchor's index may |
| 143 | // re-anchor a different account later. |
| 144 | insertEmailAnchor(uid, index string) (conflict bool, err error) |
| 145 | // listEmailAnchors / deleteEmailAnchor mirror the siwa pair for the email |
| 146 | // blind indexes. |
| 147 | listEmailAnchors(uid string) ([]Anchor, error) |
| 148 | deleteEmailAnchor(uid, index string) (deleted, last bool, err error) |
| 149 | |
| 150 | // ---- OIDC provider anchors (oidc/<provider>/<subHash> pointers) ---- |
| 151 | // |
| 152 | // The siwa set generalized (oidc.go): provider is a fixed in-code name |
| 153 | // ("google", "microsoft" — oidcProviderNames), never input, and the hash |
| 154 | // is HMAC(that provider's pepper, sub). Apple deliberately stays on the |
| 155 | // siwa methods above — deployed data and wire behavior are untouched. |
| 156 | // Semantics match the siwa set one for one: insert is idempotent for the |
| 157 | // same user and conflict=true for a different one; a removed anchor's |
| 158 | // identifier may re-anchor a different account later; delete sits under |
| 159 | // the same last-method guard as every other sign-in method. |
| 160 | |
| 161 | oidcUser(provider, subHash string) (uid string, ok bool) |
| 162 | insertOidcAnchor(uid, provider, subHash string) (conflict bool, err error) |
| 163 | listOidcAnchors(uid, provider string) ([]Anchor, error) |
| 164 | deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) |
| 165 | |
| 166 | // ---- account merge (#1098) ---- |
| 167 | |
| 168 | // mergeAccounts folds the absorbed account into the survivor: EVERY |
| 169 | // sign-in anchor (siwa, email, oidc), passkey credential, and device link |
| 170 | // token moves from absorbed to survivor, then the absorbed user record is |
| 171 | // deleted. Identifiers cannot collide across accounts by construction — |
| 172 | // each anchor hash / credential id / token hash is a globally-unique |
| 173 | // pointer to exactly one account — so a move never conflicts. Link tokens |
| 174 | // move (rather than die) so the absorbed account's other devices keep |
| 175 | // working, now signed into the survivor. The op is atomic per backend |
| 176 | // (one transaction / a crash-safe write ordering); it refuses with |
| 177 | // errAbsorbedHasBlob while the absorbed account still holds a blob — the |
| 178 | // belt under the handler's own check, so a merge can never orphan vault |
| 179 | // data. An absorbed account that no longer exists is a zero-count |
| 180 | // success, which is what makes a retried merge (the ceremony proof |
| 181 | // outlives the deleted account by a few minutes) harmless. |
| 182 | mergeAccounts(survivor, absorbed string) (mergeMoved, error) |
| 183 | |
| 184 | // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| 185 | // tokens (stateless.go) — a store that persists nothing needs no methods. |
| 186 | } |
| 187 | |
| 188 | // mergeMoved reports what mergeAccounts relocated — the counts the merge |
| 189 | // endpoint answers with, so the client can say what happened in plain words. |
| 190 | type mergeMoved struct { |
| 191 | Anchors int `json:"anchors"` |
| 192 | Passkeys int `json:"passkeys"` |
| 193 | Devices int `json:"devices"` |
| 194 | } |
| 195 | |
| 196 | // Anchor is one sign-in method's listing metadata — an opaque, stable removal |
| 197 | // id plus creation time, nothing else (cf. Device). For siwa/email the id is |
| 198 | // the stored HMAC-peppered hash ITSELF: it reverses to nothing without the |
| 199 | // pepper, it never changes, and it's exactly the key removal needs — minting |
| 200 | // a surrogate id per anchor would add store state for zero privacy gain. |
| 201 | // What must never appear here is anything reversible (an Apple sub, an email |
| 202 | // address — the server never holds those anyway). |
| 203 | type Anchor struct { |
| 204 | ID string `json:"id"` |
| 205 | CreatedAt int64 `json:"created_at"` |
| 206 | } |
| 207 | |
| 208 | // Device is link-token metadata for the devices list — ids and timestamps |
| 209 | // only, never token values (or anything derived from them). |
| 210 | type Device struct { |
| 211 | ID string `json:"id"` |
| 212 | CreatedAt int64 `json:"created_at"` |
| 213 | LastSeen int64 `json:"last_seen"` |
| 214 | Current bool `json:"current,omitempty"` |
| 215 | } |
| 216 | |