| new file mode 100644 |
| index 0000000..6aa1476 |
| --- /dev/null |
| +++ b/home/s3store.go |
| @@ -0,0 +1,715 @@ |
| +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/<uid> the vault: ONE key, S3-native versioning; the |
| +// interface version is the ETag (quotes stripped) |
| +// users/<uid> one metadata JSON per user (credentials + devices), |
| +// every mutation an If-Match CAS loop |
| +// creds/<credId> write-once pointer object, body = uid |
| +// tokens/<sha256> 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/<uid> 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://<bucket>.s3.<region>.amazonaws.com, or the endpoint override |
| + pathPrefix string // "" virtual-hosted; "/<bucket>" 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 } |
| + |
| +// ---- 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/<uid>) ----------------------------------------------- |
| + |
| +// s3UserDoc is the one metadata JSON per user. Array order is enrolment / |
| +// creation order — the contract's ordering guarantee. |
| +type s3UserDoc struct { |
| + Credentials []webauthn.Credential `json:"credentials"` |
| + Devices []s3Device `json:"devices"` |
| +} |
| + |
| +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: []webauthn.Credential{}, 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)) |
| + } |
| +} |
| + |
| +// ---- 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 |
| + } |
| + return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { |
| + doc.Credentials = append(doc.Credentials, *cred) |
| + 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] = *cred |
| + 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 |
| + } |
| + if doc.Credentials == nil { |
| + return []webauthn.Credential{}, nil |
| + } |
| + return doc.Credentials, nil |
| +} |
| + |
| +// credentialUser resolves the creds/<credId> pointer and then VERIFIES the |
| +// credential is still listed in users/<uid> — 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-passkey guard 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 len(doc.Credentials) == 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 |
| +} |
| + |
| +// ---- 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 |
| +} |
| + |
| +// linkTokenUser resolves tokens/<hash> 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 |
| +} |