eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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).
12const 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.
17const 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.
22var 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.
28var 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.
48type 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.
190type 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).
203type 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).
210type 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