eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– s3Store is vaultStore implementation #2 (issue #550 Phase 1): one S3
4// bucket as home's only database. The layout is the one the interface was
5// shaped for β€”
6//
7// blobs/<uid> the vault: ONE key, S3-native versioning; the
8// interface version is the ETag (quotes stripped)
9// users/<uid> one metadata JSON per user (credentials + devices),
10// every mutation an If-Match CAS loop
11// creds/<credId> write-once pointer object, body = uid
12// tokens/<sha256> write-once pointer object, body = uid
13//
14// β€” all under an optional key prefix so tests can isolate runs. Pointers are
15// never authoritative: resolving one always re-loads users/<uid> and verifies
16// the credential/token is still listed, so the stale pointer a revocation
17// leaves behind denies by construction (the vaultStore contract demands it).
18//
19// CAS semantics ride S3 conditional writes: If-Match for replace, the "0"
20// sentinel mapping to If-None-Match:* for create-only. S3 cannot distinguish
21// "a version string that was never ours" from a plain mismatch (both are just
22// non-matching ETags), so unlike SQLite there is no errBadVersion here β€” the
23// contract suite deliberately leaves that backend-specific.
24
25import (
26 "bytes"
27 "encoding/base64"
28 "encoding/json"
29 "encoding/xml"
30 "errors"
31 "fmt"
32 "io"
33 "net/http"
34 "net/url"
35 "os"
36 "strings"
37 "time"
38
39 "github.com/go-webauthn/webauthn/webauthn"
40)
41
42// ---- the S3 client ----------------------------------------------------------
43
44// s3Client is a minimal S3 REST client over the hand-rolled SigV4 signer
45// (sigv4.go): GET/PUT/HEAD/DELETE object (+versionId) and ListObjectVersions
46// are the whole surface home needs.
47type s3Client struct {
48 base *url.URL // https://<bucket>.s3.<region>.amazonaws.com, or the endpoint override
49 pathPrefix string // "" virtual-hosted; "/<bucket>" path-style under an endpoint override
50 region string
51 accessKey string
52 secretKey string
53 sessionKey string // AWS_SESSION_TOKEN, optional
54 hc *http.Client
55}
56
57type s3Result struct {
58 status int
59 header http.Header
60 body []byte
61}
62
63// do performs one signed S3 request, retrying once on a transient 5xx or
64// transport error. Non-2xx statuses are returned, not errors β€” callers map
65// 404/409/412 to their own semantics.
66func (c *s3Client) do(method, key string, query url.Values, hdrs map[string]string, body []byte) (*s3Result, error) {
67 var lastErr error
68 for attempt := 0; attempt < 2; attempt++ {
69 res, err := c.doOnce(method, key, query, hdrs, body)
70 if err == nil && res.status < 500 {
71 return res, nil
72 }
73 if err != nil {
74 lastErr = err
75 } else {
76 lastErr = fmt.Errorf("s3 %s %s: HTTP %d: %s", method, key, res.status, truncate(res.body))
77 }
78 }
79 return nil, lastErr
80}
81
82func (c *s3Client) doOnce(method, key string, query url.Values, hdrs map[string]string, body []byte) (*s3Result, error) {
83 full := c.base.Scheme + "://" + c.base.Host + c.pathPrefix + "/" + awsURIEncode(key, false)
84 if len(query) > 0 {
85 full += "?" + query.Encode()
86 }
87 req, err := http.NewRequest(method, full, bytes.NewReader(body))
88 if err != nil {
89 return nil, err
90 }
91 now := time.Now()
92 req.Header.Set("x-amz-date", now.UTC().Format("20060102T150405Z"))
93 payloadHash := emptyPayloadHash
94 if len(body) > 0 {
95 payloadHash = hexSHA256(body)
96 }
97 req.Header.Set("x-amz-content-sha256", payloadHash)
98 if c.sessionKey != "" {
99 req.Header.Set("x-amz-security-token", c.sessionKey)
100 }
101 for k, v := range hdrs {
102 req.Header.Set(k, v)
103 }
104 awsSignV4(req, c.accessKey, c.secretKey, c.region, "s3", now)
105 resp, err := c.hc.Do(req)
106 if err != nil {
107 return nil, err
108 }
109 defer resp.Body.Close()
110 b, err := io.ReadAll(resp.Body)
111 if err != nil {
112 return nil, err
113 }
114 return &s3Result{status: resp.StatusCode, header: resp.Header, body: b}, nil
115}
116
117// truncate keeps error messages readable β€” S3 XML errors are small but there's
118// no need to echo a whole response body into a log line.
119func truncate(b []byte) string {
120 s := strings.TrimSpace(string(b))
121 if len(s) > 300 {
122 s = s[:300] + "…"
123 }
124 return s
125}
126
127// etagOf strips the quotes S3 puts around ETags; the vaultStore version is the
128// bare value (the HTTP handlers re-add quotes for the wire).
129func etagOf(h http.Header) string {
130 return strings.Trim(h.Get("ETag"), `"`)
131}
132
133// ---- the store ----------------------------------------------------------------
134
135type s3Store struct {
136 c *s3Client
137 prefix string // key prefix incl. trailing "/", may be ""
138}
139
140var _ vaultStore = (*s3Store)(nil)
141
142// newS3Store builds an s3Store for bucket/region, keys under prefix.
143// Credentials come from the standard AWS env (AWS_ACCESS_KEY_ID,
144// AWS_SECRET_ACCESS_KEY, optional AWS_SESSION_TOKEN) at construction β€”
145// fail-fast, so a half-configured deployment can't limp along storeless.
146// endpoint overrides the AWS URL for S3-compatible stores (path-style).
147func newS3Store(bucket, region, prefix, endpoint string) (*s3Store, error) {
148 if bucket == "" || region == "" {
149 return nil, errors.New("s3 store: bucket and region are required")
150 }
151 access, secret := os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY")
152 if access == "" || secret == "" {
153 return nil, errors.New("s3 store: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set")
154 }
155 base := "https://" + bucket + ".s3." + region + ".amazonaws.com"
156 pathPrefix := ""
157 if endpoint != "" {
158 base = strings.TrimSuffix(endpoint, "/")
159 pathPrefix = "/" + bucket
160 }
161 u, err := url.Parse(base)
162 if err != nil {
163 return nil, fmt.Errorf("s3 store: bad endpoint: %w", err)
164 }
165 if prefix != "" && !strings.HasSuffix(prefix, "/") {
166 prefix += "/"
167 }
168 return &s3Store{
169 c: &s3Client{
170 base: u,
171 pathPrefix: pathPrefix,
172 region: region,
173 accessKey: access,
174 secretKey: secret,
175 sessionKey: os.Getenv("AWS_SESSION_TOKEN"),
176 hc: &http.Client{Timeout: 30 * time.Second},
177 },
178 prefix: prefix,
179 }, nil
180}
181
182func (s *s3Store) blobKey(uid string) string { return s.prefix + "blobs/" + uid }
183func (s *s3Store) userKey(uid string) string { return s.prefix + "users/" + uid }
184func (s *s3Store) credKey(id string) string { return s.prefix + "creds/" + id }
185func (s *s3Store) tokenKey(h string) string { return s.prefix + "tokens/" + h }
186func (s *s3Store) siwaKey(h string) string { return s.prefix + "siwa/" + h }
187func (s *s3Store) emailKey(h string) string { return s.prefix + "emails/" + h }
188
189// oidcKey namespaces per provider β€” the pointer-prefix twin of the
190// oidc_anchors (provider, sub_hash) key. provider is always an in-code name
191// (oidcProviderNames), never input.
192func (s *s3Store) oidcKey(provider, h string) string {
193 return s.prefix + "oidc/" + provider + "/" + h
194}
195
196// ---- the blob -----------------------------------------------------------------
197
198func (s *s3Store) getBlob(uid string) ([]byte, string, bool, error) {
199 res, err := s.c.do("GET", s.blobKey(uid), nil, nil, nil)
200 if err != nil {
201 return nil, "", false, err
202 }
203 switch res.status {
204 case http.StatusOK:
205 return res.body, etagOf(res.header), true, nil
206 case http.StatusNotFound:
207 return nil, "", false, nil
208 default:
209 return nil, "", false, fmt.Errorf("s3 get blob: HTTP %d: %s", res.status, truncate(res.body))
210 }
211}
212
213// putBlob maps the interface CAS onto S3 conditional writes. ifMatch "0" β†’
214// If-None-Match:* (create-only); anything else β†’ If-Match. On 412 (mismatch),
215// 404 (If-Match against a missing key) or 409 (a concurrent conditional write
216// in flight) the write lost: HEAD the key for the CURRENT ETag and report a
217// conflict β€” the current version being "0" when nothing exists β€” mirroring
218// sqliteStore. Old versions aren't pruned here: the bucket lifecycle expires
219// noncurrent versions asynchronously, and listBlobVersions caps at
220// blobKeepVersions so the keep-last-N contract holds at the interface while
221// S3 catches up.
222func (s *s3Store) putBlob(uid string, data []byte, ifMatch string) (string, bool, error) {
223 hdrs := map[string]string{"Content-Type": "application/octet-stream"}
224 if ifMatch == "0" {
225 hdrs["If-None-Match"] = "*"
226 } else {
227 hdrs["If-Match"] = `"` + ifMatch + `"`
228 }
229 res, err := s.c.do("PUT", s.blobKey(uid), nil, hdrs, data)
230 if err != nil {
231 return "", false, err
232 }
233 switch res.status {
234 case http.StatusOK:
235 return etagOf(res.header), false, nil
236 case http.StatusNotFound, http.StatusConflict, http.StatusPreconditionFailed:
237 cur, found, err := s.headETag(s.blobKey(uid))
238 if err != nil {
239 return "", false, err
240 }
241 if !found {
242 return "0", true, nil
243 }
244 return cur, true, nil
245 default:
246 return "", false, fmt.Errorf("s3 put blob: HTTP %d: %s", res.status, truncate(res.body))
247 }
248}
249
250func (s *s3Store) headETag(key string) (string, bool, error) {
251 res, err := s.c.do("HEAD", key, nil, nil, nil)
252 if err != nil {
253 return "", false, err
254 }
255 switch res.status {
256 case http.StatusOK:
257 return etagOf(res.header), true, nil
258 case http.StatusNotFound:
259 return "", false, nil
260 default:
261 return "", false, fmt.Errorf("s3 head %s: HTTP %d", key, res.status)
262 }
263}
264
265// listBlobVersions lists the blob key's S3 versions (newest first, as
266// ListObjectVersions returns them) and reports their ETags, capped at
267// blobKeepVersions: the lifecycle rule prunes noncurrent versions days later,
268// so the cap β€” not the bucket β€” is what keeps the keep-last-N contract
269// observable at the interface in the meantime.
270func (s *s3Store) listBlobVersions(uid string) ([]string, error) {
271 versions, err := s.listVersions(s.blobKey(uid))
272 if err != nil {
273 return nil, err
274 }
275 out := []string{}
276 for _, v := range versions {
277 if v.deleteMarker || v.key != s.blobKey(uid) {
278 continue
279 }
280 out = append(out, strings.Trim(v.etag, `"`))
281 if len(out) == blobKeepVersions {
282 break
283 }
284 }
285 return out, nil
286}
287
288// deleteBlob permanently deletes EVERY version of the blob key (delete by
289// versionId, no delete markers), so the user is genuinely back at the "0"
290// sentinel and a later create-only write succeeds β€” the contract's
291// delete-then-recreate path.
292func (s *s3Store) deleteBlob(uid string) error {
293 key := s.blobKey(uid)
294 versions, err := s.listVersions(key)
295 if err != nil {
296 return err
297 }
298 for _, v := range versions {
299 if v.key != key {
300 continue
301 }
302 res, err := s.c.do("DELETE", key, url.Values{"versionId": {v.versionID}}, nil, nil)
303 if err != nil {
304 return err
305 }
306 if res.status != http.StatusNoContent && res.status != http.StatusOK {
307 return fmt.Errorf("s3 delete version: HTTP %d: %s", res.status, truncate(res.body))
308 }
309 }
310 return nil
311}
312
313// s3Version is one row of ListObjectVersions β€” a real version or a delete
314// marker (we never create markers, but a defensive parse costs nothing).
315type s3Version struct {
316 key string
317 versionID string
318 etag string
319 deleteMarker bool
320}
321
322// listVersions pages through ListObjectVersions for everything under prefix.
323// Within one key S3 returns versions newest first; that ordering is what
324// listBlobVersions relies on.
325func (s *s3Store) listVersions(prefix string) ([]s3Version, error) {
326 type xmlVersion struct {
327 Key string `xml:"Key"`
328 VersionID string `xml:"VersionId"`
329 ETag string `xml:"ETag"`
330 }
331 type xmlResult struct {
332 IsTruncated bool `xml:"IsTruncated"`
333 NextKeyMarker string `xml:"NextKeyMarker"`
334 NextVersionIDMarker string `xml:"NextVersionIdMarker"`
335 Versions []xmlVersion `xml:"Version"`
336 DeleteMarkers []xmlVersion `xml:"DeleteMarker"`
337 }
338 var out []s3Version
339 q := url.Values{"versions": {""}, "prefix": {prefix}}
340 for {
341 res, err := s.c.do("GET", "", q, nil, nil)
342 if err != nil {
343 return nil, err
344 }
345 if res.status != http.StatusOK {
346 return nil, fmt.Errorf("s3 list versions: HTTP %d: %s", res.status, truncate(res.body))
347 }
348 var page xmlResult
349 if err := xml.Unmarshal(res.body, &page); err != nil {
350 return nil, fmt.Errorf("s3 list versions: %w", err)
351 }
352 for _, v := range page.Versions {
353 out = append(out, s3Version{key: v.Key, versionID: v.VersionID, etag: v.ETag})
354 }
355 for _, v := range page.DeleteMarkers {
356 out = append(out, s3Version{key: v.Key, versionID: v.VersionID, deleteMarker: true})
357 }
358 if !page.IsTruncated {
359 return out, nil
360 }
361 q.Set("key-marker", page.NextKeyMarker)
362 q.Set("version-id-marker", page.NextVersionIDMarker)
363 }
364}
365
366// ---- user metadata (users/<uid>) -----------------------------------------------
367
368// s3UserDoc is the one metadata JSON per user. Array order is enrolment /
369// creation order β€” the contract's ordering guarantee.
370type s3UserDoc struct {
371 Credentials []s3Credential `json:"credentials"`
372 Devices []s3Device `json:"devices"`
373 // SiwaAnchors lists the account's Sign in with Apple sub hashes β€” the
374 // doc-side half of the siwa/<subHash> pointers (pointers never decide;
375 // see the vaultStore contract).
376 SiwaAnchors []s3SiwaAnchor `json:"siwa_anchors,omitempty"`
377 // EmailAnchors mirrors SiwaAnchors for the email blind indexes.
378 EmailAnchors []s3EmailAnchor `json:"email_anchors,omitempty"`
379 // OidcAnchors mirrors SiwaAnchors for the newer provider doors (google,
380 // microsoft β€” oidc.go), with the provider name beside each hash; the
381 // pointer half lives at oidc/<provider>/<hash>. Apple stays in
382 // SiwaAnchors.
383 OidcAnchors []s3OidcAnchor `json:"oidc_anchors,omitempty"`
384}
385
386// s3Credential wraps webauthn.Credential with its enrolment time β€” the
387// listing metadata the anchors surface shows (#1089). The embedded
388// credential's fields inline in the JSON, so this is additive on the wire:
389// docs written before this field unmarshal with CreatedAt 0 (the client
390// renders "unknown"), and older readers ignore the extra key.
391type s3Credential struct {
392 webauthn.Credential
393 CreatedAt int64 `json:"created_at,omitempty"`
394}
395
396type s3SiwaAnchor struct {
397 SubHash string `json:"sub_hash"`
398 CreatedAt int64 `json:"created_at"`
399}
400
401type s3EmailAnchor struct {
402 EmailHash string `json:"email_hash"`
403 CreatedAt int64 `json:"created_at"`
404}
405
406type s3OidcAnchor struct {
407 Provider string `json:"provider"`
408 SubHash string `json:"sub_hash"`
409 CreatedAt int64 `json:"created_at"`
410}
411
412type s3Device struct {
413 ID string `json:"id"`
414 TokenHash string `json:"token_hash"`
415 CreatedAt int64 `json:"created_at"`
416 LastSeen int64 `json:"last_seen"`
417}
418
419func (s *s3Store) loadUser(uid string) (doc s3UserDoc, etag string, found bool, err error) {
420 res, err := s.c.do("GET", s.userKey(uid), nil, nil, nil)
421 if err != nil {
422 return doc, "", false, err
423 }
424 switch res.status {
425 case http.StatusOK:
426 if err := json.Unmarshal(res.body, &doc); err != nil {
427 return doc, "", false, fmt.Errorf("s3 user doc: %w", err)
428 }
429 return doc, etagOf(res.header), true, nil
430 case http.StatusNotFound:
431 return doc, "", false, nil
432 default:
433 return doc, "", false, fmt.Errorf("s3 get user: HTTP %d: %s", res.status, truncate(res.body))
434 }
435}
436
437// updateUser is the metadata CAS loop: GET (capture ETag) β†’ mutate β†’ PUT
438// If-Match (If-None-Match:* when the doc doesn't exist yet), retried on a lost
439// race. mutate returning write=false means "nothing to change" β€” the loop
440// exits without a write, which is also how mutations against a nonexistent
441// user fall through as no-ops (mirroring SQLite's zero-rows-affected).
442func (s *s3Store) updateUser(uid string, mutate func(doc *s3UserDoc) (write bool, err error)) error {
443 for attempt := 0; attempt < 5; attempt++ {
444 doc, etag, found, err := s.loadUser(uid)
445 if err != nil {
446 return err
447 }
448 write, err := mutate(&doc)
449 if err != nil {
450 return err
451 }
452 if !write {
453 return nil
454 }
455 body, err := json.Marshal(doc)
456 if err != nil {
457 return err
458 }
459 hdrs := map[string]string{"Content-Type": "application/json"}
460 if found {
461 hdrs["If-Match"] = `"` + etag + `"`
462 } else {
463 hdrs["If-None-Match"] = "*"
464 }
465 res, err := s.c.do("PUT", s.userKey(uid), nil, hdrs, body)
466 if err != nil {
467 return err
468 }
469 switch res.status {
470 case http.StatusOK:
471 return nil
472 case http.StatusNotFound, http.StatusConflict, http.StatusPreconditionFailed:
473 continue // another writer won; reload and re-apply
474 default:
475 return fmt.Errorf("s3 put user: HTTP %d: %s", res.status, truncate(res.body))
476 }
477 }
478 return errors.New("s3 user doc: CAS retries exhausted")
479}
480
481// createUser writes an empty metadata doc, create-only. "Already exists" is
482// success: uids are long random strings, so a duplicate create can only be a
483// retry of our own request β€” erroring would just make registration flaky.
484func (s *s3Store) createUser(uid string) error {
485 body, err := json.Marshal(s3UserDoc{Credentials: []s3Credential{}, Devices: []s3Device{}})
486 if err != nil {
487 return err
488 }
489 hdrs := map[string]string{"If-None-Match": "*", "Content-Type": "application/json"}
490 res, err := s.c.do("PUT", s.userKey(uid), nil, hdrs, body)
491 if err != nil {
492 return err
493 }
494 switch res.status {
495 case http.StatusOK, http.StatusConflict, http.StatusPreconditionFailed:
496 return nil
497 default:
498 return fmt.Errorf("s3 create user: HTTP %d: %s", res.status, truncate(res.body))
499 }
500}
501
502// ---- pointer objects ------------------------------------------------------------
503
504// putPointer writes a write-once pointer object (body = uid). A pointer that
505// already exists is fine iff it names the same user β€” that's a retry of our
506// own write; a different owner would be a random-collision-sized bug, so it
507// errors loudly.
508func (s *s3Store) putPointer(key, uid string) error {
509 res, err := s.c.do("PUT", key, nil, map[string]string{"If-None-Match": "*"}, []byte(uid))
510 if err != nil {
511 return err
512 }
513 switch res.status {
514 case http.StatusOK:
515 return nil
516 case http.StatusConflict, http.StatusPreconditionFailed:
517 cur, found, err := s.getPointer(key)
518 if err != nil {
519 return err
520 }
521 if found && cur == uid {
522 return nil
523 }
524 return fmt.Errorf("s3 pointer %s: already owned by another user", key)
525 default:
526 return fmt.Errorf("s3 put pointer: HTTP %d: %s", res.status, truncate(res.body))
527 }
528}
529
530func (s *s3Store) getPointer(key string) (string, bool, error) {
531 res, err := s.c.do("GET", key, nil, nil, nil)
532 if err != nil {
533 return "", false, err
534 }
535 switch res.status {
536 case http.StatusOK:
537 return string(res.body), true, nil
538 case http.StatusNotFound:
539 return "", false, nil
540 default:
541 return "", false, fmt.Errorf("s3 get pointer: HTTP %d: %s", res.status, truncate(res.body))
542 }
543}
544
545// repointPointer unconditionally overwrites a pointer object's body with a new
546// uid β€” the account-merge move (#1098). Unlike putPointer there is no
547// write-once condition: the pointer is KNOWN to exist and name the absorbed
548// account, and pointers are never authoritative anyway β€” the doc-verify at
549// resolution is what decides, so an overwrite can never grant access the docs
550// don't back.
551func (s *s3Store) repointPointer(key, uid string) error {
552 res, err := s.c.do("PUT", key, nil, nil, []byte(uid))
553 if err != nil {
554 return err
555 }
556 if res.status != http.StatusOK {
557 return fmt.Errorf("s3 repoint pointer: HTTP %d: %s", res.status, truncate(res.body))
558 }
559 return nil
560}
561
562// deletePointer removes a pointer object; 404 counts as success (a retried
563// delete, or one never written). On a versioned bucket this lays a delete
564// marker, which is all that's needed: GETs answer 404 and a later write-once
565// claim of the same key succeeds again.
566func (s *s3Store) deletePointer(key string) error {
567 res, err := s.c.do("DELETE", key, nil, nil, nil)
568 if err != nil {
569 return err
570 }
571 switch res.status {
572 case http.StatusNoContent, http.StatusOK, http.StatusNotFound:
573 return nil
574 default:
575 return fmt.Errorf("s3 delete pointer: HTTP %d: %s", res.status, truncate(res.body))
576 }
577}
578
579// ---- credentials ------------------------------------------------------------------
580
581func b64CredIDOf(cred *webauthn.Credential) string {
582 return base64.RawURLEncoding.EncodeToString(cred.ID)
583}
584
585// insertCredential writes the pointer FIRST, then the doc: a pointer without
586// a doc entry denies harmlessly (pointers are never authoritative), whereas a
587// doc entry without a pointer would silently break login by credential id.
588func (s *s3Store) insertCredential(uid string, cred *webauthn.Credential) error {
589 if err := s.putPointer(s.credKey(b64CredIDOf(cred)), uid); err != nil {
590 return err
591 }
592 now := time.Now().Unix()
593 return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
594 doc.Credentials = append(doc.Credentials, s3Credential{Credential: *cred, CreatedAt: now})
595 return true, nil
596 })
597}
598
599// updateCredential locates the owner via the pointer (the method has no uid,
600// mirroring SQLite's UPDATE ... WHERE cred_id). An unknown id is a silent
601// no-op, exactly like an UPDATE that matches zero rows.
602func (s *s3Store) updateCredential(cred *webauthn.Credential) error {
603 credID := b64CredIDOf(cred)
604 uid, found, err := s.getPointer(s.credKey(credID))
605 if err != nil || !found {
606 return err
607 }
608 return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
609 for i := range doc.Credentials {
610 if bytes.Equal(doc.Credentials[i].ID, cred.ID) {
611 doc.Credentials[i].Credential = *cred // CreatedAt stays put
612 return true, nil
613 }
614 }
615 return false, nil
616 })
617}
618
619func (s *s3Store) credentialsFor(uid string) ([]webauthn.Credential, error) {
620 doc, _, _, err := s.loadUser(uid)
621 if err != nil {
622 return nil, err
623 }
624 out := []webauthn.Credential{}
625 for _, c := range doc.Credentials {
626 out = append(out, c.Credential)
627 }
628 return out, nil
629}
630
631// listCredentialMeta returns passkey listing metadata from the user doc β€”
632// base64url id + enrolment time, enrolment order (like credentialsFor, but
633// without hauling public keys anywhere).
634func (s *s3Store) listCredentialMeta(uid string) ([]Anchor, error) {
635 doc, _, _, err := s.loadUser(uid)
636 if err != nil {
637 return nil, err
638 }
639 out := []Anchor{}
640 for _, c := range doc.Credentials {
641 out = append(out, Anchor{ID: b64CredIDOf(&c.Credential), CreatedAt: c.CreatedAt})
642 }
643 return out, nil
644}
645
646// credentialUser resolves the creds/<credId> pointer and then VERIFIES the
647// credential is still listed in users/<uid> β€” the pointer alone never grants:
648// a revoked credential's stale pointer must deny.
649func (s *s3Store) credentialUser(credID string) (string, bool) {
650 uid, found, err := s.getPointer(s.credKey(credID))
651 if err != nil || !found {
652 return "", false
653 }
654 doc, _, found, err := s.loadUser(uid)
655 if err != nil || !found {
656 return "", false
657 }
658 for _, c := range doc.Credentials {
659 if base64.RawURLEncoding.EncodeToString(c.ID) == credID {
660 return uid, true
661 }
662 }
663 return "", false
664}
665
666// deleteCredential removes the credential from the user doc only; the stale
667// creds/ pointer stays behind, harmless because resolution re-checks the doc.
668// The last-METHOD guard (passkeys + siwa + email anchors, #1089) is atomic
669// here because the whole doc updates under one CAS β€” same guarantee SQLite
670// gets from its transaction.
671func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err error) {
672 err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
673 deleted, last = false, false
674 idx := -1
675 for i, c := range doc.Credentials {
676 if base64.RawURLEncoding.EncodeToString(c.ID) == credID {
677 idx = i
678 break
679 }
680 }
681 if idx < 0 {
682 return false, nil
683 }
684 if signInMethodsDoc(doc) == 1 {
685 last = true
686 return false, nil
687 }
688 doc.Credentials = append(doc.Credentials[:idx], doc.Credentials[idx+1:]...)
689 deleted = true
690 return true, nil
691 })
692 return deleted, last, err
693}
694
695// signInMethodsDoc counts every way into the account within one loaded user
696// doc β€” the doc-CAS twin of SQLite's in-transaction signInMethods.
697func signInMethodsDoc(doc *s3UserDoc) int {
698 return len(doc.Credentials) + len(doc.SiwaAnchors) + len(doc.EmailAnchors) + len(doc.OidcAnchors)
699}
700
701// ---- device link tokens --------------------------------------------------------
702
703// createLinkToken mints the token, then pointer-first like insertCredential:
704// an orphaned tokens/ pointer denies; a device entry without its pointer
705// would be an unusable token.
706func (s *s3Store) createLinkToken(uid string) (string, error) {
707 tok, err := randToken()
708 if err != nil {
709 return "", err
710 }
711 id, err := randToken()
712 if err != nil {
713 return "", err
714 }
715 hash := hashToken(tok)
716 if err := s.putPointer(s.tokenKey(hash), uid); err != nil {
717 return "", err
718 }
719 now := time.Now().Unix()
720 err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
721 doc.Devices = append(doc.Devices, s3Device{ID: id, TokenHash: hash, CreatedAt: now, LastSeen: now})
722 return true, nil
723 })
724 if err != nil {
725 return "", err
726 }
727 return tok, nil
728}
729
730// siwaUser resolves siwa/<subHash> and verifies the anchor is still listed in
731// the user doc β€” a stale pointer left by a future unlink denies by
732// construction, per the vaultStore contract.
733func (s *s3Store) siwaUser(subHash string) (string, bool) {
734 uid, found, err := s.getPointer(s.siwaKey(subHash))
735 if err != nil || !found {
736 return "", false
737 }
738 doc, _, found, err := s.loadUser(uid)
739 if err != nil || !found {
740 return "", false
741 }
742 for _, a := range doc.SiwaAnchors {
743 if a.SubHash == subHash {
744 return uid, true
745 }
746 }
747 return "", false
748}
749
750// insertSiwaAnchor writes pointer first, doc entry second (same ordering
751// rationale as createLinkToken: an orphaned pointer denies; a doc entry
752// without its pointer would be an unusable anchor). The conflict check is
753// DOC-verified (siwaUser), not pointer-only: an unlink (deleteSiwaAnchor)
754// leaves a stale pointer behind when its cleanup half fails, and that must
755// not stop the same Apple ID anchoring a different account later β€” a stale
756// pointer is swept here so the write-once claim below can land.
757func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) {
758 if err := s.sweepAnchorPointer(s.siwaKey(subHash), uid, s.siwaUser, subHash); err != nil {
759 if err == errAnchorTaken {
760 return true, nil
761 }
762 return false, err
763 }
764 if err := s.putPointer(s.siwaKey(subHash), uid); err != nil {
765 return false, err
766 }
767 now := time.Now().Unix()
768 err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
769 for _, a := range doc.SiwaAnchors {
770 if a.SubHash == subHash {
771 return false, nil // already listed β€” idempotent
772 }
773 }
774 doc.SiwaAnchors = append(doc.SiwaAnchors, s3SiwaAnchor{SubHash: subHash, CreatedAt: now})
775 return true, nil
776 })
777 if err != nil {
778 return false, err
779 }
780 return false, nil
781}
782
783// errAnchorTaken is sweepAnchorPointer's "anchored to a DIFFERENT user"
784// signal β€” the callers' conflict=true.
785var errAnchorTaken = errors.New("anchor taken")
786
787// sweepAnchorPointer prepares an anchor key for a write-once claim by uid:
788// errAnchorTaken when the identifier is live on another account (doc-verified
789// via resolve β€” the pointer alone never decides). A leftover pointer that no
790// doc lists any more is deleted so the claim can land; a live pointer that
791// already names uid is left alone (putPointer treats a same-owner re-claim as
792// success, and deleting it would open a needless deny window).
793func (s *s3Store) sweepAnchorPointer(key, uid string, resolve func(string) (string, bool), hash string) error {
794 if owner, ok := resolve(hash); ok {
795 if owner != uid {
796 return errAnchorTaken
797 }
798 return nil
799 }
800 if _, found, err := s.getPointer(key); err != nil {
801 return err
802 } else if found {
803 return s.deletePointer(key)
804 }
805 return nil
806}
807
808func (s *s3Store) listSiwaAnchors(uid string) ([]Anchor, error) {
809 doc, _, _, err := s.loadUser(uid)
810 if err != nil {
811 return nil, err
812 }
813 out := []Anchor{}
814 for _, a := range doc.SiwaAnchors {
815 out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt})
816 }
817 return out, nil
818}
819
820// deleteSiwaAnchor removes the anchor from the user doc β€” under the same
821// one-CAS last-method guard as deleteCredential β€” then clears the siwa/
822// pointer best-effort. A pointer a failed cleanup leaves behind still denies
823// at resolution (siwaUser re-checks the doc), and the next link of the same
824// sub sweeps it (sweepAnchorPointer), so the miss costs nothing but a
825// dangling object.
826func (s *s3Store) deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error) {
827 err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
828 deleted, last = false, false
829 idx := -1
830 for i, a := range doc.SiwaAnchors {
831 if a.SubHash == subHash {
832 idx = i
833 break
834 }
835 }
836 if idx < 0 {
837 return false, nil
838 }
839 if signInMethodsDoc(doc) == 1 {
840 last = true
841 return false, nil
842 }
843 doc.SiwaAnchors = append(doc.SiwaAnchors[:idx], doc.SiwaAnchors[idx+1:]...)
844 deleted = true
845 return true, nil
846 })
847 if err == nil && deleted {
848 _ = s.deletePointer(s.siwaKey(subHash)) // best-effort; see doc comment
849 }
850 return deleted, last, err
851}
852
853// emailUser / insertEmailAnchor mirror the siwa pair exactly β€” pointer plus
854// doc entry, pointer never decides.
855func (s *s3Store) emailUser(index string) (string, bool) {
856 uid, found, err := s.getPointer(s.emailKey(index))
857 if err != nil || !found {
858 return "", false
859 }
860 doc, _, found, err := s.loadUser(uid)
861 if err != nil || !found {
862 return "", false
863 }
864 for _, a := range doc.EmailAnchors {
865 if a.EmailHash == index {
866 return uid, true
867 }
868 }
869 return "", false
870}
871
872func (s *s3Store) insertEmailAnchor(uid, index string) (bool, error) {
873 if err := s.sweepAnchorPointer(s.emailKey(index), uid, s.emailUser, index); err != nil {
874 if err == errAnchorTaken {
875 return true, nil
876 }
877 return false, err
878 }
879 if err := s.putPointer(s.emailKey(index), uid); err != nil {
880 return false, err
881 }
882 now := time.Now().Unix()
883 err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
884 for _, a := range doc.EmailAnchors {
885 if a.EmailHash == index {
886 return false, nil
887 }
888 }
889 doc.EmailAnchors = append(doc.EmailAnchors, s3EmailAnchor{EmailHash: index, CreatedAt: now})
890 return true, nil
891 })
892 if err != nil {
893 return false, err
894 }
895 return false, nil
896}
897
898func (s *s3Store) listEmailAnchors(uid string) ([]Anchor, error) {
899 doc, _, _, err := s.loadUser(uid)
900 if err != nil {
901 return nil, err
902 }
903 out := []Anchor{}
904 for _, a := range doc.EmailAnchors {
905 out = append(out, Anchor{ID: a.EmailHash, CreatedAt: a.CreatedAt})
906 }
907 return out, nil
908}
909
910// deleteEmailAnchor mirrors deleteSiwaAnchor exactly β€” one-CAS last-method
911// guard, then best-effort pointer cleanup.
912func (s *s3Store) deleteEmailAnchor(uid, index string) (deleted, last bool, err error) {
913 err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
914 deleted, last = false, false
915 idx := -1
916 for i, a := range doc.EmailAnchors {
917 if a.EmailHash == index {
918 idx = i
919 break
920 }
921 }
922 if idx < 0 {
923 return false, nil
924 }
925 if signInMethodsDoc(doc) == 1 {
926 last = true
927 return false, nil
928 }
929 doc.EmailAnchors = append(doc.EmailAnchors[:idx], doc.EmailAnchors[idx+1:]...)
930 deleted = true
931 return true, nil
932 })
933 if err == nil && deleted {
934 _ = s.deletePointer(s.emailKey(index)) // best-effort; see deleteSiwaAnchor
935 }
936 return deleted, last, err
937}
938
939// The OIDC anchor methods (oidc.go's provider table) mirror the siwa set
940// exactly β€” pointer plus doc entry, pointer never decides, stale-pointer
941// sweep on insert, one-CAS last-method guard on delete β€” with the provider
942// name carried beside the hash in both halves.
943
944func (s *s3Store) oidcUser(provider, subHash string) (string, bool) {
945 uid, found, err := s.getPointer(s.oidcKey(provider, subHash))
946 if err != nil || !found {
947 return "", false
948 }
949 doc, _, found, err := s.loadUser(uid)
950 if err != nil || !found {
951 return "", false
952 }
953 for _, a := range doc.OidcAnchors {
954 if a.Provider == provider && a.SubHash == subHash {
955 return uid, true
956 }
957 }
958 return "", false
959}
960
961func (s *s3Store) insertOidcAnchor(uid, provider, subHash string) (bool, error) {
962 resolve := func(h string) (string, bool) { return s.oidcUser(provider, h) }
963 if err := s.sweepAnchorPointer(s.oidcKey(provider, subHash), uid, resolve, subHash); err != nil {
964 if err == errAnchorTaken {
965 return true, nil
966 }
967 return false, err
968 }
969 if err := s.putPointer(s.oidcKey(provider, subHash), uid); err != nil {
970 return false, err
971 }
972 now := time.Now().Unix()
973 err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
974 for _, a := range doc.OidcAnchors {
975 if a.Provider == provider && a.SubHash == subHash {
976 return false, nil // already listed β€” idempotent
977 }
978 }
979 doc.OidcAnchors = append(doc.OidcAnchors, s3OidcAnchor{Provider: provider, SubHash: subHash, CreatedAt: now})
980 return true, nil
981 })
982 if err != nil {
983 return false, err
984 }
985 return false, nil
986}
987
988func (s *s3Store) listOidcAnchors(uid, provider string) ([]Anchor, error) {
989 doc, _, _, err := s.loadUser(uid)
990 if err != nil {
991 return nil, err
992 }
993 out := []Anchor{}
994 for _, a := range doc.OidcAnchors {
995 if a.Provider == provider {
996 out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt})
997 }
998 }
999 return out, nil
1000}
1001
1002func (s *s3Store) deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) {
1003 err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
1004 deleted, last = false, false
1005 idx := -1
1006 for i, a := range doc.OidcAnchors {
1007 if a.Provider == provider && a.SubHash == subHash {
1008 idx = i
1009 break
1010 }
1011 }
1012 if idx < 0 {
1013 return false, nil
1014 }
1015 if signInMethodsDoc(doc) == 1 {
1016 last = true
1017 return false, nil
1018 }
1019 doc.OidcAnchors = append(doc.OidcAnchors[:idx], doc.OidcAnchors[idx+1:]...)
1020 deleted = true
1021 return true, nil
1022 })
1023 if err == nil && deleted {
1024 _ = s.deletePointer(s.oidcKey(provider, subHash)) // best-effort; see deleteSiwaAnchor
1025 }
1026 return deleted, last, err
1027}
1028
1029// mergeAccounts (#1098) folds the absorbed account into the survivor. There is
1030// no cross-doc transaction on S3, so the write ORDER is what makes a crash at
1031// any step resolve safely (same discipline as createLinkToken's pointer-first
1032// rule; every step is idempotent, so a retried merge re-walks the sequence):
1033//
1034// 1. The SURVIVOR doc gains every absorbed entry first (one CAS update,
1035// deduped by each entry's key so a retry appends nothing twice). Crash
1036// after this: pointers still name the absorbed account and its doc still
1037// lists everything, so every anchor/token resolves exactly as before β€”
1038// the survivor's extra entries are inert (nothing points at them yet).
1039// 2. Every pointer repoints to the survivor (unconditional overwrite β€”
1040// repointPointer). Crash mid-way: a repointed key resolves via
1041// pointer→survivor-doc (which lists it, thanks to step 1); an untouched
1042// key still resolves via absorbed. Either way the doc-verify contract
1043// holds and nothing is denied or misgranted.
1044// 3. The ABSORBED doc is deleted LAST. Deleting it any earlier would kill
1045// resolution for every not-yet-repointed key (pointer→absorbed→gone =
1046// deny); last, it only retires listings nothing points at any more. A
1047// lingering absorbed doc after a crash is harmless for the same reason β€”
1048// pointers all name the survivor β€” and the retry deletes it.
1049//
1050// The blob check up front is the store-side belt for the handler's
1051// precondition (the client empties the absorbed vault first); an absorbed
1052// account with no doc is a zero-count success β€” that's the post-completion
1053// retry, which must not error while the absorb proof is still live.
1054func (s *s3Store) mergeAccounts(survivor, absorbed string) (mergeMoved, error) {
1055 var moved mergeMoved
1056 if survivor == absorbed {
1057 // Belt: the handler 400s this; reaching step 3 here would delete the
1058 // account's own doc.
1059 return moved, errors.New("merge: survivor and absorbed are the same account")
1060 }
1061 if _, _, found, err := s.getBlob(absorbed); err != nil {
1062 return moved, err
1063 } else if found {
1064 return moved, errAbsorbedHasBlob
1065 }
1066 absDoc, _, found, err := s.loadUser(absorbed)
1067 if err != nil {
1068 return moved, err
1069 }
1070 if !found {
1071 return moved, nil // already merged β€” a retry
1072 }
1073 moved = mergeMoved{
1074 Anchors: len(absDoc.SiwaAnchors) + len(absDoc.EmailAnchors) + len(absDoc.OidcAnchors),
1075 Passkeys: len(absDoc.Credentials),
1076 Devices: len(absDoc.Devices),
1077 }
1078
1079 // Step 1: the survivor doc gains everything. CreatedAt/LastSeen ride along
1080 // untouched β€” a merged anchor keeps its original enrolment time.
1081 err = s.updateUser(survivor, func(doc *s3UserDoc) (bool, error) {
1082 changed := false
1083 has := map[string]bool{}
1084 for _, c := range doc.Credentials {
1085 has["cred/"+b64CredIDOf(&c.Credential)] = true
1086 }
1087 for _, d := range doc.Devices {
1088 has["tok/"+d.TokenHash] = true
1089 }
1090 for _, a := range doc.SiwaAnchors {
1091 has["siwa/"+a.SubHash] = true
1092 }
1093 for _, a := range doc.EmailAnchors {
1094 has["email/"+a.EmailHash] = true
1095 }
1096 for _, a := range doc.OidcAnchors {
1097 has["oidc/"+a.Provider+"/"+a.SubHash] = true
1098 }
1099 for _, c := range absDoc.Credentials {
1100 if !has["cred/"+b64CredIDOf(&c.Credential)] {
1101 doc.Credentials = append(doc.Credentials, c)
1102 changed = true
1103 }
1104 }
1105 for _, d := range absDoc.Devices {
1106 if !has["tok/"+d.TokenHash] {
1107 doc.Devices = append(doc.Devices, d)
1108 changed = true
1109 }
1110 }
1111 for _, a := range absDoc.SiwaAnchors {
1112 if !has["siwa/"+a.SubHash] {
1113 doc.SiwaAnchors = append(doc.SiwaAnchors, a)
1114 changed = true
1115 }
1116 }
1117 for _, a := range absDoc.EmailAnchors {
1118 if !has["email/"+a.EmailHash] {
1119 doc.EmailAnchors = append(doc.EmailAnchors, a)
1120 changed = true
1121 }
1122 }
1123 for _, a := range absDoc.OidcAnchors {
1124 if !has["oidc/"+a.Provider+"/"+a.SubHash] {
1125 doc.OidcAnchors = append(doc.OidcAnchors, a)
1126 changed = true
1127 }
1128 }
1129 return changed, nil
1130 })
1131 if err != nil {
1132 return mergeMoved{}, err
1133 }
1134
1135 // Step 2: repoint every pointer at the survivor.
1136 for _, c := range absDoc.Credentials {
1137 if err := s.repointPointer(s.credKey(b64CredIDOf(&c.Credential)), survivor); err != nil {
1138 return mergeMoved{}, err
1139 }
1140 }
1141 for _, d := range absDoc.Devices {
1142 if err := s.repointPointer(s.tokenKey(d.TokenHash), survivor); err != nil {
1143 return mergeMoved{}, err
1144 }
1145 }
1146 for _, a := range absDoc.SiwaAnchors {
1147 if err := s.repointPointer(s.siwaKey(a.SubHash), survivor); err != nil {
1148 return mergeMoved{}, err
1149 }
1150 }
1151 for _, a := range absDoc.EmailAnchors {
1152 if err := s.repointPointer(s.emailKey(a.EmailHash), survivor); err != nil {
1153 return mergeMoved{}, err
1154 }
1155 }
1156 for _, a := range absDoc.OidcAnchors {
1157 if err := s.repointPointer(s.oidcKey(a.Provider, a.SubHash), survivor); err != nil {
1158 return mergeMoved{}, err
1159 }
1160 }
1161
1162 // Step 3: retire the emptied absorbed doc. deletePointer is the generic
1163 // delete-with-404-as-success this needs (a delete marker suffices β€” uids
1164 // are long random strings, never reused, so create-only never re-claims
1165 // this key).
1166 if err := s.deletePointer(s.userKey(absorbed)); err != nil {
1167 return mergeMoved{}, err
1168 }
1169 return moved, nil
1170}
1171
1172// linkTokenUser resolves tokens/<hash> and verifies the device is still in
1173// the user doc (revocation = removal from the doc; the pointer never decides).
1174// last_seen bumps only when more than lastSeenGranularity stale β€” that
1175// coarseness (#612) is what keeps authenticated reads write-free on S3.
1176func (s *s3Store) linkTokenUser(tokenHash string) (string, bool) {
1177 uid, found, err := s.getPointer(s.tokenKey(tokenHash))
1178 if err != nil || !found {
1179 return "", false
1180 }
1181 doc, _, found, err := s.loadUser(uid)
1182 if err != nil || !found {
1183 return "", false
1184 }
1185 var lastSeen int64 = -1
1186 for _, d := range doc.Devices {
1187 if d.TokenHash == tokenHash {
1188 lastSeen = d.LastSeen
1189 break
1190 }
1191 }
1192 if lastSeen < 0 {
1193 return "", false
1194 }
1195 if now := time.Now().Unix(); lastSeen < now-lastSeenGranularity {
1196 // Best-effort, like SQLite's fire-and-forget UPDATE: a lost bump never
1197 // fails authentication.
1198 _ = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
1199 for i := range doc.Devices {
1200 if doc.Devices[i].TokenHash == tokenHash && doc.Devices[i].LastSeen < now-lastSeenGranularity {
1201 doc.Devices[i].LastSeen = now
1202 return true, nil
1203 }
1204 }
1205 return false, nil
1206 })
1207 }
1208 return uid, true
1209}
1210
1211func (s *s3Store) linkTokenID(tokenHash string) string {
1212 uid, found, err := s.getPointer(s.tokenKey(tokenHash))
1213 if err != nil || !found {
1214 return ""
1215 }
1216 doc, _, _, err := s.loadUser(uid)
1217 if err != nil {
1218 return ""
1219 }
1220 for _, d := range doc.Devices {
1221 if d.TokenHash == tokenHash {
1222 return d.ID
1223 }
1224 }
1225 return ""
1226}
1227
1228func (s *s3Store) listDevices(uid string) ([]Device, error) {
1229 doc, _, _, err := s.loadUser(uid)
1230 if err != nil {
1231 return nil, err
1232 }
1233 out := []Device{}
1234 for _, d := range doc.Devices {
1235 out = append(out, Device{ID: d.ID, CreatedAt: d.CreatedAt, LastSeen: d.LastSeen})
1236 }
1237 return out, nil
1238}
1239
1240// deleteDevice removes the device from the doc; the stale tokens/ pointer
1241// stays, denied at resolution by construction.
1242func (s *s3Store) deleteDevice(uid, id string) (bool, error) {
1243 var ok bool
1244 err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
1245 ok = false
1246 idx := -1
1247 for i, d := range doc.Devices {
1248 if d.ID == id {
1249 idx = i
1250 break
1251 }
1252 }
1253 if idx < 0 {
1254 return false, nil
1255 }
1256 doc.Devices = append(doc.Devices[:idx], doc.Devices[idx+1:]...)
1257 ok = true
1258 return true, nil
1259 })
1260 return ok, err
1261}
1262