eleven / ocho Public

home: extract the vaultStore interface — the object-store seam (#550 Phase 0.1) (#611)

Pure refactor, no behavior change. Persistence now goes through vaultStore,
an interface whose method shapes mirror an object store (point reads, CAS
writes on opaque version strings with the "0" create-only sentinel,
keep-last-N version listing, prefix delete, and write-once pointer reads by
credential id / token hash). The existing SQLite store becomes implementation
#1 (sqliteStore, unchanged schema); an s3Store can later become #2 without
the handlers changing.

Versions are opaque strings at the boundary (SQLite renders its integer
rowversions); token/proof hashing moves to the callers so the store is keyed
by hash and never sees a presented plaintext. The PUT /api/blob response
keeps its version as a JSON number via json.Number (Phase 0 item 4 switches
it to a string deliberately, not here), and a new wire test pins that.

store_test.go adds the backend-agnostic contract suite (testVaultStore), run
against SQLite today and any future backend by adding one entry point.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5d7c516a79f658943b9a886eba1eee3876afff9e parent 7937d78
8 files changed, +615 −133
  • home/db.go +87 −65
  • home/home_test.go +36 −26
  • home/main.go +34 −30
  • home/provision.go +1 −1
  • home/provision_test.go +3 −3
  • home/store.go +116 −0
  • home/store_test.go +330 −0
  • home/webauthn.go +8 −8
diff --git a/home/db.go b/home/db.go
index ac4802e..10de81f 100644
--- a/home/db.go
+++ b/home/db.go
@@ -7,29 +7,27 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "strconv"
"time"
"github.com/go-webauthn/webauthn/webauthn"
_ "modernc.org/sqlite"
)
-// blobKeepVersions is how many blob versions the server retains per user —
-// overwrite protection against a stolen link token trashing the backup (the
-// token can write, but the previous versions survive until pushed out).
-const blobKeepVersions = 5
+// sqliteStore is vaultStore implementation #1 — the zero-config default. The
+// home server is content-blind: the one blob per user is opaque,
+// client-encrypted bytes; credentials are WebAuthn public keys (useless
+// without the authenticator); tokens are stored hashed. There is deliberately
+// no email, username, or display name anywhere.
+//
+// Versions are integer rowversions internally but rendered as opaque strings
+// at the vaultStore boundary — the interface (and the wire) never learns
+// they're integers.
+type sqliteStore struct{ sql *sql.DB }
-// ceremonyWindow is how long a ceremony proof stays "fresh". Destructive ops
-// (revoking devices, deleting the blob) demand a passkey ceremony completed
-// within this window, so a stolen link token can never reduce access.
-const ceremonyWindow = 5 * time.Minute
+var _ vaultStore = (*sqliteStore)(nil)
-// DB wraps the SQLite connection. The home server is content-blind: the one
-// blob per user is opaque, client-encrypted bytes; credentials are WebAuthn
-// public keys (useless without the authenticator); tokens are stored hashed.
-// There is deliberately no email, username, or display name anywhere.
-type DB struct{ sql *sql.DB }
-
-func openDB(path string) (*DB, error) {
+func openSQLiteStore(path string) (*sqliteStore, error) {
d, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
@@ -46,14 +44,14 @@ func openDB(path string) (*DB, error) {
return nil, err
}
}
- db := &DB{sql: d}
+ db := &sqliteStore{sql: d}
if err := db.migrate(); err != nil {
return nil, err
}
return db, nil
}
-func (db *DB) migrate() error {
+func (db *sqliteStore) migrate() error {
_, err := db.sql.Exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- random; the only identity there is
@@ -111,12 +109,12 @@ func randToken() (string, error) {
// ---- users + credentials --------------------------------------------------
-func (db *DB) createUser(id string) error {
+func (db *sqliteStore) createUser(id string) error {
_, err := db.sql.Exec("INSERT INTO users(id, created_at) VALUES(?, ?)", id, time.Now().Unix())
return err
}
-func (db *DB) userExists(id string) bool {
+func (db *sqliteStore) userExists(id string) bool {
var x int
return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
}
@@ -124,7 +122,7 @@ func (db *DB) userExists(id string) bool {
// insertCredential stores a verified WebAuthn credential as the library's own
// JSON — public key, sign count, and flags travel together, so upgrades that
// add fields stay additive.
-func (db *DB) insertCredential(userID string, cred *webauthn.Credential) error {
+func (db *sqliteStore) insertCredential(userID string, cred *webauthn.Credential) error {
blob, err := json.Marshal(cred)
if err != nil {
return err
@@ -137,7 +135,7 @@ func (db *DB) insertCredential(userID string, cred *webauthn.Credential) error {
}
// updateCredential persists post-ceremony state (sign count, backup flags).
-func (db *DB) updateCredential(cred *webauthn.Credential) error {
+func (db *sqliteStore) updateCredential(cred *webauthn.Credential) error {
blob, err := json.Marshal(cred)
if err != nil {
return err
@@ -149,7 +147,7 @@ func (db *DB) updateCredential(cred *webauthn.Credential) error {
return err
}
-func (db *DB) credentialsFor(userID string) ([]webauthn.Credential, error) {
+func (db *sqliteStore) credentialsFor(userID string) ([]webauthn.Credential, error) {
rows, err := db.sql.Query("SELECT credential FROM credentials WHERE user_id = ? ORDER BY created_at", userID)
if err != nil {
return nil, err
@@ -170,12 +168,21 @@ func (db *DB) credentialsFor(userID string) ([]webauthn.Credential, error) {
return out, rows.Err()
}
+// credentialUser resolves a credential id to its user — the creds/<credId>
+// pointer read. In SQLite the pointer and the credential are one row, so
+// resolution and the "still listed" check coincide.
+func (db *sqliteStore) credentialUser(credID string) (string, bool) {
+ var uid string
+ err := db.sql.QueryRow("SELECT user_id FROM credentials WHERE cred_id = ?", credID).Scan(&uid)
+ return uid, err == nil
+}
+
// deleteCredential removes one of a user's passkeys (a rogue or lost one).
// It refuses to remove the LAST credential: the blob would be orphaned (no
// passkey could ever unwrap its DEK again) while staying live on the server —
// the explicit path for "I'm done" is the ceremony-gated blob delete.
// deleted=false with last=false means the id is unknown (or someone else's).
-func (db *DB) deleteCredential(userID, credID string) (deleted, last bool, err error) {
+func (db *sqliteStore) deleteCredential(userID, credID string) (deleted, last bool, err error) {
tx, err := db.sql.Begin()
if err != nil {
return false, false, err
@@ -205,54 +212,78 @@ func (db *DB) deleteCredential(userID, credID string) (deleted, last bool, err e
// ---- the blob ---------------------------------------------------------------
// getBlob returns the latest version of a user's blob.
-func (db *DB) getBlob(userID string) (data []byte, version int64, ok bool, err error) {
+func (db *sqliteStore) getBlob(userID string) (data []byte, version string, found bool, err error) {
+ var v int64
err = db.sql.QueryRow(
"SELECT data, version FROM blobs WHERE user_id = ? ORDER BY version DESC LIMIT 1", userID,
- ).Scan(&data, &version)
+ ).Scan(&data, &v)
if err == sql.ErrNoRows {
- return nil, 0, false, nil
+ return nil, "", false, nil
}
if err != nil {
- return nil, 0, false, err
+ return nil, "", false, err
}
- return data, version, true, nil
+ return data, strconv.FormatInt(v, 10), true, nil
}
-// putBlob writes the next version if ifMatch equals the current one (0 = no
+// putBlob writes the next version if ifMatch equals the current one ("0" = no
// blob yet). On mismatch it reports conflict=true with the current version so
// the caller can 409 + ETag; the client merges and retries. Old versions past
-// blobKeepVersions are pruned.
-func (db *DB) putBlob(userID string, data []byte, ifMatch int64) (newVersion int64, conflict bool, err error) {
+// blobKeepVersions are pruned. A version string that was never one of ours
+// (non-numeric, negative) is errBadVersion, not a conflict.
+func (db *sqliteStore) putBlob(userID string, data []byte, ifMatch string) (version string, conflict bool, err error) {
+ want, err := strconv.ParseInt(ifMatch, 10, 64)
+ if err != nil || want < 0 {
+ return "", false, errBadVersion
+ }
tx, err := db.sql.Begin()
if err != nil {
- return 0, false, err
+ return "", false, err
}
defer tx.Rollback()
var cur int64
if err := tx.QueryRow("SELECT COALESCE(MAX(version), 0) FROM blobs WHERE user_id = ?", userID).Scan(&cur); err != nil {
- return 0, false, err
+ return "", false, err
}
- if cur != ifMatch {
- return cur, true, nil
+ if cur != want {
+ return strconv.FormatInt(cur, 10), true, nil
}
- newVersion = cur + 1
+ next := cur + 1
if _, err := tx.Exec(
"INSERT INTO blobs(user_id, version, data, updated_at) VALUES(?, ?, ?, ?)",
- userID, newVersion, data, time.Now().Unix(),
+ userID, next, data, time.Now().Unix(),
); err != nil {
- return 0, false, err
+ return "", false, err
}
if _, err := tx.Exec(
- "DELETE FROM blobs WHERE user_id = ? AND version <= ?", userID, newVersion-blobKeepVersions,
+ "DELETE FROM blobs WHERE user_id = ? AND version <= ?", userID, next-blobKeepVersions,
); err != nil {
- return 0, false, err
+ return "", false, err
}
- return newVersion, false, tx.Commit()
+ return strconv.FormatInt(next, 10), false, tx.Commit()
+}
+
+// listBlobVersions returns the retained versions, newest first.
+func (db *sqliteStore) listBlobVersions(userID string) ([]string, error) {
+ rows, err := db.sql.Query("SELECT version FROM blobs WHERE user_id = ? ORDER BY version DESC", userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var v int64
+ if err := rows.Scan(&v); err != nil {
+ return nil, err
+ }
+ out = append(out, strconv.FormatInt(v, 10))
+ }
+ return out, rows.Err()
}
// deleteBlob removes every version — history included, that's the point of the
// ceremony gate in front of it.
-func (db *DB) deleteBlob(userID string) error {
+func (db *sqliteStore) deleteBlob(userID string) error {
_, err := db.sql.Exec("DELETE FROM blobs WHERE user_id = ?", userID)
return err
}
@@ -261,7 +292,7 @@ func (db *DB) deleteBlob(userID string) error {
// createLinkToken mints a bearer token for a user and returns the plaintext
// (shown/stored client-side once; only the hash persists here).
-func (db *DB) createLinkToken(userID string) (string, error) {
+func (db *sqliteStore) createLinkToken(userID string) (string, error) {
tok, err := randToken()
if err != nil {
return "", err
@@ -278,26 +309,17 @@ func (db *DB) createLinkToken(userID string) (string, error) {
return tok, err
}
-// linkTokenUser resolves a presented token to its user and bumps last_seen.
-func (db *DB) linkTokenUser(token string) (string, bool) {
+// linkTokenUser resolves a token hash to its user and bumps last_seen.
+func (db *sqliteStore) linkTokenUser(tokenHash string) (string, bool) {
var uid string
- if db.sql.QueryRow("SELECT user_id FROM link_tokens WHERE token_hash = ?", hashToken(token)).Scan(&uid) != nil {
+ if db.sql.QueryRow("SELECT user_id FROM link_tokens WHERE token_hash = ?", tokenHash).Scan(&uid) != nil {
return "", false
}
- db.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?", time.Now().Unix(), hashToken(token))
+ db.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?", time.Now().Unix(), tokenHash)
return uid, true
}
-// Device is link-token metadata for the devices list — ids and timestamps
-// only, never token values (or anything derived from them).
-type Device struct {
- ID string `json:"id"`
- CreatedAt int64 `json:"created_at"`
- LastSeen int64 `json:"last_seen"`
- Current bool `json:"current,omitempty"`
-}
-
-func (db *DB) listDevices(userID string) ([]Device, error) {
+func (db *sqliteStore) listDevices(userID string) ([]Device, error) {
rows, err := db.sql.Query(
"SELECT id, created_at, last_seen FROM link_tokens WHERE user_id = ? ORDER BY created_at", userID)
if err != nil {
@@ -315,17 +337,17 @@ func (db *DB) listDevices(userID string) ([]Device, error) {
return out, rows.Err()
}
-// linkTokenID returns the device id of a presented token, so the devices list
+// linkTokenID returns the device id behind a token hash, so the devices list
// can mark "this device".
-func (db *DB) linkTokenID(token string) string {
+func (db *sqliteStore) linkTokenID(tokenHash string) string {
var id string
- db.sql.QueryRow("SELECT id FROM link_tokens WHERE token_hash = ?", hashToken(token)).Scan(&id)
+ db.sql.QueryRow("SELECT id FROM link_tokens WHERE token_hash = ?", tokenHash).Scan(&id)
return id
}
// deleteDevice revokes one of the user's link tokens. ok=false when the id
// doesn't exist or belongs to someone else.
-func (db *DB) deleteDevice(userID, id string) (bool, error) {
+func (db *sqliteStore) deleteDevice(userID, id string) (bool, error) {
res, err := db.sql.Exec("DELETE FROM link_tokens WHERE user_id = ? AND id = ?", userID, id)
if err != nil {
return false, err
@@ -339,7 +361,7 @@ func (db *DB) deleteDevice(userID, id string) (bool, error) {
// createCeremonyProof mints the short-lived proof returned by a verified
// passkey ceremony (login/register finish). Expired rows are swept here so the
// table never needs external housekeeping.
-func (db *DB) createCeremonyProof(userID string) (string, error) {
+func (db *sqliteStore) createCeremonyProof(userID string) (string, error) {
proof, err := randToken()
if err != nil {
return "", err
@@ -353,12 +375,12 @@ func (db *DB) createCeremonyProof(userID string) (string, error) {
return proof, err
}
-// ceremonyUser resolves a presented proof, honouring the freshness window.
-func (db *DB) ceremonyUser(proof string) (string, bool) {
+// ceremonyUser resolves a presented proof hash, honouring the freshness window.
+func (db *sqliteStore) ceremonyUser(proofHash string) (string, bool) {
var uid string
err := db.sql.QueryRow(
"SELECT user_id FROM ceremony_proofs WHERE proof_hash = ? AND created_at >= ?",
- hashToken(proof), time.Now().Unix()-int64(ceremonyWindow.Seconds()),
+ proofHash, time.Now().Unix()-int64(ceremonyWindow.Seconds()),
).Scan(&uid)
return uid, err == nil
}
diff --git a/home/home_test.go b/home/home_test.go
index 8b9e934..5a4a4bf 100644
--- a/home/home_test.go
+++ b/home/home_test.go
@@ -16,11 +16,11 @@ import (
func newTestServer(t *testing.T) (*httptest.Server, *Server) {
t.Helper()
- db, err := openDB(t.TempDir() + "/home.db")
+ store, err := openSQLiteStore(t.TempDir() + "/home.db")
if err != nil {
- t.Fatalf("openDB: %v", err)
+ t.Fatalf("openSQLiteStore: %v", err)
}
- s, err := newServer(db, "http://localhost:1748")
+ s, err := newServer(store, "http://localhost:1748")
if err != nil {
t.Fatalf("newServer: %v", err)
}
@@ -38,7 +38,7 @@ func seedUser(t *testing.T, s *Server) (userID, token string) {
if err != nil {
t.Fatalf("randToken: %v", err)
}
- if err := s.db.createUser(userID); err != nil {
+ if err := s.store.createUser(userID); err != nil {
t.Fatalf("createUser: %v", err)
}
cred := &webauthn.Credential{
@@ -49,10 +49,10 @@ func seedUser(t *testing.T, s *Server) (userID, token string) {
SignCount: 1,
},
}
- if err := s.db.insertCredential(userID, cred); err != nil {
+ if err := s.store.insertCredential(userID, cred); err != nil {
t.Fatalf("insertCredential: %v", err)
}
- token, err = s.db.createLinkToken(userID)
+ token, err = s.store.createLinkToken(userID)
if err != nil {
t.Fatalf("createLinkToken: %v", err)
}
@@ -106,6 +106,12 @@ func TestBlobPutGet304(t *testing.T) {
if got := r.Header.Get("ETag"); got != `"1"` {
t.Fatalf("PUT ETag: want %q, got %q", `"1"`, got)
}
+ // The response's version is (still) a bare JSON number on the wire —
+ // Phase 0 item 4 of #550 changes it to a string, deliberately, not by
+ // accident of a storage refactor.
+ if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), `"version":1`) {
+ t.Fatalf("PUT body should carry version as a JSON number, got %s", body)
+ }
r = do(t, "GET", ts.URL+"/api/blob", tok, nil, nil)
if r.StatusCode != http.StatusOK {
@@ -177,13 +183,15 @@ func TestBlobVersionHistory(t *testing.T) {
t.Fatalf("PUT %d: want 200, got %d", i+1, r.StatusCode)
}
}
- var n, min int
- s.db.sql.QueryRow("SELECT COUNT(*), MIN(version) FROM blobs WHERE user_id = ?", uid).Scan(&n, &min)
- if n != blobKeepVersions {
- t.Fatalf("history: want %d versions kept, got %d", blobKeepVersions, n)
+ versions, err := s.store.listBlobVersions(uid)
+ if err != nil {
+ t.Fatalf("listBlobVersions: %v", err)
+ }
+ if len(versions) != blobKeepVersions {
+ t.Fatalf("history: want %d versions kept, got %d", blobKeepVersions, len(versions))
}
- if min != 3 { // 7 writes, keep 5 -> versions 3..7
- t.Fatalf("history: want oldest kept = 3, got %d", min)
+ if oldest := versions[len(versions)-1]; oldest != "3" { // 7 writes, keep 5 -> versions 3..7
+ t.Fatalf("history: want oldest kept = 3, got %s", oldest)
}
}
@@ -192,7 +200,7 @@ func itoa(i int) string { return strconv.Itoa(i) }
func TestDevicesListing(t *testing.T) {
ts, s := newTestServer(t)
uid, tok := seedUser(t, s)
- tok2, _ := s.db.createLinkToken(uid) // a second device
+ tok2, _ := s.store.createLinkToken(uid) // a second device
if r := do(t, "GET", ts.URL+"/api/devices", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
t.Fatalf("no token: want 401, got %d", r.StatusCode)
@@ -234,7 +242,7 @@ func TestDestructiveOpsRequireCeremony(t *testing.T) {
uid, tok := seedUser(t, s)
do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("precious"))
- devices, _ := s.db.listDevices(uid)
+ devices, _ := s.store.listDevices(uid)
devID := devices[0].ID
// A link token alone must never authorise a destructive op — that's the
@@ -247,31 +255,33 @@ func TestDestructiveOpsRequireCeremony(t *testing.T) {
}
// A STALE proof (older than the freshness window) is refused.
- stale, err := s.db.createCeremonyProof(uid)
+ stale, err := s.store.createCeremonyProof(uid)
if err != nil {
t.Fatalf("proof: %v", err)
}
- s.db.sql.Exec("UPDATE ceremony_proofs SET created_at = created_at - 600 WHERE proof_hash = ?", hashToken(stale))
+ // Backdating needs the raw table — a SQLite-impl detail the interface
+ // deliberately doesn't expose, hence the type assertion.
+ s.store.(*sqliteStore).sql.Exec("UPDATE ceremony_proofs SET created_at = created_at - 600 WHERE proof_hash = ?", hashToken(stale))
if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": stale}, nil); r.StatusCode != http.StatusUnauthorized {
t.Fatalf("stale proof: want 401, got %d", r.StatusCode)
}
// A fresh proof from ANOTHER user can't touch this user's devices.
other, _ := seedUser(t, s)
- otherProof, _ := s.db.createCeremonyProof(other)
+ otherProof, _ := s.store.createCeremonyProof(other)
if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
}
// A fresh proof for the right user works.
- proof, _ := s.db.createCeremonyProof(uid)
+ proof, _ := s.store.createCeremonyProof(uid)
if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
t.Fatalf("device delete with fresh proof: want 200, got %d", r.StatusCode)
}
if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
t.Fatalf("blob delete with fresh proof: want 200, got %d", r.StatusCode)
}
- if _, _, found, _ := s.db.getBlob(uid); found {
+ if _, _, found, _ := s.store.getBlob(uid); found {
t.Fatalf("blob (and history) should be gone after ceremony-gated delete")
}
}
@@ -310,8 +320,8 @@ func TestRegisterBeginOptions(t *testing.T) {
// With a FRESH ceremony proof, begin targets the existing account: its
// credentials are excluded so the same authenticator can't double-enrol.
uid, _ := seedUser(t, s)
- creds, _ := s.db.credentialsFor(uid)
- proof, _ := s.db.createCeremonyProof(uid)
+ creds, _ := s.store.credentialsFor(uid)
+ proof, _ := s.store.createCeremonyProof(uid)
r = do(t, "POST", ts.URL+"/api/register/begin", "", map[string]string{"X-Ceremony-Proof": proof}, nil)
if r.StatusCode != http.StatusOK {
t.Fatalf("begin with proof: want 200, got %d", r.StatusCode)
@@ -346,10 +356,10 @@ func TestCredentialDeleteRequiresCeremony(t *testing.T) {
uid, tok := seedUser(t, s)
// A second credential, as register/finish with a fresh proof would store it.
second := &webauthn.Credential{ID: []byte("cred-second"), PublicKey: []byte("pk2")}
- if err := s.db.insertCredential(uid, second); err != nil {
+ if err := s.store.insertCredential(uid, second); err != nil {
t.Fatalf("insertCredential: %v", err)
}
- creds, _ := s.db.credentialsFor(uid)
+ creds, _ := s.store.credentialsFor(uid)
firstID := base64.RawURLEncoding.EncodeToString(creds[0].ID)
secondID := base64.RawURLEncoding.EncodeToString(second.ID)
@@ -359,12 +369,12 @@ func TestCredentialDeleteRequiresCeremony(t *testing.T) {
}
// Nor may another user's fresh proof.
other, _ := seedUser(t, s)
- otherProof, _ := s.db.createCeremonyProof(other)
+ otherProof, _ := s.store.createCeremonyProof(other)
if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
}
// A fresh proof for the owner works.
- proof, _ := s.db.createCeremonyProof(uid)
+ proof, _ := s.store.createCeremonyProof(uid)
if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
t.Fatalf("delete with fresh proof: want 200, got %d", r.StatusCode)
}
@@ -373,7 +383,7 @@ func TestCredentialDeleteRequiresCeremony(t *testing.T) {
if r := do(t, "DELETE", ts.URL+"/api/credentials/"+firstID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusConflict {
t.Fatalf("delete last credential: want 409, got %d", r.StatusCode)
}
- if left, _ := s.db.credentialsFor(uid); len(left) != 1 {
+ if left, _ := s.store.credentialsFor(uid); len(left) != 1 {
t.Fatalf("want exactly 1 credential left, got %d", len(left))
}
}
diff --git a/home/main.go b/home/main.go
index 8e2a93d..504f11e 100644
--- a/home/main.go
+++ b/home/main.go
@@ -9,6 +9,7 @@ package main
import (
"embed"
"encoding/json"
+ "errors"
"flag"
"fmt"
"io"
@@ -18,7 +19,6 @@ import (
"net/http"
"net/url"
"os"
- "strconv"
"strings"
"sync"
"time"
@@ -36,8 +36,10 @@ const maxBlobBytes = 18 << 10
var webFS embed.FS
type Server struct {
- db *DB
- wa *waRelyingParty
+ // store is the persistence seam (vaultStore, store.go) — SQLite today, an
+ // object store later; handlers never know which.
+ store vaultStore
+ wa *waRelyingParty
// sessions holds in-flight WebAuthn ceremony state (challenge -> finish).
// In-memory on purpose: ceremonies live seconds, and losing them on a
// redeploy just means re-tapping the button.
@@ -64,11 +66,11 @@ func main() {
"caller token for the space provisioner (env: LCHAT_CLOUD_TOKEN)")
flag.Parse()
- db, err := openDB(*dbPath)
+ store, err := openSQLiteStore(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
- s, err := newServer(db, *origin)
+ s, err := newServer(store, *origin)
if err != nil {
log.Fatalf("server: %v", err)
}
@@ -109,7 +111,7 @@ func serve(addr string, h http.Handler) error {
return http.Serve(ln, h)
}
-func newServer(db *DB, origin string) (*Server, error) {
+func newServer(store vaultStore, origin string) (*Server, error) {
u, err := url.Parse(origin)
if err != nil {
return nil, err
@@ -131,13 +133,13 @@ func newServer(db *DB, origin string) (*Server, error) {
return nil, err
}
return &Server{
- db: db,
+ store: store,
enrollPage: enrollPage,
restorePage: restorePage,
- wa: wa,
- sessions: newSessionStore(),
- limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
- page: page,
+ wa: wa,
+ sessions: newSessionStore(),
+ limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
+ page: page,
}, nil
}
@@ -290,13 +292,14 @@ func bearerToken(r *http.Request) string {
}
// linkUser authenticates a request by device link token; ""/false when absent
-// or unknown.
+// or unknown. Hashing happens here — the store is keyed by hash and never sees
+// a presented plaintext.
func (s *Server) linkUser(r *http.Request) (string, bool) {
tok := bearerToken(r)
if tok == "" {
return "", false
}
- return s.db.linkTokenUser(tok)
+ return s.store.linkTokenUser(hashToken(tok))
}
// ceremonyUser authenticates a request by fresh ceremony proof (the
@@ -307,7 +310,7 @@ func (s *Server) ceremonyUser(r *http.Request) (string, bool) {
if proof == "" {
return "", false
}
- return s.db.ceremonyUser(proof)
+ return s.store.ceremonyUser(hashToken(proof))
}
// ---- blob -------------------------------------------------------------------
@@ -318,7 +321,7 @@ func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- data, version, found, err := s.db.getBlob(uid)
+ data, version, found, err := s.store.getBlob(uid)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
@@ -327,7 +330,7 @@ func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no blob", http.StatusNotFound)
return
}
- etag := `"` + strconv.FormatInt(version, 10) + `"`
+ etag := `"` + version + `"`
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
@@ -351,11 +354,6 @@ func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "If-Match required", http.StatusPreconditionRequired)
return
}
- want, err := strconv.ParseInt(ifMatch, 10, 64)
- if err != nil || want < 0 {
- http.Error(w, "bad If-Match", http.StatusBadRequest)
- return
- }
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes))
if err != nil {
http.Error(w, "blob too large", http.StatusRequestEntityTooLarge)
@@ -365,18 +363,24 @@ func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "empty blob", http.StatusBadRequest)
return
}
- newVersion, conflict, err := s.db.putBlob(uid, data, want)
+ newVersion, conflict, err := s.store.putBlob(uid, data, ifMatch)
+ if errors.Is(err, errBadVersion) {
+ http.Error(w, "bad If-Match", http.StatusBadRequest)
+ return
+ }
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if conflict {
- w.Header().Set("ETag", `"`+strconv.FormatInt(newVersion, 10)+`"`)
+ w.Header().Set("ETag", `"`+newVersion+`"`)
http.Error(w, "version conflict", http.StatusConflict)
return
}
- w.Header().Set("ETag", `"`+strconv.FormatInt(newVersion, 10)+`"`)
- writeJSON(w, http.StatusOK, map[string]any{"version": newVersion})
+ w.Header().Set("ETag", `"`+newVersion+`"`)
+ // The version is a JSON number today (json.Number keeps the wire byte-for-
+ // byte identical); Phase 0 item 4 of #550 switches it to a plain string.
+ writeJSON(w, http.StatusOK, map[string]any{"version": json.Number(newVersion)})
}
func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) {
@@ -385,7 +389,7 @@ func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
return
}
- if err := s.db.deleteBlob(uid); err != nil {
+ if err := s.store.deleteBlob(uid); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
@@ -400,12 +404,12 @@ func (s *Server) listDevicesHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- devices, err := s.db.listDevices(uid)
+ devices, err := s.store.listDevices(uid)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
- current := s.db.linkTokenID(bearerToken(r))
+ current := s.store.linkTokenID(hashToken(bearerToken(r)))
for i := range devices {
devices[i].Current = devices[i].ID == current
}
@@ -418,7 +422,7 @@ func (s *Server) deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
return
}
- deleted, err := s.db.deleteDevice(uid, r.PathValue("id"))
+ deleted, err := s.store.deleteDevice(uid, r.PathValue("id"))
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
@@ -438,7 +442,7 @@ func (s *Server) deleteCredentialHandler(w http.ResponseWriter, r *http.Request)
http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
return
}
- deleted, last, err := s.db.deleteCredential(uid, r.PathValue("id"))
+ deleted, last, err := s.store.deleteCredential(uid, r.PathValue("id"))
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
diff --git a/home/provision.go b/home/provision.go
index 10fc4df..31c576e 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -72,7 +72,7 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
// needs a space you were invited into — so this keeps creation invite-gated
// (not open to the world) without the server ever reading the E2E-encrypted
// list. The client hides the button too; this is the boundary behind it.
- if _, _, hasBlob, err := s.db.getBlob(uid); err != nil {
+ if _, _, hasBlob, err := s.store.getBlob(uid); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": "create_failed", "message": "could not check your account",
})
diff --git a/home/provision_test.go b/home/provision_test.go
index b9b342e..96b8e67 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -44,7 +44,7 @@ func postSpaces(t *testing.T, ts *httptest.Server, token string) *http.Response
func seedMember(t *testing.T, s *Server) (userID, token string) {
t.Helper()
userID, token = seedUser(t, s)
- if _, _, err := s.db.putBlob(userID, []byte("vault"), 0); err != nil {
+ if _, _, err := s.store.putBlob(userID, []byte("vault"), "0"); err != nil {
t.Fatalf("putBlob: %v", err)
}
return userID, token
@@ -158,7 +158,7 @@ func TestCreateSpaceRequiresExistingSpace(t *testing.T) {
}
// Once they have a saved space (a blob), creation goes through.
- if _, _, err := s.db.putBlob(uid, []byte("vault"), 0); err != nil {
+ if _, _, err := s.store.putBlob(uid, []byte("vault"), "0"); err != nil {
t.Fatal(err)
}
if resp2 := postSpaces(t, ts, token); resp2.StatusCode != http.StatusCreated {
@@ -247,7 +247,7 @@ func TestDeleteSpaceRequiresFreshCeremony(t *testing.T) {
}
// A fresh ceremony proof authorises it, and home forwards the requester uid.
- proof, err := s.db.createCeremonyProof(uid)
+ proof, err := s.store.createCeremonyProof(uid)
if err != nil {
t.Fatal(err)
}
diff --git a/home/store.go b/home/store.go
new file mode 100644
index 0000000..10ee11e
--- /dev/null
+++ b/home/store.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+ "errors"
+ "time"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// blobKeepVersions is how many blob versions the store retains per user —
+// overwrite protection against a stolen link token trashing the backup (the
+// token can write, but the previous versions survive until pushed out).
+const blobKeepVersions = 5
+
+// ceremonyWindow is how long a ceremony proof stays "fresh". Destructive ops
+// (revoking devices, deleting the blob) demand a passkey ceremony completed
+// within this window, so a stolen link token can never reduce access.
+const ceremonyWindow = 5 * time.Minute
+
+// errBadVersion reports an If-Match version string the store cannot even
+// interpret (as opposed to one that merely mismatches, which is a conflict).
+// The handler maps it to 400; a mismatch maps to 409.
+var errBadVersion = errors.New("bad version")
+
+// vaultStore is home's persistence seam. Its method shapes deliberately mirror
+// an object store — point reads by key, conditional writes (CAS on an opaque
+// version), keep-last-N versioning, prefix deletes, and write-once pointers —
+// so that a bucket can implement it directly (issue #550: blobs/<uid> for the
+// vault, users/<uid> for metadata, creds/<credId> and tokens/<sha256> as
+// pointers back to the uid). SQLite is implementation #1 and the zero-config
+// default; the interface exists so an s3Store can be implementation #2 without
+// the handlers changing.
+//
+// Versions are OPAQUE STRINGS at this boundary. The wire contract is already
+// ETag-opaque; a backend may use integers, ETags, or generation numbers
+// internally, but callers only ever echo a version they were handed — plus the
+// one universal sentinel "0", meaning "no blob yet" (create-only).
+//
+// Pointers are never authoritative: a backend resolving a credential id or
+// token hash must verify the target user still lists it, so a stale pointer
+// left behind by a revocation denies by construction. In SQLite that falls out
+// of single-table deletes; an object store must uphold it explicitly.
+type vaultStore interface {
+ // ---- the vault blob (blobs/<uid>, versioned, CAS) ----
+
+ // getBlob is a point read of the latest version. found=false when the user
+ // has no blob.
+ getBlob(uid string) (data []byte, version string, found bool, err error)
+ // putBlob writes a new version iff ifMatch names the current one ("0" =
+ // create-only, i.e. no blob may exist yet). On mismatch it reports
+ // conflict=true and returns the CURRENT version ("0" when none exists) so
+ // the caller can 409 + ETag; the client merges and retries. Only the last
+ // blobKeepVersions versions are retained. An ifMatch the store cannot
+ // interpret at all returns errBadVersion.
+ putBlob(uid string, data []byte, ifMatch string) (version string, conflict bool, err error)
+ // listBlobVersions returns the retained version identifiers, newest first —
+ // the observable face of the keep-last-N contract.
+ listBlobVersions(uid string) ([]string, error)
+ // deleteBlob is the prefix delete: every retained version goes, history
+ // included — that's the point of the ceremony gate in front of it.
+ deleteBlob(uid string) error
+
+ // ---- user metadata (users/<uid>) ----
+
+ createUser(uid string) error
+ // credentialsFor returns the user's WebAuthn credentials in enrolment order.
+ credentialsFor(uid string) ([]webauthn.Credential, error)
+ // insertCredential stores a verified credential and the creds/<credId>
+ // pointer back to its user.
+ insertCredential(uid string, cred *webauthn.Credential) error
+ // updateCredential persists post-ceremony state (sign count, backup flags),
+ // located by credential id.
+ updateCredential(cred *webauthn.Credential) error
+ // deleteCredential removes one passkey. It refuses to remove the LAST one
+ // (last=true, nothing deleted): the blob would be orphaned while staying
+ // live — the explicit wind-down path is the ceremony-gated blob delete.
+ // deleted=false with last=false means the id is unknown (or someone else's).
+ deleteCredential(uid, credID string) (deleted, last bool, err error)
+ // credentialUser resolves the creds/<credId> pointer: which user enrolled
+ // this credential. ok=false for an unknown (or revoked) id.
+ credentialUser(credID string) (uid string, ok bool)
+
+ // ---- device link tokens (tokens/<sha256> pointers) ----
+
+ // createLinkToken mints a bearer token for a user and returns the plaintext
+ // (shown/stored client-side once; only the hash persists in the store).
+ createLinkToken(uid string) (token string, err error)
+ // linkTokenUser resolves a token HASH to its user and bumps last_seen.
+ // Callers hash the presented plaintext (hashToken) — the store is keyed by
+ // hash and never sees a live token after minting.
+ linkTokenUser(tokenHash string) (uid string, ok bool)
+ // linkTokenID returns the device id behind a token hash, so the devices
+ // list can mark "this device". "" when unknown.
+ linkTokenID(tokenHash string) string
+ listDevices(uid string) ([]Device, error)
+ // deleteDevice revokes one of the user's link tokens. ok=false when the id
+ // doesn't exist or belongs to someone else.
+ deleteDevice(uid, id string) (ok bool, err error)
+
+ // ---- ceremony proofs (short-lived; slated to become stateless HMACs) ----
+
+ // createCeremonyProof mints the short-lived proof returned by a verified
+ // passkey ceremony; ceremonyUser resolves a proof HASH, honouring the
+ // ceremonyWindow freshness cut-off.
+ createCeremonyProof(uid string) (proof string, err error)
+ ceremonyUser(proofHash string) (uid string, ok bool)
+}
+
+// Device is link-token metadata for the devices list — ids and timestamps
+// only, never token values (or anything derived from them).
+type Device struct {
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+ LastSeen int64 `json:"last_seen"`
+ Current bool `json:"current,omitempty"`
+}
diff --git a/home/store_test.go b/home/store_test.go
new file mode 100644
index 0000000..740ceb9
--- /dev/null
+++ b/home/store_test.go
@@ -0,0 +1,330 @@
+package main
+
+// The vaultStore contract, tested against the interface — not any one backend.
+// A future s3Store passes by adding one TestXxxVaultStore entry point that
+// calls testVaultStore with its own constructor; nothing here may reach into
+// backend internals (versions are asserted opaque: only "0" and round-tripped
+// values are ever compared).
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "testing"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+func TestSQLiteVaultStore(t *testing.T) {
+ testVaultStore(t, func(t *testing.T) vaultStore {
+ st, err := openSQLiteStore(t.TempDir() + "/store.db")
+ if err != nil {
+ t.Fatalf("openSQLiteStore: %v", err)
+ }
+ return st
+ })
+}
+
+// SQLite-specific: a version string the backend can't interpret at all (never
+// one of ours) is errBadVersion, which the handler maps to 400 — distinct from
+// a mismatch, which is a conflict. Other backends may treat garbage as a plain
+// mismatch, so this isn't part of the shared contract.
+func TestSQLitePutBlobBadVersion(t *testing.T) {
+ st, err := openSQLiteStore(t.TempDir() + "/store.db")
+ if err != nil {
+ t.Fatalf("openSQLiteStore: %v", err)
+ }
+ for _, bad := range []string{"abc", "-1", ""} {
+ if _, _, err := st.putBlob("u", []byte("x"), bad); !errors.Is(err, errBadVersion) {
+ t.Fatalf("putBlob(ifMatch=%q): want errBadVersion, got %v", bad, err)
+ }
+ }
+}
+
+// testVaultStore runs the shared contract suite against one backend.
+func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) {
+ mkCred := func(id string) *webauthn.Credential {
+ return &webauthn.Credential{
+ ID: []byte(id),
+ PublicKey: []byte("pk-" + id),
+ Authenticator: webauthn.Authenticator{
+ AAGUID: make([]byte, 16),
+ SignCount: 1,
+ },
+ }
+ }
+
+ t.Run("BlobCAS", func(t *testing.T) {
+ st := newStore(t)
+ if _, _, found, err := st.getBlob("u1"); err != nil || found {
+ t.Fatalf("getBlob empty: want !found, got found=%v err=%v", found, err)
+ }
+
+ // A CAS write against a version that doesn't exist yet must lose, and
+ // report the "0" sentinel as the current version.
+ if cur, conflict, err := st.putBlob("u1", []byte("x"), "5"); err != nil || !conflict || cur != "0" {
+ t.Fatalf("putBlob on empty with stale version: want conflict + current \"0\", got (%q, %v, %v)", cur, conflict, err)
+ }
+
+ // "0" is create-only: it claims "no blob yet".
+ v1, conflict, err := st.putBlob("u1", []byte("one"), "0")
+ if err != nil || conflict {
+ t.Fatalf("create putBlob: %v conflict=%v", err, conflict)
+ }
+ if v1 == "" || v1 == "0" {
+ t.Fatalf("new version must be a non-sentinel opaque string, got %q", v1)
+ }
+ data, got, found, err := st.getBlob("u1")
+ if err != nil || !found || !bytes.Equal(data, []byte("one")) || got != v1 {
+ t.Fatalf("getBlob after create: data=%q version=%q found=%v err=%v (want version %q)", data, got, found, err, v1)
+ }
+
+ // Once a blob exists, "0" must lose (create-only), naming the current version.
+ if cur, conflict, _ := st.putBlob("u1", []byte("race"), "0"); !conflict || cur != v1 {
+ t.Fatalf("second create: want conflict + current %q, got (%q, %v)", v1, cur, conflict)
+ }
+ // So must any stale non-current version.
+ v2, conflict, err := st.putBlob("u1", []byte("two"), v1)
+ if err != nil || conflict {
+ t.Fatalf("CAS update: %v conflict=%v", err, conflict)
+ }
+ if cur, conflict, _ := st.putBlob("u1", []byte("stale"), v1); !conflict || cur != v2 {
+ t.Fatalf("stale CAS: want conflict + current %q, got (%q, %v)", v2, cur, conflict)
+ }
+ // The loser's write never landed.
+ if data, _, _, _ := st.getBlob("u1"); !bytes.Equal(data, []byte("two")) {
+ t.Fatalf("conflicted write must not land, got %q", data)
+ }
+
+ // Users are isolated: u1's blob says nothing about u2's.
+ if _, _, found, _ := st.getBlob("u2"); found {
+ t.Fatalf("getBlob other user: want !found")
+ }
+ })
+
+ t.Run("BlobKeepLastN", func(t *testing.T) {
+ st := newStore(t)
+ version := "0"
+ var wrote []string // every version handed out, oldest first
+ for i := 0; i < blobKeepVersions+2; i++ {
+ v, conflict, err := st.putBlob("u1", []byte{byte(i)}, version)
+ if err != nil || conflict {
+ t.Fatalf("put %d: %v conflict=%v", i, err, conflict)
+ }
+ version = v
+ wrote = append(wrote, v)
+ }
+ versions, err := st.listBlobVersions("u1")
+ if err != nil {
+ t.Fatalf("listBlobVersions: %v", err)
+ }
+ if len(versions) != blobKeepVersions {
+ t.Fatalf("want %d versions retained, got %d", blobKeepVersions, len(versions))
+ }
+ // Newest first, and exactly the last blobKeepVersions writes survive.
+ for i, v := range versions {
+ if want := wrote[len(wrote)-1-i]; v != want {
+ t.Fatalf("versions[%d]: want %q, got %q", i, want, v)
+ }
+ }
+ })
+
+ t.Run("BlobDeleteIsPrefixDelete", func(t *testing.T) {
+ st := newStore(t)
+ version := "0"
+ for i := 0; i < 3; i++ {
+ v, _, err := st.putBlob("u1", []byte("v"), version)
+ if err != nil {
+ t.Fatalf("put: %v", err)
+ }
+ version = v
+ }
+ if err := st.deleteBlob("u1"); err != nil {
+ t.Fatalf("deleteBlob: %v", err)
+ }
+ // Every version is gone — history included.
+ if _, _, found, _ := st.getBlob("u1"); found {
+ t.Fatalf("blob still readable after delete")
+ }
+ if versions, _ := st.listBlobVersions("u1"); len(versions) != 0 {
+ t.Fatalf("history survives delete: %v", versions)
+ }
+ // And the user is back at the "0" sentinel: create-only works again.
+ if _, conflict, err := st.putBlob("u1", []byte("fresh"), "0"); err != nil || conflict {
+ t.Fatalf("re-create after delete: %v conflict=%v", err, conflict)
+ }
+ })
+
+ t.Run("Credentials", func(t *testing.T) {
+ st := newStore(t)
+ if err := st.createUser("u1"); err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if creds, err := st.credentialsFor("u1"); err != nil || len(creds) != 0 {
+ t.Fatalf("fresh user credentials: want none, got %d err=%v", len(creds), err)
+ }
+ c1, c2 := mkCred("cred-one"), mkCred("cred-two")
+ if err := st.insertCredential("u1", c1); err != nil {
+ t.Fatalf("insertCredential: %v", err)
+ }
+ if err := st.insertCredential("u1", c2); err != nil {
+ t.Fatalf("insertCredential: %v", err)
+ }
+ creds, err := st.credentialsFor("u1")
+ if err != nil || len(creds) != 2 {
+ t.Fatalf("credentialsFor: want 2, got %d err=%v", len(creds), err)
+ }
+ // Enrolment order, with public keys intact.
+ if !bytes.Equal(creds[0].ID, c1.ID) || !bytes.Equal(creds[1].ID, c2.ID) ||
+ !bytes.Equal(creds[0].PublicKey, c1.PublicKey) {
+ t.Fatalf("credentials round-trip mismatch: %+v", creds)
+ }
+
+ // updateCredential persists post-ceremony state, located by credential id.
+ c1.Authenticator.SignCount = 42
+ if err := st.updateCredential(c1); err != nil {
+ t.Fatalf("updateCredential: %v", err)
+ }
+ creds, _ = st.credentialsFor("u1")
+ if creds[0].Authenticator.SignCount != 42 {
+ t.Fatalf("sign count not persisted: %d", creds[0].Authenticator.SignCount)
+ }
+ })
+
+ t.Run("CredentialPointer", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ st.insertCredential("u1", mkCred("cred-one"))
+ credID := b64CredID("cred-one")
+ if uid, ok := st.credentialUser(credID); !ok || uid != "u1" {
+ t.Fatalf("credentialUser: want (u1, true), got (%q, %v)", uid, ok)
+ }
+ if _, ok := st.credentialUser("nope"); ok {
+ t.Fatalf("credentialUser unknown id: want !ok")
+ }
+ })
+
+ t.Run("DeleteCredentialLastGuard", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ st.insertCredential("u1", mkCred("cred-one"))
+ st.insertCredential("u1", mkCred("cred-two"))
+ one, two := b64CredID("cred-one"), b64CredID("cred-two")
+
+ // Unknown id — and someone else's id — is (false, false).
+ if deleted, last, err := st.deleteCredential("u1", "nope"); deleted || last || err != nil {
+ t.Fatalf("delete unknown: want (false,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ if deleted, last, err := st.deleteCredential("u2", one); deleted || last || err != nil {
+ t.Fatalf("delete cross-user: want (false,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ // With two enrolled, one may go…
+ if deleted, last, err := st.deleteCredential("u1", two); !deleted || last || err != nil {
+ t.Fatalf("delete second: want (true,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ // …and the pointer must stop resolving (never authoritative).
+ if _, ok := st.credentialUser(two); ok {
+ t.Fatalf("revoked credential pointer still resolves")
+ }
+ // The LAST credential is refused, and nothing is deleted.
+ if deleted, last, err := st.deleteCredential("u1", one); deleted || !last || err != nil {
+ t.Fatalf("delete last: want (false,true), got (%v,%v,%v)", deleted, last, err)
+ }
+ if creds, _ := st.credentialsFor("u1"); len(creds) != 1 {
+ t.Fatalf("last-guard must leave the credential in place, got %d", len(creds))
+ }
+ })
+
+ t.Run("LinkTokens", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ tok1, err := st.createLinkToken("u1")
+ if err != nil || tok1 == "" {
+ t.Fatalf("createLinkToken: %q %v", tok1, err)
+ }
+ tok2, _ := st.createLinkToken("u1")
+
+ // Resolution is by HASH — the tokens/<sha256> pointer.
+ if uid, ok := st.linkTokenUser(hashToken(tok1)); !ok || uid != "u1" {
+ t.Fatalf("linkTokenUser: want (u1, true), got (%q, %v)", uid, ok)
+ }
+ // The plaintext itself must NOT be a key (the store holds only hashes).
+ if _, ok := st.linkTokenUser(tok1); ok {
+ t.Fatalf("plaintext token resolved — store must be keyed by hash")
+ }
+ if _, ok := st.linkTokenUser(hashToken("wrong")); ok {
+ t.Fatalf("unknown token hash resolved")
+ }
+
+ devices, err := st.listDevices("u1")
+ if err != nil || len(devices) != 2 {
+ t.Fatalf("listDevices: want 2, got %d err=%v", len(devices), err)
+ }
+ for _, d := range devices {
+ if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 {
+ t.Fatalf("device metadata incomplete: %+v", d)
+ }
+ // A device id names a token without deriving from it.
+ if d.ID == tok1 || d.ID == tok2 || d.ID == hashToken(tok1) || d.ID == hashToken(tok2) {
+ t.Fatalf("device id derived from token value")
+ }
+ }
+ if id := st.linkTokenID(hashToken(tok1)); id != devices[0].ID && id != devices[1].ID {
+ t.Fatalf("linkTokenID: %q not among device ids", id)
+ }
+ if st.linkTokenID(hashToken("wrong")) != "" {
+ t.Fatalf("linkTokenID unknown hash: want \"\"")
+ }
+ })
+
+ t.Run("DeleteDeviceRevokes", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ tok, _ := st.createLinkToken("u1")
+ id := st.linkTokenID(hashToken(tok))
+
+ // Someone else can't revoke it.
+ if ok, err := st.deleteDevice("u2", id); ok || err != nil {
+ t.Fatalf("cross-user deleteDevice: want false, got %v err=%v", ok, err)
+ }
+ if ok, err := st.deleteDevice("u1", "nope"); ok || err != nil {
+ t.Fatalf("unknown deleteDevice: want false, got %v err=%v", ok, err)
+ }
+ if ok, err := st.deleteDevice("u1", id); !ok || err != nil {
+ t.Fatalf("deleteDevice: want true, got %v err=%v", ok, err)
+ }
+ // Revocation must be effective at resolution time — a stale pointer left
+ // behind by a backend must deny, not resolve.
+ if _, ok := st.linkTokenUser(hashToken(tok)); ok {
+ t.Fatalf("revoked token still resolves")
+ }
+ if devices, _ := st.listDevices("u1"); len(devices) != 0 {
+ t.Fatalf("revoked device still listed")
+ }
+ })
+
+ t.Run("CeremonyProofs", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ proof, err := st.createCeremonyProof("u1")
+ if err != nil || proof == "" {
+ t.Fatalf("createCeremonyProof: %q %v", proof, err)
+ }
+ if uid, ok := st.ceremonyUser(hashToken(proof)); !ok || uid != "u1" {
+ t.Fatalf("ceremonyUser: want (u1, true), got (%q, %v)", uid, ok)
+ }
+ // Hash-keyed, like link tokens.
+ if _, ok := st.ceremonyUser(proof); ok {
+ t.Fatalf("plaintext proof resolved — store must be keyed by hash")
+ }
+ if _, ok := st.ceremonyUser(hashToken("wrong")); ok {
+ t.Fatalf("unknown proof hash resolved")
+ }
+ })
+}
+
+// b64CredID renders a raw credential id the way stores key it (base64url,
+// matching webauthn.Credential JSON round-trips).
+func b64CredID(raw string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
+}
diff --git a/home/webauthn.go b/home/webauthn.go
index 88feaf5..8dc7fa6 100644
--- a/home/webauthn.go
+++ b/home/webauthn.go
@@ -154,7 +154,7 @@ func (s *Server) registerBegin(w http.ResponseWriter, r *http.Request) {
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
}
if existing {
- creds, err := s.db.credentialsFor(userID)
+ creds, err := s.store.credentialsFor(userID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
@@ -187,7 +187,7 @@ func (s *Server) registerFinish(w http.ResponseWriter, r *http.Request) {
}
u := &homeUser{id: ent.userID}
if ent.existing {
- creds, err := s.db.credentialsFor(ent.userID)
+ creds, err := s.store.credentialsFor(ent.userID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
@@ -200,12 +200,12 @@ func (s *Server) registerFinish(w http.ResponseWriter, r *http.Request) {
return
}
if !ent.existing {
- if err := s.db.createUser(ent.userID); err != nil {
+ if err := s.store.createUser(ent.userID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
}
- if err := s.db.insertCredential(ent.userID, cred); err != nil {
+ if err := s.store.insertCredential(ent.userID, cred); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
@@ -241,7 +241,7 @@ func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) {
var userID string
cred, err := s.wa.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
uid := string(userHandle)
- creds, err := s.db.credentialsFor(uid)
+ creds, err := s.store.credentialsFor(uid)
if err != nil || len(creds) == 0 {
return nil, errors.New("unknown user")
}
@@ -253,7 +253,7 @@ func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) {
return
}
// Persist the updated sign count / backup flags for clone detection.
- if err := s.db.updateCredential(cred); err != nil {
+ if err := s.store.updateCredential(cred); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
@@ -265,12 +265,12 @@ func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) {
// "I just did Face ID" credential destructive ops demand), and tell the client
// which credential id it used, so it can pick its wrapped DEK out of the blob.
func (s *Server) finishCeremony(w http.ResponseWriter, userID string, cred *webauthn.Credential) {
- tok, err := s.db.createLinkToken(userID)
+ tok, err := s.store.createLinkToken(userID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
- proof, err := s.db.createCeremonyProof(userID)
+ proof, err := s.store.createCeremonyProof(userID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return