tenseleyflow/shithub / 925b4bf

Browse files

api: SSH keys CRUD at /api/v1/user/keys (authentication kind)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
925b4bf360e1bedcfecca50cc60cb5f85ae5e010
Parents
57b9ad4
Tree
84e45b6

2 changed files

StatusFile+-
A internal/web/handlers/api/keys.go 255 0
A internal/web/handlers/api/keys_test.go 321 0
internal/web/handlers/api/keys.goadded
@@ -0,0 +1,255 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"net/url"
10
+	"strconv"
11
+	"time"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5"
15
+	"github.com/jackc/pgx/v5/pgconn"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/sshkey"
19
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
21
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
22
+)
23
+
24
+// mountUserKeys registers the SSH-keys REST surface for the authenticated
25
+// user. Shape mirrors GitHub's /user/keys (authentication keys only;
26
+// signing keys land at /user/ssh_signing_keys in a future batch).
27
+//
28
+//	GET    /api/v1/user/keys        list (paginated)
29
+//	POST   /api/v1/user/keys        add { title, key }
30
+//	GET    /api/v1/user/keys/{id}   get one
31
+//	DELETE /api/v1/user/keys/{id}   remove
32
+//
33
+// Scopes: user:read for GETs, user:write for POST/DELETE.
34
+func (h *Handlers) mountUserKeys(r chi.Router) {
35
+	r.Group(func(r chi.Router) {
36
+		r.Use(middleware.RequireScope(pat.ScopeUserRead))
37
+		r.Get("/api/v1/user/keys", h.userKeysList)
38
+		r.Get("/api/v1/user/keys/{id}", h.userKeyGet)
39
+	})
40
+	r.Group(func(r chi.Router) {
41
+		r.Use(middleware.RequireScope(pat.ScopeUserWrite))
42
+		r.Post("/api/v1/user/keys", h.userKeyCreate)
43
+		r.Delete("/api/v1/user/keys/{id}", h.userKeyDelete)
44
+	})
45
+}
46
+
47
+type userKeyResponse struct {
48
+	ID          int64  `json:"id"`
49
+	Title       string `json:"title"`
50
+	Key         string `json:"key"`
51
+	Fingerprint string `json:"fingerprint"`
52
+	KeyType     string `json:"key_type"`
53
+	Verified    bool   `json:"verified"`
54
+	ReadOnly    bool   `json:"read_only"`
55
+	CreatedAt   string `json:"created_at"`
56
+}
57
+
58
+func presentUserKey(k usersdb.UserSshKey) userKeyResponse {
59
+	return userKeyResponse{
60
+		ID:          k.ID,
61
+		Title:       k.Title,
62
+		Key:         k.PublicKey,
63
+		Fingerprint: "SHA256:" + k.FingerprintSha256,
64
+		KeyType:     k.KeyType,
65
+		// Every key shithub stores has been parsed and validated at
66
+		// upload time. Surface as verified=true so gh-shaped clients
67
+		// that key off this field continue to work.
68
+		Verified:  true,
69
+		ReadOnly:  false,
70
+		CreatedAt: k.CreatedAt.Time.UTC().Format(time.RFC3339),
71
+	}
72
+}
73
+
74
+func (h *Handlers) userKeysList(w http.ResponseWriter, r *http.Request) {
75
+	auth := middleware.PATAuthFromContext(r.Context())
76
+	if auth.UserID == 0 {
77
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
78
+		return
79
+	}
80
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
81
+	total, err := h.q.CountUserSSHKeysByKind(r.Context(), h.d.Pool, usersdb.CountUserSSHKeysByKindParams{
82
+		UserID: auth.UserID, Kind: "authentication",
83
+	})
84
+	if err != nil {
85
+		h.d.Logger.ErrorContext(r.Context(), "api: count user keys", "error", err)
86
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
87
+		return
88
+	}
89
+	rows, err := h.q.ListUserSSHKeysByKind(r.Context(), h.d.Pool, usersdb.ListUserSSHKeysByKindParams{
90
+		UserID: auth.UserID,
91
+		Kind:   "authentication",
92
+		Limit:  int32(perPage),
93
+		Offset: int32((page - 1) * perPage),
94
+	})
95
+	if err != nil {
96
+		h.d.Logger.ErrorContext(r.Context(), "api: list user keys", "error", err)
97
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
98
+		return
99
+	}
100
+	link := apipage.Page{
101
+		Current: page, PerPage: perPage, Total: int(total),
102
+	}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
103
+	if link != "" {
104
+		w.Header().Set("Link", link)
105
+	}
106
+	out := make([]userKeyResponse, 0, len(rows))
107
+	for _, k := range rows {
108
+		out = append(out, presentUserKey(k))
109
+	}
110
+	writeJSON(w, http.StatusOK, out)
111
+}
112
+
113
+func (h *Handlers) userKeyGet(w http.ResponseWriter, r *http.Request) {
114
+	auth := middleware.PATAuthFromContext(r.Context())
115
+	if auth.UserID == 0 {
116
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
117
+		return
118
+	}
119
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
120
+	if err != nil {
121
+		writeAPIError(w, http.StatusNotFound, "key not found")
122
+		return
123
+	}
124
+	k, err := h.q.GetUserSSHKey(r.Context(), h.d.Pool, usersdb.GetUserSSHKeyParams{
125
+		ID: id, UserID: auth.UserID,
126
+	})
127
+	if err != nil {
128
+		if errors.Is(err, pgx.ErrNoRows) {
129
+			writeAPIError(w, http.StatusNotFound, "key not found")
130
+			return
131
+		}
132
+		h.d.Logger.ErrorContext(r.Context(), "api: get user key", "error", err)
133
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
134
+		return
135
+	}
136
+	if k.Kind != "authentication" {
137
+		// Same shape as a non-existent key: the signing-keys surface
138
+		// has its own route that this endpoint deliberately doesn't
139
+		// expose.
140
+		writeAPIError(w, http.StatusNotFound, "key not found")
141
+		return
142
+	}
143
+	writeJSON(w, http.StatusOK, presentUserKey(k))
144
+}
145
+
146
+type userKeyCreateRequest struct {
147
+	Title string `json:"title"`
148
+	Key   string `json:"key"`
149
+}
150
+
151
+func (h *Handlers) userKeyCreate(w http.ResponseWriter, r *http.Request) {
152
+	auth := middleware.PATAuthFromContext(r.Context())
153
+	if auth.UserID == 0 {
154
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
155
+		return
156
+	}
157
+	var body userKeyCreateRequest
158
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
159
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
160
+		return
161
+	}
162
+	parsed, err := sshkey.Parse(body.Title, body.Key)
163
+	if err != nil {
164
+		writeAPIError(w, http.StatusUnprocessableEntity, sshKeyAPIErrorMessage(err))
165
+		return
166
+	}
167
+	count, err := h.q.CountUserSSHKeys(r.Context(), h.d.Pool, auth.UserID)
168
+	if err != nil {
169
+		h.d.Logger.ErrorContext(r.Context(), "api: count user keys", "error", err)
170
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
171
+		return
172
+	}
173
+	if count >= int64(sshkey.MaxKeysPerUser) {
174
+		writeAPIError(w, http.StatusUnprocessableEntity, "per-user SSH-key cap reached")
175
+		return
176
+	}
177
+	k, err := h.q.InsertUserSSHKey(r.Context(), h.d.Pool, usersdb.InsertUserSSHKeyParams{
178
+		UserID:            auth.UserID,
179
+		Title:             parsed.Title,
180
+		FingerprintSha256: parsed.Fingerprint,
181
+		KeyType:           parsed.Type,
182
+		KeyBits:           int32(parsed.Bits), //nolint:gosec // RSA-bit ceiling is bounded by sshkey.Parse.
183
+		PublicKey:         parsed.PublicKey,
184
+		Kind:              "authentication",
185
+	})
186
+	if err != nil {
187
+		var pgErr *pgconn.PgError
188
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
189
+			writeAPIError(w, http.StatusUnprocessableEntity, "key already registered")
190
+			return
191
+		}
192
+		h.d.Logger.ErrorContext(r.Context(), "api: insert user key", "error", err)
193
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
194
+		return
195
+	}
196
+	writeJSON(w, http.StatusCreated, presentUserKey(k))
197
+}
198
+
199
+func (h *Handlers) userKeyDelete(w http.ResponseWriter, r *http.Request) {
200
+	auth := middleware.PATAuthFromContext(r.Context())
201
+	if auth.UserID == 0 {
202
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
203
+		return
204
+	}
205
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
206
+	if err != nil {
207
+		writeAPIError(w, http.StatusNotFound, "key not found")
208
+		return
209
+	}
210
+	rows, err := h.q.DeleteUserSSHKey(r.Context(), h.d.Pool, usersdb.DeleteUserSSHKeyParams{
211
+		ID: id, UserID: auth.UserID,
212
+	})
213
+	if err != nil {
214
+		h.d.Logger.ErrorContext(r.Context(), "api: delete user key", "error", err)
215
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
216
+		return
217
+	}
218
+	if rows == 0 {
219
+		writeAPIError(w, http.StatusNotFound, "key not found")
220
+		return
221
+	}
222
+	w.WriteHeader(http.StatusNoContent)
223
+}
224
+
225
+// sshKeyAPIErrorMessage maps the typed parser errors to user-facing
226
+// strings appropriate for an API client (no UI verbiage).
227
+func sshKeyAPIErrorMessage(err error) string {
228
+	switch {
229
+	case errors.Is(err, sshkey.ErrTitleEmpty):
230
+		return "title is required"
231
+	case errors.Is(err, sshkey.ErrTitleTooLong):
232
+		return "title must be at most 80 characters"
233
+	case errors.Is(err, sshkey.ErrTitleControl):
234
+		return "title contains control characters"
235
+	case errors.Is(err, sshkey.ErrUnsupportedAlgo):
236
+		return "unsupported key algorithm (use ed25519, ECDSA, or RSA >= 2048 bits)"
237
+	case errors.Is(err, sshkey.ErrRSATooShort):
238
+		return "RSA keys must be at least 2048 bits"
239
+	case errors.Is(err, sshkey.ErrUnparseable):
240
+		return "could not parse key blob"
241
+	default:
242
+		return "invalid key"
243
+	}
244
+}
245
+
246
+// sanitizedURL returns a copy of the request URL with no scheme/host,
247
+// suitable for feeding into apipage.LinkHeader without leaking proxy
248
+// internals. The helper exists so handlers don't have to repeat the
249
+// boilerplate copy + clear.
250
+func sanitizedURL(r *http.Request) *url.URL {
251
+	u := *r.URL
252
+	u.Scheme = ""
253
+	u.Host = ""
254
+	return &u
255
+}
internal/web/handlers/api/keys_test.goadded
@@ -0,0 +1,321 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/json"
9
+	"net/http"
10
+	"net/http/httptest"
11
+	"os"
12
+	"path/filepath"
13
+	"strings"
14
+	"testing"
15
+
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21
+)
22
+
23
+type apiUserKey struct {
24
+	ID          int64  `json:"id"`
25
+	Title       string `json:"title"`
26
+	Key         string `json:"key"`
27
+	Fingerprint string `json:"fingerprint"`
28
+	KeyType     string `json:"key_type"`
29
+	Verified    bool   `json:"verified"`
30
+	ReadOnly    bool   `json:"read_only"`
31
+	CreatedAt   string `json:"created_at"`
32
+}
33
+
34
+func loadKeyFixture(t *testing.T, name string) string {
35
+	t.Helper()
36
+	path := filepath.Join("..", "..", "..", "auth", "sshkey", "testdata", name)
37
+	b, err := os.ReadFile(path)
38
+	if err != nil {
39
+		t.Fatalf("read fixture %s: %v", path, err)
40
+	}
41
+	return strings.TrimSpace(string(b))
42
+}
43
+
44
+func seedSigningKey(t *testing.T, pool *pgxpool.Pool, userID int64, fingerprint string) int64 {
45
+	t.Helper()
46
+	k, err := usersdb.New().InsertUserSSHKey(context.Background(), pool, usersdb.InsertUserSSHKeyParams{
47
+		UserID:            userID,
48
+		Title:             "signing-only",
49
+		FingerprintSha256: fingerprint,
50
+		KeyType:           "ssh-ed25519",
51
+		KeyBits:           0,
52
+		PublicKey:         "ssh-ed25519 AAAA…signing test",
53
+		Kind:              "signing",
54
+	})
55
+	if err != nil {
56
+		t.Fatalf("seed signing key: %v", err)
57
+	}
58
+	return k.ID
59
+}
60
+
61
+func TestUserKeys_CreateListGetDelete(t *testing.T) {
62
+	pool := dbtest.NewTestDB(t)
63
+	router := newCrossCuttingAPIRouter(t, pool)
64
+	userID := crossCuttingUser(t, pool)
65
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
66
+	tokenRead := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
67
+	pubBlob := loadKeyFixture(t, "ed25519.pub")
68
+
69
+	// Create.
70
+	body, _ := json.Marshal(map[string]string{"title": "laptop", "key": pubBlob})
71
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
72
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
73
+	req.Header.Set("Content-Type", "application/json")
74
+	rr := httptest.NewRecorder()
75
+	router.ServeHTTP(rr, req)
76
+	if rr.Code != http.StatusCreated {
77
+		t.Fatalf("create status: got %d, want 201; body=%s", rr.Code, rr.Body.String())
78
+	}
79
+	var created apiUserKey
80
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
81
+		t.Fatalf("decode create: %v; body=%s", err, rr.Body.String())
82
+	}
83
+	if created.ID == 0 || created.Title != "laptop" || created.KeyType != "ssh-ed25519" {
84
+		t.Errorf("created shape unexpected: %+v", created)
85
+	}
86
+	if !strings.HasPrefix(created.Fingerprint, "SHA256:") {
87
+		t.Errorf("fingerprint not prefixed: %q", created.Fingerprint)
88
+	}
89
+
90
+	// List.
91
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/keys", nil)
92
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
93
+	rr = httptest.NewRecorder()
94
+	router.ServeHTTP(rr, req)
95
+	if rr.Code != http.StatusOK {
96
+		t.Fatalf("list status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
97
+	}
98
+	var listed []apiUserKey
99
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
100
+		t.Fatalf("decode list: %v", err)
101
+	}
102
+	if len(listed) != 1 || listed[0].ID != created.ID {
103
+		t.Errorf("list shape unexpected: %+v", listed)
104
+	}
105
+
106
+	// Get by id.
107
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/keys/"+itoa(created.ID), nil)
108
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
109
+	rr = httptest.NewRecorder()
110
+	router.ServeHTTP(rr, req)
111
+	if rr.Code != http.StatusOK {
112
+		t.Fatalf("get status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
113
+	}
114
+
115
+	// Delete.
116
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/user/keys/"+itoa(created.ID), nil)
117
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
118
+	rr = httptest.NewRecorder()
119
+	router.ServeHTTP(rr, req)
120
+	if rr.Code != http.StatusNoContent {
121
+		t.Fatalf("delete status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
122
+	}
123
+
124
+	// Subsequent get returns 404.
125
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/keys/"+itoa(created.ID), nil)
126
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
127
+	rr = httptest.NewRecorder()
128
+	router.ServeHTTP(rr, req)
129
+	if rr.Code != http.StatusNotFound {
130
+		t.Fatalf("post-delete get: got %d, want 404; body=%s", rr.Code, rr.Body.String())
131
+	}
132
+}
133
+
134
+func TestUserKeys_CreateRejectsBadKey(t *testing.T) {
135
+	pool := dbtest.NewTestDB(t)
136
+	router := newCrossCuttingAPIRouter(t, pool)
137
+	userID := crossCuttingUser(t, pool)
138
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
139
+
140
+	body, _ := json.Marshal(map[string]string{"title": "broken", "key": "not even close to a key"})
141
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
142
+	req.Header.Set("Authorization", "Bearer "+token)
143
+	req.Header.Set("Content-Type", "application/json")
144
+	rr := httptest.NewRecorder()
145
+	router.ServeHTTP(rr, req)
146
+
147
+	if rr.Code != http.StatusUnprocessableEntity {
148
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
149
+	}
150
+	var envelope struct {
151
+		Error string `json:"error"`
152
+	}
153
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
154
+		t.Fatalf("decode envelope: %v", err)
155
+	}
156
+	if envelope.Error == "" {
157
+		t.Errorf("error envelope empty: %s", rr.Body.String())
158
+	}
159
+}
160
+
161
+func TestUserKeys_CreateRejectsRSA1024(t *testing.T) {
162
+	pool := dbtest.NewTestDB(t)
163
+	router := newCrossCuttingAPIRouter(t, pool)
164
+	userID := crossCuttingUser(t, pool)
165
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
166
+
167
+	body, _ := json.Marshal(map[string]string{"title": "weak", "key": loadKeyFixture(t, "rsa1024.pub")})
168
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
169
+	req.Header.Set("Authorization", "Bearer "+token)
170
+	rr := httptest.NewRecorder()
171
+	router.ServeHTTP(rr, req)
172
+
173
+	if rr.Code != http.StatusUnprocessableEntity {
174
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
175
+	}
176
+}
177
+
178
+func TestUserKeys_CreateRequiresUserWriteScope(t *testing.T) {
179
+	pool := dbtest.NewTestDB(t)
180
+	router := newCrossCuttingAPIRouter(t, pool)
181
+	userID := crossCuttingUser(t, pool)
182
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
183
+	pubBlob := loadKeyFixture(t, "ed25519.pub")
184
+
185
+	body, _ := json.Marshal(map[string]string{"title": "laptop", "key": pubBlob})
186
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
187
+	req.Header.Set("Authorization", "Bearer "+token)
188
+	rr := httptest.NewRecorder()
189
+	router.ServeHTTP(rr, req)
190
+
191
+	if rr.Code != http.StatusForbidden {
192
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
193
+	}
194
+}
195
+
196
+func TestUserKeys_GetUnknownReturns404(t *testing.T) {
197
+	pool := dbtest.NewTestDB(t)
198
+	router := newCrossCuttingAPIRouter(t, pool)
199
+	userID := crossCuttingUser(t, pool)
200
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
201
+
202
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user/keys/9999999", nil)
203
+	req.Header.Set("Authorization", "Bearer "+token)
204
+	rr := httptest.NewRecorder()
205
+	router.ServeHTTP(rr, req)
206
+	if rr.Code != http.StatusNotFound {
207
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
208
+	}
209
+}
210
+
211
+func TestUserKeys_ListExcludesSigningKeys(t *testing.T) {
212
+	pool := dbtest.NewTestDB(t)
213
+	router := newCrossCuttingAPIRouter(t, pool)
214
+	userID := crossCuttingUser(t, pool)
215
+	tokenWrite := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserWrite))
216
+	tokenRead := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
217
+
218
+	// Add an authentication key via REST.
219
+	pubBlob := loadKeyFixture(t, "ed25519.pub")
220
+	body, _ := json.Marshal(map[string]string{"title": "laptop", "key": pubBlob})
221
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
222
+	req.Header.Set("Authorization", "Bearer "+tokenWrite)
223
+	rr := httptest.NewRecorder()
224
+	router.ServeHTTP(rr, req)
225
+	if rr.Code != http.StatusCreated {
226
+		t.Fatalf("seed auth key: %d; body=%s", rr.Code, rr.Body.String())
227
+	}
228
+
229
+	// Seed a signing key directly.
230
+	signingID := seedSigningKey(t, pool, userID, "z9R6V9d8aGAxN1pVdQF/notarealfingerprint=")
231
+
232
+	// List should return only the authentication one.
233
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/keys", nil)
234
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
235
+	rr = httptest.NewRecorder()
236
+	router.ServeHTTP(rr, req)
237
+	if rr.Code != http.StatusOK {
238
+		t.Fatalf("list status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
239
+	}
240
+	var listed []apiUserKey
241
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
242
+		t.Fatalf("decode list: %v", err)
243
+	}
244
+	for _, k := range listed {
245
+		if k.ID == signingID {
246
+			t.Errorf("signing key leaked into auth-keys list: %+v", k)
247
+		}
248
+	}
249
+
250
+	// Direct GET of the signing key by id should also 404 on this surface.
251
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/user/keys/"+itoa(signingID), nil)
252
+	req.Header.Set("Authorization", "Bearer "+tokenRead)
253
+	rr = httptest.NewRecorder()
254
+	router.ServeHTTP(rr, req)
255
+	if rr.Code != http.StatusNotFound {
256
+		t.Fatalf("signing key direct get: got %d, want 404; body=%s", rr.Code, rr.Body.String())
257
+	}
258
+}
259
+
260
+func TestUserKeys_DeleteOnlyOwnersKey(t *testing.T) {
261
+	pool := dbtest.NewTestDB(t)
262
+	router := newCrossCuttingAPIRouter(t, pool)
263
+
264
+	// User A creates a key.
265
+	userA := crossCuttingUser(t, pool)
266
+	tokenA := mintRunnerAPIPAT(t, pool, userA, string(pat.ScopeUserWrite))
267
+	pubBlob := loadKeyFixture(t, "ed25519.pub")
268
+	body, _ := json.Marshal(map[string]string{"title": "laptop", "key": pubBlob})
269
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/keys", bytes.NewReader(body))
270
+	req.Header.Set("Authorization", "Bearer "+tokenA)
271
+	rr := httptest.NewRecorder()
272
+	router.ServeHTTP(rr, req)
273
+	if rr.Code != http.StatusCreated {
274
+		t.Fatalf("A create: %d; %s", rr.Code, rr.Body.String())
275
+	}
276
+	var created apiUserKey
277
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
278
+		t.Fatalf("decode: %v", err)
279
+	}
280
+
281
+	// User B tries to delete user A's key.
282
+	userB, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
283
+		Username:     "bob",
284
+		DisplayName:  "Bob",
285
+		PasswordHash: runnerAPIFixtureHash,
286
+	})
287
+	if err != nil {
288
+		t.Fatalf("create userB: %v", err)
289
+	}
290
+	tokenB := mintRunnerAPIPAT(t, pool, userB.ID, string(pat.ScopeUserWrite))
291
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/user/keys/"+itoa(created.ID), nil)
292
+	req.Header.Set("Authorization", "Bearer "+tokenB)
293
+	rr = httptest.NewRecorder()
294
+	router.ServeHTTP(rr, req)
295
+	if rr.Code != http.StatusNotFound {
296
+		t.Fatalf("cross-user delete: got %d, want 404; body=%s", rr.Code, rr.Body.String())
297
+	}
298
+}
299
+
300
+func itoa(i int64) string {
301
+	const digits = "0123456789"
302
+	if i == 0 {
303
+		return "0"
304
+	}
305
+	neg := i < 0
306
+	if neg {
307
+		i = -i
308
+	}
309
+	var buf [20]byte
310
+	pos := len(buf)
311
+	for i > 0 {
312
+		pos--
313
+		buf[pos] = digits[i%10]
314
+		i /= 10
315
+	}
316
+	if neg {
317
+		pos--
318
+		buf[pos] = '-'
319
+	}
320
+	return string(buf[pos:])
321
+}