eleven / ocho Public

home: s3Store — vaultStore implementation #2 on a real S3 bucket (#550 Phase 1) (#627)

One bucket, no database: blobs/<uid> rides S3-native versioning with the ETag
as the interface version (If-Match CAS, "0" → If-None-Match:*); users/<uid>
is one metadata JSON per user, every mutation a CAS loop; creds/ and tokens/
are write-once pointer objects that are never authoritative — resolution
re-checks the user doc, so revocation's stale pointers deny by construction.
listBlobVersions caps at blobKeepVersions (the bucket lifecycle prunes
noncurrent versions asynchronously); deleteBlob deletes every version by id
so create-only works again.

The S3 client is a hand-rolled SigV4 signer (~6 operations, no new
dependencies), pinned offline against the official AWS worked examples.
TestS3VaultStore runs the shared contract suite against a real bucket, gated
on LCHAT_TEST_S3_BUCKET, under a random per-run prefix with full version
cleanup. Backend selection: LCHAT_HOME_STORE=s3 + LCHAT_HOME_S3_BUCKET/_REGION
(_PREFIX/_ENDPOINT optional); unset stays sqlite, the zero-config default.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3a5b40ad426175b86c127af31c151db49fceb12b parent 48c5eca
5 files changed, +1051 −3
  • home/main.go +26 −3
  • home/s3store.go +715 −0
  • home/s3store_test.go +66 −0
  • home/sigv4.go +135 −0
  • home/sigv4_test.go +109 −0
