package main // πŸ€– s3Store is vaultStore implementation #2 (issue #550 Phase 1): one S3 // bucket as home's only database. The layout is the one the interface was // shaped for β€” // // blobs/ the vault: ONE key, S3-native versioning; the // interface version is the ETag (quotes stripped) // users/ one metadata JSON per user (credentials + devices), // every mutation an If-Match CAS loop // creds/ write-once pointer object, body = uid // tokens/ write-once pointer object, body = uid // // β€” all under an optional key prefix so tests can isolate runs. Pointers are // never authoritative: resolving one always re-loads users/ and verifies // the credential/token is still listed, so the stale pointer a revocation // leaves behind denies by construction (the vaultStore contract demands it). // // CAS semantics ride S3 conditional writes: If-Match for replace, the "0" // sentinel mapping to If-None-Match:* for create-only. S3 cannot distinguish // "a version string that was never ours" from a plain mismatch (both are just // non-matching ETags), so unlike SQLite there is no errBadVersion here β€” the // contract suite deliberately leaves that backend-specific. import ( "bytes" "encoding/base64" "encoding/json" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "github.com/go-webauthn/webauthn/webauthn" ) // ---- the S3 client ---------------------------------------------------------- // s3Client is a minimal S3 REST client over the hand-rolled SigV4 signer // (sigv4.go): GET/PUT/HEAD/DELETE object (+versionId) and ListObjectVersions // are the whole surface home needs. type s3Client struct { base *url.URL // https://.s3..amazonaws.com, or the endpoint override pathPrefix string // "" virtual-hosted; "/" path-style under an endpoint override region string accessKey string secretKey string sessionKey string // AWS_SESSION_TOKEN, optional hc *http.Client } type s3Result struct { status int header http.Header body []byte } // do performs one signed S3 request, retrying once on a transient 5xx or // transport error. Non-2xx statuses are returned, not errors β€” callers map // 404/409/412 to their own semantics. func (c *s3Client) do(method, key string, query url.Values, hdrs map[string]string, body []byte) (*s3Result, error) { var lastErr error for attempt := 0; attempt < 2; attempt++ { res, err := c.doOnce(method, key, query, hdrs, body) if err == nil && res.status < 500 { return res, nil } if err != nil { lastErr = err } else { lastErr = fmt.Errorf("s3 %s %s: HTTP %d: %s", method, key, res.status, truncate(res.body)) } } return nil, lastErr } func (c *s3Client) doOnce(method, key string, query url.Values, hdrs map[string]string, body []byte) (*s3Result, error) { full := c.base.Scheme + "://" + c.base.Host + c.pathPrefix + "/" + awsURIEncode(key, false) if len(query) > 0 { full += "?" + query.Encode() } req, err := http.NewRequest(method, full, bytes.NewReader(body)) if err != nil { return nil, err } now := time.Now() req.Header.Set("x-amz-date", now.UTC().Format("20060102T150405Z")) payloadHash := emptyPayloadHash if len(body) > 0 { payloadHash = hexSHA256(body) } req.Header.Set("x-amz-content-sha256", payloadHash) if c.sessionKey != "" { req.Header.Set("x-amz-security-token", c.sessionKey) } for k, v := range hdrs { req.Header.Set(k, v) } awsSignV4(req, c.accessKey, c.secretKey, c.region, "s3", now) resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return &s3Result{status: resp.StatusCode, header: resp.Header, body: b}, nil } // truncate keeps error messages readable β€” S3 XML errors are small but there's // no need to echo a whole response body into a log line. func truncate(b []byte) string { s := strings.TrimSpace(string(b)) if len(s) > 300 { s = s[:300] + "…" } return s } // etagOf strips the quotes S3 puts around ETags; the vaultStore version is the // bare value (the HTTP handlers re-add quotes for the wire). func etagOf(h http.Header) string { return strings.Trim(h.Get("ETag"), `"`) } // ---- the store ---------------------------------------------------------------- type s3Store struct { c *s3Client prefix string // key prefix incl. trailing "/", may be "" } var _ vaultStore = (*s3Store)(nil) // newS3Store builds an s3Store for bucket/region, keys under prefix. // Credentials come from the standard AWS env (AWS_ACCESS_KEY_ID, // AWS_SECRET_ACCESS_KEY, optional AWS_SESSION_TOKEN) at construction β€” // fail-fast, so a half-configured deployment can't limp along storeless. // endpoint overrides the AWS URL for S3-compatible stores (path-style). func newS3Store(bucket, region, prefix, endpoint string) (*s3Store, error) { if bucket == "" || region == "" { return nil, errors.New("s3 store: bucket and region are required") } access, secret := os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY") if access == "" || secret == "" { return nil, errors.New("s3 store: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set") } base := "https://" + bucket + ".s3." + region + ".amazonaws.com" pathPrefix := "" if endpoint != "" { base = strings.TrimSuffix(endpoint, "/") pathPrefix = "/" + bucket } u, err := url.Parse(base) if err != nil { return nil, fmt.Errorf("s3 store: bad endpoint: %w", err) } if prefix != "" && !strings.HasSuffix(prefix, "/") { prefix += "/" } return &s3Store{ c: &s3Client{ base: u, pathPrefix: pathPrefix, region: region, accessKey: access, secretKey: secret, sessionKey: os.Getenv("AWS_SESSION_TOKEN"), hc: &http.Client{Timeout: 30 * time.Second}, }, prefix: prefix, }, nil } func (s *s3Store) blobKey(uid string) string { return s.prefix + "blobs/" + uid } func (s *s3Store) userKey(uid string) string { return s.prefix + "users/" + uid } func (s *s3Store) credKey(id string) string { return s.prefix + "creds/" + id } func (s *s3Store) tokenKey(h string) string { return s.prefix + "tokens/" + h } func (s *s3Store) siwaKey(h string) string { return s.prefix + "siwa/" + h } func (s *s3Store) emailKey(h string) string { return s.prefix + "emails/" + h } // oidcKey namespaces per provider β€” the pointer-prefix twin of the // oidc_anchors (provider, sub_hash) key. provider is always an in-code name // (oidcProviderNames), never input. func (s *s3Store) oidcKey(provider, h string) string { return s.prefix + "oidc/" + provider + "/" + h } // ---- the blob ----------------------------------------------------------------- func (s *s3Store) getBlob(uid string) ([]byte, string, bool, error) { res, err := s.c.do("GET", s.blobKey(uid), nil, nil, nil) if err != nil { return nil, "", false, err } switch res.status { case http.StatusOK: return res.body, etagOf(res.header), true, nil case http.StatusNotFound: return nil, "", false, nil default: return nil, "", false, fmt.Errorf("s3 get blob: HTTP %d: %s", res.status, truncate(res.body)) } } // putBlob maps the interface CAS onto S3 conditional writes. ifMatch "0" β†’ // If-None-Match:* (create-only); anything else β†’ If-Match. On 412 (mismatch), // 404 (If-Match against a missing key) or 409 (a concurrent conditional write // in flight) the write lost: HEAD the key for the CURRENT ETag and report a // conflict β€” the current version being "0" when nothing exists β€” mirroring // sqliteStore. Old versions aren't pruned here: the bucket lifecycle expires // noncurrent versions asynchronously, and listBlobVersions caps at // blobKeepVersions so the keep-last-N contract holds at the interface while // S3 catches up. func (s *s3Store) putBlob(uid string, data []byte, ifMatch string) (string, bool, error) { hdrs := map[string]string{"Content-Type": "application/octet-stream"} if ifMatch == "0" { hdrs["If-None-Match"] = "*" } else { hdrs["If-Match"] = `"` + ifMatch + `"` } res, err := s.c.do("PUT", s.blobKey(uid), nil, hdrs, data) if err != nil { return "", false, err } switch res.status { case http.StatusOK: return etagOf(res.header), false, nil case http.StatusNotFound, http.StatusConflict, http.StatusPreconditionFailed: cur, found, err := s.headETag(s.blobKey(uid)) if err != nil { return "", false, err } if !found { return "0", true, nil } return cur, true, nil default: return "", false, fmt.Errorf("s3 put blob: HTTP %d: %s", res.status, truncate(res.body)) } } func (s *s3Store) headETag(key string) (string, bool, error) { res, err := s.c.do("HEAD", key, nil, nil, nil) if err != nil { return "", false, err } switch res.status { case http.StatusOK: return etagOf(res.header), true, nil case http.StatusNotFound: return "", false, nil default: return "", false, fmt.Errorf("s3 head %s: HTTP %d", key, res.status) } } // listBlobVersions lists the blob key's S3 versions (newest first, as // ListObjectVersions returns them) and reports their ETags, capped at // blobKeepVersions: the lifecycle rule prunes noncurrent versions days later, // so the cap β€” not the bucket β€” is what keeps the keep-last-N contract // observable at the interface in the meantime. func (s *s3Store) listBlobVersions(uid string) ([]string, error) { versions, err := s.listVersions(s.blobKey(uid)) if err != nil { return nil, err } out := []string{} for _, v := range versions { if v.deleteMarker || v.key != s.blobKey(uid) { continue } out = append(out, strings.Trim(v.etag, `"`)) if len(out) == blobKeepVersions { break } } return out, nil } // deleteBlob permanently deletes EVERY version of the blob key (delete by // versionId, no delete markers), so the user is genuinely back at the "0" // sentinel and a later create-only write succeeds β€” the contract's // delete-then-recreate path. func (s *s3Store) deleteBlob(uid string) error { key := s.blobKey(uid) versions, err := s.listVersions(key) if err != nil { return err } for _, v := range versions { if v.key != key { continue } res, err := s.c.do("DELETE", key, url.Values{"versionId": {v.versionID}}, nil, nil) if err != nil { return err } if res.status != http.StatusNoContent && res.status != http.StatusOK { return fmt.Errorf("s3 delete version: HTTP %d: %s", res.status, truncate(res.body)) } } return nil } // s3Version is one row of ListObjectVersions β€” a real version or a delete // marker (we never create markers, but a defensive parse costs nothing). type s3Version struct { key string versionID string etag string deleteMarker bool } // listVersions pages through ListObjectVersions for everything under prefix. // Within one key S3 returns versions newest first; that ordering is what // listBlobVersions relies on. func (s *s3Store) listVersions(prefix string) ([]s3Version, error) { type xmlVersion struct { Key string `xml:"Key"` VersionID string `xml:"VersionId"` ETag string `xml:"ETag"` } type xmlResult struct { IsTruncated bool `xml:"IsTruncated"` NextKeyMarker string `xml:"NextKeyMarker"` NextVersionIDMarker string `xml:"NextVersionIdMarker"` Versions []xmlVersion `xml:"Version"` DeleteMarkers []xmlVersion `xml:"DeleteMarker"` } var out []s3Version q := url.Values{"versions": {""}, "prefix": {prefix}} for { res, err := s.c.do("GET", "", q, nil, nil) if err != nil { return nil, err } if res.status != http.StatusOK { return nil, fmt.Errorf("s3 list versions: HTTP %d: %s", res.status, truncate(res.body)) } var page xmlResult if err := xml.Unmarshal(res.body, &page); err != nil { return nil, fmt.Errorf("s3 list versions: %w", err) } for _, v := range page.Versions { out = append(out, s3Version{key: v.Key, versionID: v.VersionID, etag: v.ETag}) } for _, v := range page.DeleteMarkers { out = append(out, s3Version{key: v.Key, versionID: v.VersionID, deleteMarker: true}) } if !page.IsTruncated { return out, nil } q.Set("key-marker", page.NextKeyMarker) q.Set("version-id-marker", page.NextVersionIDMarker) } } // ---- user metadata (users/) ----------------------------------------------- // s3UserDoc is the one metadata JSON per user. Array order is enrolment / // creation order β€” the contract's ordering guarantee. type s3UserDoc struct { Credentials []s3Credential `json:"credentials"` Devices []s3Device `json:"devices"` // SiwaAnchors lists the account's Sign in with Apple sub hashes β€” the // doc-side half of the siwa/ pointers (pointers never decide; // see the vaultStore contract). SiwaAnchors []s3SiwaAnchor `json:"siwa_anchors,omitempty"` // EmailAnchors mirrors SiwaAnchors for the email blind indexes. EmailAnchors []s3EmailAnchor `json:"email_anchors,omitempty"` // OidcAnchors mirrors SiwaAnchors for the newer provider doors (google, // microsoft β€” oidc.go), with the provider name beside each hash; the // pointer half lives at oidc//. Apple stays in // SiwaAnchors. OidcAnchors []s3OidcAnchor `json:"oidc_anchors,omitempty"` } // s3Credential wraps webauthn.Credential with its enrolment time β€” the // listing metadata the anchors surface shows (#1089). The embedded // credential's fields inline in the JSON, so this is additive on the wire: // docs written before this field unmarshal with CreatedAt 0 (the client // renders "unknown"), and older readers ignore the extra key. type s3Credential struct { webauthn.Credential CreatedAt int64 `json:"created_at,omitempty"` } type s3SiwaAnchor struct { SubHash string `json:"sub_hash"` CreatedAt int64 `json:"created_at"` } type s3EmailAnchor struct { EmailHash string `json:"email_hash"` CreatedAt int64 `json:"created_at"` } type s3OidcAnchor struct { Provider string `json:"provider"` SubHash string `json:"sub_hash"` CreatedAt int64 `json:"created_at"` } type s3Device struct { ID string `json:"id"` TokenHash string `json:"token_hash"` CreatedAt int64 `json:"created_at"` LastSeen int64 `json:"last_seen"` } func (s *s3Store) loadUser(uid string) (doc s3UserDoc, etag string, found bool, err error) { res, err := s.c.do("GET", s.userKey(uid), nil, nil, nil) if err != nil { return doc, "", false, err } switch res.status { case http.StatusOK: if err := json.Unmarshal(res.body, &doc); err != nil { return doc, "", false, fmt.Errorf("s3 user doc: %w", err) } return doc, etagOf(res.header), true, nil case http.StatusNotFound: return doc, "", false, nil default: return doc, "", false, fmt.Errorf("s3 get user: HTTP %d: %s", res.status, truncate(res.body)) } } // updateUser is the metadata CAS loop: GET (capture ETag) β†’ mutate β†’ PUT // If-Match (If-None-Match:* when the doc doesn't exist yet), retried on a lost // race. mutate returning write=false means "nothing to change" β€” the loop // exits without a write, which is also how mutations against a nonexistent // user fall through as no-ops (mirroring SQLite's zero-rows-affected). func (s *s3Store) updateUser(uid string, mutate func(doc *s3UserDoc) (write bool, err error)) error { for attempt := 0; attempt < 5; attempt++ { doc, etag, found, err := s.loadUser(uid) if err != nil { return err } write, err := mutate(&doc) if err != nil { return err } if !write { return nil } body, err := json.Marshal(doc) if err != nil { return err } hdrs := map[string]string{"Content-Type": "application/json"} if found { hdrs["If-Match"] = `"` + etag + `"` } else { hdrs["If-None-Match"] = "*" } res, err := s.c.do("PUT", s.userKey(uid), nil, hdrs, body) if err != nil { return err } switch res.status { case http.StatusOK: return nil case http.StatusNotFound, http.StatusConflict, http.StatusPreconditionFailed: continue // another writer won; reload and re-apply default: return fmt.Errorf("s3 put user: HTTP %d: %s", res.status, truncate(res.body)) } } return errors.New("s3 user doc: CAS retries exhausted") } // createUser writes an empty metadata doc, create-only. "Already exists" is // success: uids are long random strings, so a duplicate create can only be a // retry of our own request β€” erroring would just make registration flaky. func (s *s3Store) createUser(uid string) error { body, err := json.Marshal(s3UserDoc{Credentials: []s3Credential{}, Devices: []s3Device{}}) if err != nil { return err } hdrs := map[string]string{"If-None-Match": "*", "Content-Type": "application/json"} res, err := s.c.do("PUT", s.userKey(uid), nil, hdrs, body) if err != nil { return err } switch res.status { case http.StatusOK, http.StatusConflict, http.StatusPreconditionFailed: return nil default: return fmt.Errorf("s3 create user: HTTP %d: %s", res.status, truncate(res.body)) } } // ---- pointer objects ------------------------------------------------------------ // putPointer writes a write-once pointer object (body = uid). A pointer that // already exists is fine iff it names the same user β€” that's a retry of our // own write; a different owner would be a random-collision-sized bug, so it // errors loudly. func (s *s3Store) putPointer(key, uid string) error { res, err := s.c.do("PUT", key, nil, map[string]string{"If-None-Match": "*"}, []byte(uid)) if err != nil { return err } switch res.status { case http.StatusOK: return nil case http.StatusConflict, http.StatusPreconditionFailed: cur, found, err := s.getPointer(key) if err != nil { return err } if found && cur == uid { return nil } return fmt.Errorf("s3 pointer %s: already owned by another user", key) default: return fmt.Errorf("s3 put pointer: HTTP %d: %s", res.status, truncate(res.body)) } } func (s *s3Store) getPointer(key string) (string, bool, error) { res, err := s.c.do("GET", key, nil, nil, nil) if err != nil { return "", false, err } switch res.status { case http.StatusOK: return string(res.body), true, nil case http.StatusNotFound: return "", false, nil default: return "", false, fmt.Errorf("s3 get pointer: HTTP %d: %s", res.status, truncate(res.body)) } } // repointPointer unconditionally overwrites a pointer object's body with a new // uid β€” the account-merge move (#1098). Unlike putPointer there is no // write-once condition: the pointer is KNOWN to exist and name the absorbed // account, and pointers are never authoritative anyway β€” the doc-verify at // resolution is what decides, so an overwrite can never grant access the docs // don't back. func (s *s3Store) repointPointer(key, uid string) error { res, err := s.c.do("PUT", key, nil, nil, []byte(uid)) if err != nil { return err } if res.status != http.StatusOK { return fmt.Errorf("s3 repoint pointer: HTTP %d: %s", res.status, truncate(res.body)) } return nil } // deletePointer removes a pointer object; 404 counts as success (a retried // delete, or one never written). On a versioned bucket this lays a delete // marker, which is all that's needed: GETs answer 404 and a later write-once // claim of the same key succeeds again. func (s *s3Store) deletePointer(key string) error { res, err := s.c.do("DELETE", key, nil, nil, nil) if err != nil { return err } switch res.status { case http.StatusNoContent, http.StatusOK, http.StatusNotFound: return nil default: return fmt.Errorf("s3 delete pointer: HTTP %d: %s", res.status, truncate(res.body)) } } // ---- credentials ------------------------------------------------------------------ func b64CredIDOf(cred *webauthn.Credential) string { return base64.RawURLEncoding.EncodeToString(cred.ID) } // insertCredential writes the pointer FIRST, then the doc: a pointer without // a doc entry denies harmlessly (pointers are never authoritative), whereas a // doc entry without a pointer would silently break login by credential id. func (s *s3Store) insertCredential(uid string, cred *webauthn.Credential) error { if err := s.putPointer(s.credKey(b64CredIDOf(cred)), uid); err != nil { return err } now := time.Now().Unix() return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { doc.Credentials = append(doc.Credentials, s3Credential{Credential: *cred, CreatedAt: now}) return true, nil }) } // updateCredential locates the owner via the pointer (the method has no uid, // mirroring SQLite's UPDATE ... WHERE cred_id). An unknown id is a silent // no-op, exactly like an UPDATE that matches zero rows. func (s *s3Store) updateCredential(cred *webauthn.Credential) error { credID := b64CredIDOf(cred) uid, found, err := s.getPointer(s.credKey(credID)) if err != nil || !found { return err } return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { for i := range doc.Credentials { if bytes.Equal(doc.Credentials[i].ID, cred.ID) { doc.Credentials[i].Credential = *cred // CreatedAt stays put return true, nil } } return false, nil }) } func (s *s3Store) credentialsFor(uid string) ([]webauthn.Credential, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []webauthn.Credential{} for _, c := range doc.Credentials { out = append(out, c.Credential) } return out, nil } // listCredentialMeta returns passkey listing metadata from the user doc β€” // base64url id + enrolment time, enrolment order (like credentialsFor, but // without hauling public keys anywhere). func (s *s3Store) listCredentialMeta(uid string) ([]Anchor, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []Anchor{} for _, c := range doc.Credentials { out = append(out, Anchor{ID: b64CredIDOf(&c.Credential), CreatedAt: c.CreatedAt}) } return out, nil } // credentialUser resolves the creds/ pointer and then VERIFIES the // credential is still listed in users/ β€” the pointer alone never grants: // a revoked credential's stale pointer must deny. func (s *s3Store) credentialUser(credID string) (string, bool) { uid, found, err := s.getPointer(s.credKey(credID)) if err != nil || !found { return "", false } doc, _, found, err := s.loadUser(uid) if err != nil || !found { return "", false } for _, c := range doc.Credentials { if base64.RawURLEncoding.EncodeToString(c.ID) == credID { return uid, true } } return "", false } // deleteCredential removes the credential from the user doc only; the stale // creds/ pointer stays behind, harmless because resolution re-checks the doc. // The last-METHOD guard (passkeys + siwa + email anchors, #1089) is atomic // here because the whole doc updates under one CAS β€” same guarantee SQLite // gets from its transaction. func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err error) { err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { deleted, last = false, false idx := -1 for i, c := range doc.Credentials { if base64.RawURLEncoding.EncodeToString(c.ID) == credID { idx = i break } } if idx < 0 { return false, nil } if signInMethodsDoc(doc) == 1 { last = true return false, nil } doc.Credentials = append(doc.Credentials[:idx], doc.Credentials[idx+1:]...) deleted = true return true, nil }) return deleted, last, err } // signInMethodsDoc counts every way into the account within one loaded user // doc β€” the doc-CAS twin of SQLite's in-transaction signInMethods. func signInMethodsDoc(doc *s3UserDoc) int { return len(doc.Credentials) + len(doc.SiwaAnchors) + len(doc.EmailAnchors) + len(doc.OidcAnchors) } // ---- device link tokens -------------------------------------------------------- // createLinkToken mints the token, then pointer-first like insertCredential: // an orphaned tokens/ pointer denies; a device entry without its pointer // would be an unusable token. func (s *s3Store) createLinkToken(uid string) (string, error) { tok, err := randToken() if err != nil { return "", err } id, err := randToken() if err != nil { return "", err } hash := hashToken(tok) if err := s.putPointer(s.tokenKey(hash), uid); err != nil { return "", err } now := time.Now().Unix() err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { doc.Devices = append(doc.Devices, s3Device{ID: id, TokenHash: hash, CreatedAt: now, LastSeen: now}) return true, nil }) if err != nil { return "", err } return tok, nil } // siwaUser resolves siwa/ and verifies the anchor is still listed in // the user doc β€” a stale pointer left by a future unlink denies by // construction, per the vaultStore contract. func (s *s3Store) siwaUser(subHash string) (string, bool) { uid, found, err := s.getPointer(s.siwaKey(subHash)) if err != nil || !found { return "", false } doc, _, found, err := s.loadUser(uid) if err != nil || !found { return "", false } for _, a := range doc.SiwaAnchors { if a.SubHash == subHash { return uid, true } } return "", false } // insertSiwaAnchor writes pointer first, doc entry second (same ordering // rationale as createLinkToken: an orphaned pointer denies; a doc entry // without its pointer would be an unusable anchor). The conflict check is // DOC-verified (siwaUser), not pointer-only: an unlink (deleteSiwaAnchor) // leaves a stale pointer behind when its cleanup half fails, and that must // not stop the same Apple ID anchoring a different account later β€” a stale // pointer is swept here so the write-once claim below can land. func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) { if err := s.sweepAnchorPointer(s.siwaKey(subHash), uid, s.siwaUser, subHash); err != nil { if err == errAnchorTaken { return true, nil } return false, err } if err := s.putPointer(s.siwaKey(subHash), uid); err != nil { return false, err } now := time.Now().Unix() err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { for _, a := range doc.SiwaAnchors { if a.SubHash == subHash { return false, nil // already listed β€” idempotent } } doc.SiwaAnchors = append(doc.SiwaAnchors, s3SiwaAnchor{SubHash: subHash, CreatedAt: now}) return true, nil }) if err != nil { return false, err } return false, nil } // errAnchorTaken is sweepAnchorPointer's "anchored to a DIFFERENT user" // signal β€” the callers' conflict=true. var errAnchorTaken = errors.New("anchor taken") // sweepAnchorPointer prepares an anchor key for a write-once claim by uid: // errAnchorTaken when the identifier is live on another account (doc-verified // via resolve β€” the pointer alone never decides). A leftover pointer that no // doc lists any more is deleted so the claim can land; a live pointer that // already names uid is left alone (putPointer treats a same-owner re-claim as // success, and deleting it would open a needless deny window). func (s *s3Store) sweepAnchorPointer(key, uid string, resolve func(string) (string, bool), hash string) error { if owner, ok := resolve(hash); ok { if owner != uid { return errAnchorTaken } return nil } if _, found, err := s.getPointer(key); err != nil { return err } else if found { return s.deletePointer(key) } return nil } func (s *s3Store) listSiwaAnchors(uid string) ([]Anchor, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []Anchor{} for _, a := range doc.SiwaAnchors { out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt}) } return out, nil } // deleteSiwaAnchor removes the anchor from the user doc β€” under the same // one-CAS last-method guard as deleteCredential β€” then clears the siwa/ // pointer best-effort. A pointer a failed cleanup leaves behind still denies // at resolution (siwaUser re-checks the doc), and the next link of the same // sub sweeps it (sweepAnchorPointer), so the miss costs nothing but a // dangling object. func (s *s3Store) deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error) { err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { deleted, last = false, false idx := -1 for i, a := range doc.SiwaAnchors { if a.SubHash == subHash { idx = i break } } if idx < 0 { return false, nil } if signInMethodsDoc(doc) == 1 { last = true return false, nil } doc.SiwaAnchors = append(doc.SiwaAnchors[:idx], doc.SiwaAnchors[idx+1:]...) deleted = true return true, nil }) if err == nil && deleted { _ = s.deletePointer(s.siwaKey(subHash)) // best-effort; see doc comment } return deleted, last, err } // emailUser / insertEmailAnchor mirror the siwa pair exactly β€” pointer plus // doc entry, pointer never decides. func (s *s3Store) emailUser(index string) (string, bool) { uid, found, err := s.getPointer(s.emailKey(index)) if err != nil || !found { return "", false } doc, _, found, err := s.loadUser(uid) if err != nil || !found { return "", false } for _, a := range doc.EmailAnchors { if a.EmailHash == index { return uid, true } } return "", false } func (s *s3Store) insertEmailAnchor(uid, index string) (bool, error) { if err := s.sweepAnchorPointer(s.emailKey(index), uid, s.emailUser, index); err != nil { if err == errAnchorTaken { return true, nil } return false, err } if err := s.putPointer(s.emailKey(index), uid); err != nil { return false, err } now := time.Now().Unix() err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { for _, a := range doc.EmailAnchors { if a.EmailHash == index { return false, nil } } doc.EmailAnchors = append(doc.EmailAnchors, s3EmailAnchor{EmailHash: index, CreatedAt: now}) return true, nil }) if err != nil { return false, err } return false, nil } func (s *s3Store) listEmailAnchors(uid string) ([]Anchor, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []Anchor{} for _, a := range doc.EmailAnchors { out = append(out, Anchor{ID: a.EmailHash, CreatedAt: a.CreatedAt}) } return out, nil } // deleteEmailAnchor mirrors deleteSiwaAnchor exactly β€” one-CAS last-method // guard, then best-effort pointer cleanup. func (s *s3Store) deleteEmailAnchor(uid, index string) (deleted, last bool, err error) { err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { deleted, last = false, false idx := -1 for i, a := range doc.EmailAnchors { if a.EmailHash == index { idx = i break } } if idx < 0 { return false, nil } if signInMethodsDoc(doc) == 1 { last = true return false, nil } doc.EmailAnchors = append(doc.EmailAnchors[:idx], doc.EmailAnchors[idx+1:]...) deleted = true return true, nil }) if err == nil && deleted { _ = s.deletePointer(s.emailKey(index)) // best-effort; see deleteSiwaAnchor } return deleted, last, err } // The OIDC anchor methods (oidc.go's provider table) mirror the siwa set // exactly β€” pointer plus doc entry, pointer never decides, stale-pointer // sweep on insert, one-CAS last-method guard on delete β€” with the provider // name carried beside the hash in both halves. func (s *s3Store) oidcUser(provider, subHash string) (string, bool) { uid, found, err := s.getPointer(s.oidcKey(provider, subHash)) if err != nil || !found { return "", false } doc, _, found, err := s.loadUser(uid) if err != nil || !found { return "", false } for _, a := range doc.OidcAnchors { if a.Provider == provider && a.SubHash == subHash { return uid, true } } return "", false } func (s *s3Store) insertOidcAnchor(uid, provider, subHash string) (bool, error) { resolve := func(h string) (string, bool) { return s.oidcUser(provider, h) } if err := s.sweepAnchorPointer(s.oidcKey(provider, subHash), uid, resolve, subHash); err != nil { if err == errAnchorTaken { return true, nil } return false, err } if err := s.putPointer(s.oidcKey(provider, subHash), uid); err != nil { return false, err } now := time.Now().Unix() err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { for _, a := range doc.OidcAnchors { if a.Provider == provider && a.SubHash == subHash { return false, nil // already listed β€” idempotent } } doc.OidcAnchors = append(doc.OidcAnchors, s3OidcAnchor{Provider: provider, SubHash: subHash, CreatedAt: now}) return true, nil }) if err != nil { return false, err } return false, nil } func (s *s3Store) listOidcAnchors(uid, provider string) ([]Anchor, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []Anchor{} for _, a := range doc.OidcAnchors { if a.Provider == provider { out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt}) } } return out, nil } func (s *s3Store) deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) { err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { deleted, last = false, false idx := -1 for i, a := range doc.OidcAnchors { if a.Provider == provider && a.SubHash == subHash { idx = i break } } if idx < 0 { return false, nil } if signInMethodsDoc(doc) == 1 { last = true return false, nil } doc.OidcAnchors = append(doc.OidcAnchors[:idx], doc.OidcAnchors[idx+1:]...) deleted = true return true, nil }) if err == nil && deleted { _ = s.deletePointer(s.oidcKey(provider, subHash)) // best-effort; see deleteSiwaAnchor } return deleted, last, err } // mergeAccounts (#1098) folds the absorbed account into the survivor. There is // no cross-doc transaction on S3, so the write ORDER is what makes a crash at // any step resolve safely (same discipline as createLinkToken's pointer-first // rule; every step is idempotent, so a retried merge re-walks the sequence): // // 1. The SURVIVOR doc gains every absorbed entry first (one CAS update, // deduped by each entry's key so a retry appends nothing twice). Crash // after this: pointers still name the absorbed account and its doc still // lists everything, so every anchor/token resolves exactly as before β€” // the survivor's extra entries are inert (nothing points at them yet). // 2. Every pointer repoints to the survivor (unconditional overwrite β€” // repointPointer). Crash mid-way: a repointed key resolves via // pointerβ†’survivor-doc (which lists it, thanks to step 1); an untouched // key still resolves via absorbed. Either way the doc-verify contract // holds and nothing is denied or misgranted. // 3. The ABSORBED doc is deleted LAST. Deleting it any earlier would kill // resolution for every not-yet-repointed key (pointerβ†’absorbedβ†’gone = // deny); last, it only retires listings nothing points at any more. A // lingering absorbed doc after a crash is harmless for the same reason β€” // pointers all name the survivor β€” and the retry deletes it. // // The blob check up front is the store-side belt for the handler's // precondition (the client empties the absorbed vault first); an absorbed // account with no doc is a zero-count success β€” that's the post-completion // retry, which must not error while the absorb proof is still live. func (s *s3Store) mergeAccounts(survivor, absorbed string) (mergeMoved, error) { var moved mergeMoved if survivor == absorbed { // Belt: the handler 400s this; reaching step 3 here would delete the // account's own doc. return moved, errors.New("merge: survivor and absorbed are the same account") } if _, _, found, err := s.getBlob(absorbed); err != nil { return moved, err } else if found { return moved, errAbsorbedHasBlob } absDoc, _, found, err := s.loadUser(absorbed) if err != nil { return moved, err } if !found { return moved, nil // already merged β€” a retry } moved = mergeMoved{ Anchors: len(absDoc.SiwaAnchors) + len(absDoc.EmailAnchors) + len(absDoc.OidcAnchors), Passkeys: len(absDoc.Credentials), Devices: len(absDoc.Devices), } // Step 1: the survivor doc gains everything. CreatedAt/LastSeen ride along // untouched β€” a merged anchor keeps its original enrolment time. err = s.updateUser(survivor, func(doc *s3UserDoc) (bool, error) { changed := false has := map[string]bool{} for _, c := range doc.Credentials { has["cred/"+b64CredIDOf(&c.Credential)] = true } for _, d := range doc.Devices { has["tok/"+d.TokenHash] = true } for _, a := range doc.SiwaAnchors { has["siwa/"+a.SubHash] = true } for _, a := range doc.EmailAnchors { has["email/"+a.EmailHash] = true } for _, a := range doc.OidcAnchors { has["oidc/"+a.Provider+"/"+a.SubHash] = true } for _, c := range absDoc.Credentials { if !has["cred/"+b64CredIDOf(&c.Credential)] { doc.Credentials = append(doc.Credentials, c) changed = true } } for _, d := range absDoc.Devices { if !has["tok/"+d.TokenHash] { doc.Devices = append(doc.Devices, d) changed = true } } for _, a := range absDoc.SiwaAnchors { if !has["siwa/"+a.SubHash] { doc.SiwaAnchors = append(doc.SiwaAnchors, a) changed = true } } for _, a := range absDoc.EmailAnchors { if !has["email/"+a.EmailHash] { doc.EmailAnchors = append(doc.EmailAnchors, a) changed = true } } for _, a := range absDoc.OidcAnchors { if !has["oidc/"+a.Provider+"/"+a.SubHash] { doc.OidcAnchors = append(doc.OidcAnchors, a) changed = true } } return changed, nil }) if err != nil { return mergeMoved{}, err } // Step 2: repoint every pointer at the survivor. for _, c := range absDoc.Credentials { if err := s.repointPointer(s.credKey(b64CredIDOf(&c.Credential)), survivor); err != nil { return mergeMoved{}, err } } for _, d := range absDoc.Devices { if err := s.repointPointer(s.tokenKey(d.TokenHash), survivor); err != nil { return mergeMoved{}, err } } for _, a := range absDoc.SiwaAnchors { if err := s.repointPointer(s.siwaKey(a.SubHash), survivor); err != nil { return mergeMoved{}, err } } for _, a := range absDoc.EmailAnchors { if err := s.repointPointer(s.emailKey(a.EmailHash), survivor); err != nil { return mergeMoved{}, err } } for _, a := range absDoc.OidcAnchors { if err := s.repointPointer(s.oidcKey(a.Provider, a.SubHash), survivor); err != nil { return mergeMoved{}, err } } // Step 3: retire the emptied absorbed doc. deletePointer is the generic // delete-with-404-as-success this needs (a delete marker suffices β€” uids // are long random strings, never reused, so create-only never re-claims // this key). if err := s.deletePointer(s.userKey(absorbed)); err != nil { return mergeMoved{}, err } return moved, nil } // linkTokenUser resolves tokens/ and verifies the device is still in // the user doc (revocation = removal from the doc; the pointer never decides). // last_seen bumps only when more than lastSeenGranularity stale β€” that // coarseness (#612) is what keeps authenticated reads write-free on S3. func (s *s3Store) linkTokenUser(tokenHash string) (string, bool) { uid, found, err := s.getPointer(s.tokenKey(tokenHash)) if err != nil || !found { return "", false } doc, _, found, err := s.loadUser(uid) if err != nil || !found { return "", false } var lastSeen int64 = -1 for _, d := range doc.Devices { if d.TokenHash == tokenHash { lastSeen = d.LastSeen break } } if lastSeen < 0 { return "", false } if now := time.Now().Unix(); lastSeen < now-lastSeenGranularity { // Best-effort, like SQLite's fire-and-forget UPDATE: a lost bump never // fails authentication. _ = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { for i := range doc.Devices { if doc.Devices[i].TokenHash == tokenHash && doc.Devices[i].LastSeen < now-lastSeenGranularity { doc.Devices[i].LastSeen = now return true, nil } } return false, nil }) } return uid, true } func (s *s3Store) linkTokenID(tokenHash string) string { uid, found, err := s.getPointer(s.tokenKey(tokenHash)) if err != nil || !found { return "" } doc, _, _, err := s.loadUser(uid) if err != nil { return "" } for _, d := range doc.Devices { if d.TokenHash == tokenHash { return d.ID } } return "" } func (s *s3Store) listDevices(uid string) ([]Device, error) { doc, _, _, err := s.loadUser(uid) if err != nil { return nil, err } out := []Device{} for _, d := range doc.Devices { out = append(out, Device{ID: d.ID, CreatedAt: d.CreatedAt, LastSeen: d.LastSeen}) } return out, nil } // deleteDevice removes the device from the doc; the stale tokens/ pointer // stays, denied at resolution by construction. func (s *s3Store) deleteDevice(uid, id string) (bool, error) { var ok bool err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { ok = false idx := -1 for i, d := range doc.Devices { if d.ID == id { idx = i break } } if idx < 0 { return false, nil } doc.Devices = append(doc.Devices[:idx], doc.Devices[idx+1:]...) ok = true return true, nil }) return ok, err }