package main import ( "bytes" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strconv" "strings" "testing" "time" "github.com/go-webauthn/webauthn/webauthn" ) func newTestServer(t *testing.T) (*httptest.Server, *Server) { t.Helper() store, err := openSQLiteStore(t.TempDir() + "/home.db") if err != nil { t.Fatalf("openSQLiteStore: %v", err) } s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret")) if err != nil { t.Fatalf("newServer: %v", err) } ts := httptest.NewServer(s.handler()) t.Cleanup(ts.Close) return ts, s } // seedUser creates a user with one credential (a real webauthn.Credential, as // a verified ceremony would store) and a device link token — the state a // register/finish leaves behind, without simulating an authenticator. func seedUser(t *testing.T, s *Server) (userID, token string) { t.Helper() userID, err := randToken() if err != nil { t.Fatalf("randToken: %v", err) } if err := s.store.createUser(userID); err != nil { t.Fatalf("createUser: %v", err) } cred := &webauthn.Credential{ ID: []byte("cred-" + userID[:8]), PublicKey: []byte("test-cose-public-key"), Authenticator: webauthn.Authenticator{ AAGUID: make([]byte, 16), SignCount: 1, }, } if err := s.store.insertCredential(userID, cred); err != nil { t.Fatalf("insertCredential: %v", err) } token, err = s.store.createLinkToken(userID) if err != nil { t.Fatalf("createLinkToken: %v", err) } return userID, token } func do(t *testing.T, method, url, token string, headers map[string]string, body []byte) *http.Response { t.Helper() req, _ := http.NewRequest(method, url, bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } for k, v := range headers { req.Header.Set(k, v) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } return resp } func TestBlobAuthGating(t *testing.T) { ts, _ := newTestServer(t) if r := do(t, "GET", ts.URL+"/api/blob", "", nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("GET no token: want 401, got %d", r.StatusCode) } if r := do(t, "PUT", ts.URL+"/api/blob", "", map[string]string{"If-Match": `"0"`}, []byte("x")); r.StatusCode != http.StatusUnauthorized { t.Fatalf("PUT no token: want 401, got %d", r.StatusCode) } if r := do(t, "GET", ts.URL+"/api/blob", "wrong", nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("GET bad token: want 401, got %d", r.StatusCode) } } func TestBlobPutGet304(t *testing.T) { ts, s := newTestServer(t) _, tok := seedUser(t, s) // No blob yet. if r := do(t, "GET", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusNotFound { t.Fatalf("empty GET: want 404, got %d", r.StatusCode) } // First write claims version 0 -> 1. blob := []byte("opaque-ciphertext") r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, blob) if r.StatusCode != http.StatusOK { t.Fatalf("PUT: want 200, got %d", r.StatusCode) } if got := r.Header.Get("ETag"); got != `"1"` { t.Fatalf("PUT ETag: want %q, got %q", `"1"`, got) } // The response's version is an opaque JSON string, like the ETag (#550 // Phase 0) — a future backend's non-numeric versions must not need a wire // change. if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), `"version":"1"`) { t.Fatalf("PUT body should carry version as a JSON string, got %s", body) } r = do(t, "GET", ts.URL+"/api/blob", tok, nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("GET: want 200, got %d", r.StatusCode) } if got := r.Header.Get("ETag"); got != `"1"` { t.Fatalf("GET ETag: want %q, got %q", `"1"`, got) } body, _ := io.ReadAll(r.Body) if !bytes.Equal(body, blob) { t.Fatalf("GET body mismatch") } // Conditional read: matching version returns 304 with no body. r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"1"`}, nil) if r.StatusCode != http.StatusNotModified { t.Fatalf("If-None-Match: want 304, got %d", r.StatusCode) } // A stale version still gets the full blob. r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"0"`}, nil) if r.StatusCode != http.StatusOK { t.Fatalf("stale If-None-Match: want 200, got %d", r.StatusCode) } } func TestBlobVersionConflict(t *testing.T) { ts, s := newTestServer(t) _, tok := seedUser(t, s) if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1")); r.StatusCode != http.StatusOK { t.Fatalf("first PUT: want 200, got %d", r.StatusCode) } // A second writer that still believes version 0 must lose. r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1-race")) if r.StatusCode != http.StatusConflict { t.Fatalf("stale PUT: want 409, got %d", r.StatusCode) } if got := r.Header.Get("ETag"); got != `"1"` { t.Fatalf("conflict ETag should name the current version: want %q, got %q", `"1"`, got) } // If-Match is mandatory: an unconditional overwrite is not a thing. if r := do(t, "PUT", ts.URL+"/api/blob", tok, nil, []byte("x")); r.StatusCode != http.StatusPreconditionRequired { t.Fatalf("missing If-Match: want 428, got %d", r.StatusCode) } } func TestBlobSizeCap(t *testing.T) { ts, s := newTestServer(t) _, tok := seedUser(t, s) big := make([]byte, maxBlobBytes+1) r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, big) if r.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("oversize PUT: want 413, got %d", r.StatusCode) } // At the cap is fine. ok := make([]byte, maxBlobBytes) if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, ok); r.StatusCode != http.StatusOK { t.Fatalf("cap-size PUT: want 200, got %d", r.StatusCode) } } func TestBlobVersionHistory(t *testing.T) { ts, s := newTestServer(t) uid, tok := seedUser(t, s) for i := 0; i < blobKeepVersions+2; i++ { r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"` + itoa(i) + `"`}, []byte("v"+itoa(i+1))) if r.StatusCode != http.StatusOK { t.Fatalf("PUT %d: want 200, got %d", i+1, r.StatusCode) } } 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 oldest := versions[len(versions)-1]; oldest != "3" { // 7 writes, keep 5 -> versions 3..7 t.Fatalf("history: want oldest kept = 3, got %s", oldest) } } 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.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) } r := do(t, "GET", ts.URL+"/api/devices", tok, nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("list: want 200, got %d", r.StatusCode) } raw, _ := io.ReadAll(r.Body) // Token values (or hashes) must never appear in the listing. for _, secret := range []string{tok, tok2, hashToken(tok), hashToken(tok2)} { if strings.Contains(string(raw), secret) { t.Fatalf("devices listing leaks a token value") } } var out struct{ Devices []Device } if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("bad json: %v", err) } if len(out.Devices) != 2 { t.Fatalf("want 2 devices, got %d", len(out.Devices)) } currents := 0 for _, d := range out.Devices { if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 { t.Fatalf("device metadata incomplete: %+v", d) } if d.Current { currents++ } } if currents != 1 { t.Fatalf("exactly one device should be marked current, got %d", currents) } } func TestDestructiveOpsRequireCeremony(t *testing.T) { ts, s := newTestServer(t) uid, tok := seedUser(t, s) do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("precious")) devices, _ := s.store.listDevices(uid) devID := devices[0].ID // A link token alone must never authorise a destructive op — that's the // whole point of the proof mechanism (a stolen token can't lock you out). if r := do(t, "DELETE", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("blob delete with link token: want 401, got %d", r.StatusCode) } if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("device delete with link token: want 401, got %d", r.StatusCode) } // A STALE proof (past its embedded expiry) is refused. stale := s.keys.mintCeremonyProofAt(uid, time.Now().Add(-time.Minute)) 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.keys.mintCeremonyProof(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.keys.mintCeremonyProof(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.store.getBlob(uid); found { t.Fatalf("blob (and history) should be gone after ceremony-gated delete") } } func TestRegisterBeginOptions(t *testing.T) { ts, s := newTestServer(t) r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("begin: want 200, got %d", r.StatusCode) } var out struct { Session string `json:"session"` Options struct { PublicKey struct { Challenge string `json:"challenge"` AuthenticatorSelection struct { ResidentKey string `json:"residentKey"` } `json:"authenticatorSelection"` ExcludeCredentials []struct { ID string `json:"id"` } `json:"excludeCredentials"` } `json:"publicKey"` } `json:"options"` } if err := json.NewDecoder(r.Body).Decode(&out); err != nil { t.Fatalf("bad json: %v", err) } if out.Session == "" || out.Options.PublicKey.Challenge == "" { t.Fatalf("begin response missing session/challenge") } // Login is discoverable-only, so registration must demand a resident key. if out.Options.PublicKey.AuthenticatorSelection.ResidentKey != "required" { t.Fatalf("residentKey: want required, got %q", out.Options.PublicKey.AuthenticatorSelection.ResidentKey) } // 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.store.credentialsFor(uid) proof := s.keys.mintCeremonyProof(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) } if err := json.NewDecoder(r.Body).Decode(&out); err != nil { t.Fatalf("bad json: %v", err) } want := base64.RawURLEncoding.EncodeToString(creds[0].ID) if len(out.Options.PublicKey.ExcludeCredentials) != 1 || out.Options.PublicKey.ExcludeCredentials[0].ID != want { t.Fatalf("expected the existing credential to be excluded, got %+v", out.Options.PublicKey.ExcludeCredentials) } } // A link token must never open an add-a-passkey ceremony: an enrolled // credential can mint fresh ceremony proofs, so accepting one here would // escalate a stolen read/write token into full account takeover. func TestRegisterBeginRefusesLinkToken(t *testing.T) { ts, s := newTestServer(t) _, tok := seedUser(t, s) if r := do(t, "POST", ts.URL+"/api/register/begin", tok, nil, nil); r.StatusCode != http.StatusForbidden { t.Fatalf("begin with link token: want 403, got %d", r.StatusCode) } // An unknown/garbage bearer token is refused the same way, not treated as // an anonymous new enrolment. if r := do(t, "POST", ts.URL+"/api/register/begin", "stolen-or-stale", nil, nil); r.StatusCode != http.StatusForbidden { t.Fatalf("begin with bogus token: want 403, got %d", r.StatusCode) } } func TestCredentialDeleteRequiresCeremony(t *testing.T) { ts, s := newTestServer(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.store.insertCredential(uid, second); err != nil { t.Fatalf("insertCredential: %v", err) } creds, _ := s.store.credentialsFor(uid) firstID := base64.RawURLEncoding.EncodeToString(creds[0].ID) secondID := base64.RawURLEncoding.EncodeToString(second.ID) // A link token must not remove a passkey (it reduces access). if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("delete with link token: want 401, got %d", r.StatusCode) } // Nor may another user's fresh proof. other, _ := seedUser(t, s) otherProof := s.keys.mintCeremonyProof(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.keys.mintCeremonyProof(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) } // The LAST credential is refused — this user has no SiwA/email anchors, so // the passkey is the last sign-in method overall. (The guard evolved from // last-passkey to last-method with #1089: an ANCHORED account may now shed // its final passkey — that side of the matrix lives in anchors_test.go. // Blob delete remains the wind-down path.) 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.store.credentialsFor(uid); len(left) != 1 { t.Fatalf("want exactly 1 credential left, got %d", len(left)) } } func TestFinishRejectsUnknownSession(t *testing.T) { ts, _ := newTestServer(t) for _, path := range []string{"/api/register/finish", "/api/login/finish"} { r := do(t, "POST", ts.URL+path+"?session=nope", "", nil, []byte("{}")) if r.StatusCode != http.StatusBadRequest { t.Fatalf("%s: want 400, got %d", path, r.StatusCode) } } } func TestBeginEndpointsRateLimit(t *testing.T) { ts, _ := newTestServer(t) limited := false for i := 0; i < 30; i++ { r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil) if r.StatusCode == http.StatusTooManyRequests { limited = true break } } if !limited { t.Fatalf("register/begin never rate-limited after 30 rapid requests") } // The bucket is shared per IP across both ceremony begin endpoints. if r := do(t, "POST", ts.URL+"/api/login/begin", "", nil, nil); r.StatusCode != http.StatusTooManyRequests { t.Fatalf("login/begin should share the exhausted bucket: want 429, got %d", r.StatusCode) } } // The enrolment popup must serve, be HTML with a locked-down CSP (frame-ancestors // none — it must never be iframed), and carry no secrets (the entry payload // arrives client-side in the URL fragment, so a plain GET returns a static page). func TestServeEnrollPage(t *testing.T) { ts, _ := newTestServer(t) r := do(t, "GET", ts.URL+"/enroll", "", nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("GET /enroll: want 200, got %d", r.StatusCode) } if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("/enroll content-type = %q", ct) } if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") { t.Fatalf("/enroll CSP too loose: %q", csp) } body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), "/static/enroll.js") { t.Fatalf("/enroll page missing enroll.js") } } // The restore page must serve with the same locked-down CSP as the others and // carry no secrets (the request rides in the URL fragment). func TestServeRestorePage(t *testing.T) { ts, _ := newTestServer(t) r := do(t, "GET", ts.URL+"/restore", "", nil, nil) if r.StatusCode != http.StatusOK { t.Fatalf("GET /restore: want 200, got %d", r.StatusCode) } if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") { t.Fatalf("/restore CSP too loose: %q", csp) } body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), "/static/restore.js") { t.Fatalf("/restore page missing restore.js") } } // With no LCHAT_HOME_INFRASTRUCTURE_DIR configured (the default, and every // existing self-hosted deployment today), /infrastructure/ must 404 — not // panic, not serve an empty directory listing. func TestInfrastructureDisabledByDefault(t *testing.T) { ts, _ := newTestServer(t) for _, path := range []string{"/infrastructure", "/infrastructure/", "/infrastructure/index.html"} { r := do(t, "GET", ts.URL+path, "", nil, nil) if r.StatusCode != http.StatusNotFound { t.Fatalf("GET %s with no infrastructureDir: want 404, got %d", path, r.StatusCode) } } } // With LCHAT_HOME_INFRASTRUCTURE_DIR set, /infrastructure/ serves straight // from that directory — not embedded, not copied — with a strict // same-origin CSP and no caching. func TestServeInfrastructureFromDisk(t *testing.T) { store, err := openSQLiteStore(t.TempDir() + "/home.db") if err != nil { t.Fatalf("openSQLiteStore: %v", err) } s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret")) if err != nil { t.Fatalf("newServer: %v", err) } s.infrastructureDir = t.TempDir() if err := os.WriteFile(s.infrastructureDir+"/index.html", []byte("