diff --git a/home/main.go b/home/main.go
index 90f002a..61dc829 100644
--- a/home/main.go
+++ b/home/main.go
@@ -67,9 +67,9 @@ func main() {
"caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)")
flag.Parse()
- store, err := openSQLiteStore(*dbPath)
+ store, storeDesc, err := openStore(*dbPath)
if err != nil {
- log.Fatalf("open db: %v", err)
+ log.Fatalf("open store: %v", err)
}
// LCHAT_HOME_SESSION_KEY signs/seals home's stateless ceremony proofs and
// in-flight WebAuthn sessions (stateless.go). It is auth plumbing, NOT a
@@ -88,12 +88,35 @@ func main() {
if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
log.Printf("space provisioning via %s", s.cloud.url)
}
- log.Printf("home listening on %s (db: %s, origin: %s)", *addr, *dbPath, *origin)
+ log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin)
if err := serve(*addr, s.handler()); err != nil {
log.Fatal(err)
}
}
+// openStore selects the vaultStore backend by LCHAT_HOME_STORE. Unset (or
+// "sqlite") keeps today's zero-config self-hoster default; "s3" is the
+// flagship-deployment backend (#550): LCHAT_HOME_S3_BUCKET + _REGION required,
+// _PREFIX and _ENDPOINT optional, credentials from the standard AWS env.
+// Misconfiguration fails fast — an s3-selected deployment must never limp
+// along on an accidental empty local DB.
+func openStore(dbPath string) (vaultStore, string, error) {
+ switch backend := os.Getenv("LCHAT_HOME_STORE"); backend {
+ case "", "sqlite":
+ st, err := openSQLiteStore(dbPath)
+ return st, "sqlite " + dbPath, err
+ case "s3":
+ bucket, region := os.Getenv("LCHAT_HOME_S3_BUCKET"), os.Getenv("LCHAT_HOME_S3_REGION")
+ st, err := newS3Store(bucket, region, os.Getenv("LCHAT_HOME_S3_PREFIX"), os.Getenv("LCHAT_HOME_S3_ENDPOINT"))
+ if err != nil {
+ return nil, "", fmt.Errorf("LCHAT_HOME_STORE=s3: %w (need LCHAT_HOME_S3_BUCKET, LCHAT_HOME_S3_REGION, AWS credentials in env)", err)
+ }
+ return st, fmt.Sprintf("s3 bucket %s (%s)", bucket, region), nil
+ default:
+ return nil, "", fmt.Errorf("unknown LCHAT_HOME_STORE %q (want \"sqlite\" or \"s3\")", backend)
+ }
+}
+
// serve listens on addr and serves h. A path-like addr (starts with "/") is a
// unix socket — how the instance router fronts this service, reverse-proxying to
// a socket rather than a TCP port; anything else is a TCP address (the default,
diff --git a/home/s3store.go b/home/s3store.go
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
+}
diff --git a/home/s3store_test.go b/home/s3store_test.go
new file mode 100644
index 0000000..b5ebaf0
--- /dev/null
+++ b/home/s3store_test.go
@@ -0,0 +1,66 @@
+package main
+
+// 🤖 Runs the backend-agnostic vaultStore contract suite (store_test.go)
+// against a REAL S3 bucket — conditional-write semantics are exactly the kind
+// of thing a fake gets subtly wrong, so there is deliberately no mock here.
+// Gated on env so the default `go test ./...` stays offline:
+//
+// eval "$(aws configure export-credentials --profile eleven-dev --format env)" && \
+// LCHAT_TEST_S3_BUCKET=eleven-dev-home-427880590669 LCHAT_TEST_S3_REGION=eu-west-1 \
+// go test ./home/ -run TestS3VaultStore -count=1 -v
+//
+// Each run isolates itself under a random test-<hex>/ key prefix (and each
+// subtest store under its own sub-prefix, since the contract expects a fresh
+// store), and cleanup permanently deletes every version of every object under
+// the run prefix.
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "os"
+ "testing"
+)
+
+func TestS3VaultStore(t *testing.T) {
+ bucket := os.Getenv("LCHAT_TEST_S3_BUCKET")
+ if bucket == "" {
+ t.Skip("LCHAT_TEST_S3_BUCKET not set; skipping live S3 contract suite (see file header for how to run)")
+ }
+ region := os.Getenv("LCHAT_TEST_S3_REGION")
+ if region == "" {
+ t.Fatal("LCHAT_TEST_S3_REGION must be set alongside LCHAT_TEST_S3_BUCKET")
+ }
+
+ var runID [8]byte
+ if _, err := rand.Read(runID[:]); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ runPrefix := "test-" + hex.EncodeToString(runID[:]) + "/"
+ root, err := newS3Store(bucket, region, runPrefix, os.Getenv("LCHAT_TEST_S3_ENDPOINT"))
+ if err != nil {
+ t.Fatalf("newS3Store: %v", err)
+ }
+ t.Cleanup(func() {
+ versions, err := root.listVersions(runPrefix)
+ if err != nil {
+ t.Errorf("cleanup list: %v (objects may remain under %s)", err, runPrefix)
+ return
+ }
+ for _, v := range versions {
+ res, err := root.c.do("DELETE", v.key, url.Values{"versionId": {v.versionID}}, nil, nil)
+ if err != nil || (res.status != 204 && res.status != 200) {
+ t.Errorf("cleanup delete %s@%s: status=%v err=%v", v.key, v.versionID, res, err)
+ }
+ }
+ })
+
+ // The contract suite expects every newStore call to hand back a FRESH,
+ // empty store — so each gets its own sub-prefix under the run prefix.
+ n := 0
+ testVaultStore(t, func(t *testing.T) vaultStore {
+ n++
+ return &s3Store{c: root.c, prefix: fmt.Sprintf("%ss%d/", runPrefix, n)}
+ })
+}
diff --git a/home/sigv4.go b/home/sigv4.go
new file mode 100644
index 0000000..d59433f
--- /dev/null
+++ b/home/sigv4.go
@@ -0,0 +1,135 @@
+package main
+
+// 🤖 Minimal AWS Signature Version 4 signer — just enough to talk to S3.
+// Hand-rolled on purpose (#550 Phase 1): home has two dependencies total and
+// the s3Store needs ~6 operations, so pulling in aws-sdk-go-v2 would multiply
+// the dependency surface for one HMAC chain. Pinned offline against the
+// official AWS worked examples in sigv4_test.go; exercised for real by
+// TestS3VaultStore.
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+// emptyPayloadHash is sha256("") — the x-amz-content-sha256 for bodyless
+// requests (GET/HEAD/DELETE).
+const emptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+// awsSignV4 computes the SigV4 signature for req and sets its Authorization
+// header. The caller must already have set x-amz-date (matching t) and
+// x-amz-content-sha256 (the hex payload hash — signed, so the body can't be
+// swapped in flight). Signed headers are host plus every x-amz-* header plus
+// range/content-type/content-md5 when present; conditional headers (If-Match
+// etc.) ride unsigned, which S3 permits — only x-amz-* headers MUST be signed.
+func awsSignV4(req *http.Request, accessKey, secretKey, region, service string, t time.Time) {
+ amzDate := t.UTC().Format("20060102T150405Z")
+ scope := t.UTC().Format("20060102") + "/" + region + "/" + service + "/aws4_request"
+
+ host := req.Host
+ if host == "" {
+ host = req.URL.Host
+ }
+ type hdr struct{ name, value string }
+ signed := []hdr{{"host", host}}
+ for name, vals := range req.Header {
+ ln := strings.ToLower(name)
+ if strings.HasPrefix(ln, "x-amz-") || ln == "range" || ln == "content-type" || ln == "content-md5" {
+ signed = append(signed, hdr{ln, strings.TrimSpace(strings.Join(vals, ","))})
+ }
+ }
+ sort.Slice(signed, func(i, j int) bool { return signed[i].name < signed[j].name })
+ names := make([]string, len(signed))
+ var canonHdrs strings.Builder
+ for i, h := range signed {
+ canonHdrs.WriteString(h.name)
+ canonHdrs.WriteByte(':')
+ canonHdrs.WriteString(h.value)
+ canonHdrs.WriteByte('\n')
+ names[i] = h.name
+ }
+ signedHeaders := strings.Join(names, ";")
+
+ path := req.URL.EscapedPath()
+ if path == "" {
+ path = "/"
+ }
+ canonical := req.Method + "\n" +
+ path + "\n" +
+ canonicalQuery(req.URL.Query()) + "\n" +
+ canonHdrs.String() + "\n" +
+ signedHeaders + "\n" +
+ req.Header.Get("x-amz-content-sha256")
+
+ stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hexSHA256([]byte(canonical))
+
+ // The HMAC chain: secret → date → region → service → "aws4_request".
+ key := hmacSHA256([]byte("AWS4"+secretKey), t.UTC().Format("20060102"))
+ key = hmacSHA256(key, region)
+ key = hmacSHA256(key, service)
+ key = hmacSHA256(key, "aws4_request")
+ sig := hex.EncodeToString(hmacSHA256(key, stringToSign))
+
+ req.Header.Set("Authorization",
+ "AWS4-HMAC-SHA256 Credential="+accessKey+"/"+scope+
+ ",SignedHeaders="+signedHeaders+",Signature="+sig)
+}
+
+// canonicalQuery renders url.Values in SigV4 canonical form: keys sorted,
+// values sorted within a key, strict RFC 3986 escaping (%20 for space, never
+// '+'), and flag parameters ("?versions") rendered with a trailing '='.
+func canonicalQuery(q url.Values) string {
+ keys := make([]string, 0, len(q))
+ for k := range q {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(q))
+ for _, k := range keys {
+ vs := append([]string(nil), q[k]...)
+ sort.Strings(vs)
+ for _, v := range vs {
+ parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true))
+ }
+ }
+ return strings.Join(parts, "&")
+}
+
+// awsURIEncode percent-encodes per the SigV4 rules: unreserved characters
+// (A-Za-z0-9, '-', '.', '_', '~') pass through; '/' passes through only in
+// paths (encodeSlash=false); everything else — including '+' and space —
+// becomes %XX with uppercase hex.
+func awsURIEncode(s string, encodeSlash bool) string {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9',
+ c == '-', c == '.', c == '_', c == '~':
+ b.WriteByte(c)
+ case c == '/' && !encodeSlash:
+ b.WriteByte(c)
+ default:
+ fmt.Fprintf(&b, "%%%02X", c)
+ }
+ }
+ return b.String()
+}
+
+func hexSHA256(b []byte) string {
+ h := sha256.Sum256(b)
+ return hex.EncodeToString(h[:])
+}
+
+func hmacSHA256(key []byte, msg string) []byte {
+ m := hmac.New(sha256.New, key)
+ m.Write([]byte(msg))
+ return m.Sum(nil)
+}
diff --git a/home/sigv4_test.go b/home/sigv4_test.go
new file mode 100644
index 0000000..56f52dc
--- /dev/null
+++ b/home/sigv4_test.go
@@ -0,0 +1,109 @@
+package main
+
+// 🤖 Pins the hand-rolled signer against the official worked examples in the
+// AWS docs ("Signature Calculations for the Authorization Header" — the
+// examplebucket vectors, signing key AKIAIOSFODNN7EXAMPLE, 2013-05-24), so the
+// signer is testable offline. TestS3VaultStore exercises it for real.
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSigV4Vectors(t *testing.T) {
+ const (
+ accessKey = "AKIAIOSFODNN7EXAMPLE"
+ secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ amzDate = "20130524T000000Z"
+ )
+ ts := time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC)
+
+ cases := []struct {
+ name, method, url string
+ headers map[string]string
+ wantSignedHeaders string
+ wantSig string
+ }{
+ {
+ // GET Object: GET /test.txt with a Range header.
+ name: "GetObject",
+ method: "GET",
+ url: "https://examplebucket.s3.amazonaws.com/test.txt",
+ headers: map[string]string{
+ "Range": "bytes=0-9",
+ "x-amz-content-sha256": emptyPayloadHash,
+ "x-amz-date": amzDate,
+ },
+ wantSignedHeaders: "host;range;x-amz-content-sha256;x-amz-date",
+ wantSig: "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41",
+ },
+ {
+ // GET Bucket Lifecycle: a valueless subresource must canonicalize
+ // as "lifecycle=" — the same shape our ListObjectVersions uses.
+ name: "GetBucketLifecycle",
+ method: "GET",
+ url: "https://examplebucket.s3.amazonaws.com/?lifecycle",
+ headers: map[string]string{
+ "x-amz-content-sha256": emptyPayloadHash,
+ "x-amz-date": amzDate,
+ },
+ wantSignedHeaders: "host;x-amz-content-sha256;x-amz-date",
+ wantSig: "fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543",
+ },
+ {
+ // List Objects: multiple query params, sorted canonically.
+ name: "ListObjects",
+ method: "GET",
+ url: "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J",
+ headers: map[string]string{
+ "x-amz-content-sha256": emptyPayloadHash,
+ "x-amz-date": amzDate,
+ },
+ wantSignedHeaders: "host;x-amz-content-sha256;x-amz-date",
+ wantSig: "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req, err := http.NewRequest(tc.method, tc.url, nil)
+ if err != nil {
+ t.Fatalf("NewRequest: %v", err)
+ }
+ for k, v := range tc.headers {
+ req.Header.Set(k, v)
+ }
+ awsSignV4(req, accessKey, secretKey, "us-east-1", "s3", ts)
+ auth := req.Header.Get("Authorization")
+ if !strings.Contains(auth, "SignedHeaders="+tc.wantSignedHeaders+",") {
+ t.Errorf("signed headers: want %q in %q", tc.wantSignedHeaders, auth)
+ }
+ if !strings.HasSuffix(auth, "Signature="+tc.wantSig) {
+ t.Errorf("signature mismatch:\n got %q\nwant …Signature=%s", auth, tc.wantSig)
+ }
+ })
+ }
+}
+
+// The SigV4 path/query encoders have their own rules (uppercase hex, '+' is
+// %2B never a space, '/' survives only in paths); a wrong encoder signs
+// requests S3 will reject, so pin the corners.
+func TestAWSURIEncode(t *testing.T) {
+ for _, tc := range []struct {
+ in string
+ encodeSlash bool
+ want string
+ }{
+ {"blobs/u1", false, "blobs/u1"},
+ {"blobs/u1", true, "blobs%2Fu1"},
+ {"a b+c", true, "a%20b%2Bc"},
+ {"ok-._~", true, "ok-._~"},
+ {"é", true, "%C3%A9"},
+ } {
+ if got := awsURIEncode(tc.in, tc.encodeSlash); got != tc.want {
+ t.Errorf("awsURIEncode(%q, %v) = %q, want %q", tc.in, tc.encodeSlash, got, tc.want)
+ }
+ }
+}