tenseleyflow/shithub / a327747

Browse files

web/handlers/api: GPG keys REST CRUD with gh-exact JSON shape

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a327747b1af537ee8477993053b827a463a888fa
Parents
5cedcb7
Tree
422ee47

2 changed files

StatusFile+-
M internal/web/handlers/api/api.go 2 0
A internal/web/handlers/api/gpg_keys.go 508 0
internal/web/handlers/api/api.gomodified
@@ -152,6 +152,8 @@ func (h *Handlers) Mount(r chi.Router) {
152152
 		h.mountUserEmails(r)
153153
 		// S50 §1 — user SSH keys CRUD.
154154
 		h.mountUserKeys(r)
155
+		// S51 — user GPG keys CRUD. Capability advertised in Sub-PR 6.
156
+		h.mountGPGKeys(r)
155157
 		// S50 §2 — repos REST core (list/single/create/patch/delete).
156158
 		h.mountRepos(r)
157159
 		// S50 §3 — issues + comments + lock.
internal/web/handlers/api/gpg_keys.goadded
@@ -0,0 +1,508 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"net/http"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	"github.com/go-chi/chi/v5"
15
+	"github.com/jackc/pgx/v5"
16
+	"github.com/jackc/pgx/v5/pgconn"
17
+	"github.com/jackc/pgx/v5/pgtype"
18
+
19
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
20
+	"github.com/tenseleyFlow/shithub/internal/auth/gpgkey"
21
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
22
+	"github.com/tenseleyFlow/shithub/internal/repos/sigverify"
23
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
26
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
27
+)
28
+
29
+// mountGPGKeys registers the OpenPGP keys REST surface. JSON shape
30
+// mirrors GitHub's /user/gpg_keys EXACTLY — every field name and
31
+// nullability matches docs.github.com/en/rest/users/gpg-keys so the
32
+// shithub-cli client can consume responses without per-field shims.
33
+//
34
+//	GET    /api/v1/user/gpg_keys        list (paginated)
35
+//	POST   /api/v1/user/gpg_keys        add { name, armored_public_key }
36
+//	GET    /api/v1/user/gpg_keys/{id}   get one
37
+//	DELETE /api/v1/user/gpg_keys/{id}   soft-delete
38
+//
39
+// Scopes: user:read for GETs, user:write for POST/DELETE.
40
+func (h *Handlers) mountGPGKeys(r chi.Router) {
41
+	r.Group(func(r chi.Router) {
42
+		r.Use(middleware.RequireScope(pat.ScopeUserRead))
43
+		r.Get("/api/v1/user/gpg_keys", h.gpgKeysList)
44
+		r.Get("/api/v1/user/gpg_keys/{id}", h.gpgKeyGet)
45
+	})
46
+	r.Group(func(r chi.Router) {
47
+		r.Use(middleware.RequireScope(pat.ScopeUserWrite))
48
+		r.Post("/api/v1/user/gpg_keys", h.gpgKeyCreate)
49
+		r.Delete("/api/v1/user/gpg_keys/{id}", h.gpgKeyDelete)
50
+	})
51
+}
52
+
53
+// gpgKeyResponse mirrors GitHub's GpgKey type byte-for-byte. Two
54
+// fields worth calling out as gh-exact:
55
+//
56
+//   - can_encrypt_comms + can_encrypt_storage are split per RFC 4880
57
+//     §5.2.3.21. gh exposes both bits; consumers expect both.
58
+//   - public_key and raw_key both carry the armored block. gh
59
+//     historically distinguishes them; we emit the same armored text
60
+//     in both for maximum client compatibility.
61
+//
62
+// can_authenticate is NOT in gh's response — OpenPGP carries the bit
63
+// but gh doesn't surface it. We follow suit; the DB column stays in
64
+// case S52/S53 want to expose it.
65
+type gpgKeyResponse struct {
66
+	ID                int64               `json:"id"`
67
+	Name              string              `json:"name"`
68
+	PrimaryKeyID      *int64              `json:"primary_key_id"`
69
+	KeyID             string              `json:"key_id"`
70
+	PublicKey         string              `json:"public_key"`
71
+	Emails            []gpgEmailResponse  `json:"emails"`
72
+	Subkeys           []gpgSubkeyResponse `json:"subkeys"`
73
+	CanSign           bool                `json:"can_sign"`
74
+	CanEncryptComms   bool                `json:"can_encrypt_comms"`
75
+	CanEncryptStorage bool                `json:"can_encrypt_storage"`
76
+	CanCertify        bool                `json:"can_certify"`
77
+	CreatedAt         string              `json:"created_at"`
78
+	ExpiresAt         *string             `json:"expires_at"`
79
+	Revoked           bool                `json:"revoked"`
80
+	RawKey            string              `json:"raw_key"`
81
+}
82
+
83
+type gpgEmailResponse struct {
84
+	Email    string `json:"email"`
85
+	Verified bool   `json:"verified"`
86
+}
87
+
88
+type gpgSubkeyResponse struct {
89
+	ID                int64               `json:"id"`
90
+	PrimaryKeyID      int64               `json:"primary_key_id"`
91
+	KeyID             string              `json:"key_id"`
92
+	PublicKey         string              `json:"public_key"`
93
+	Emails            []gpgEmailResponse  `json:"emails"`
94
+	Subkeys           []gpgSubkeyResponse `json:"subkeys"`
95
+	CanSign           bool                `json:"can_sign"`
96
+	CanEncryptComms   bool                `json:"can_encrypt_comms"`
97
+	CanEncryptStorage bool                `json:"can_encrypt_storage"`
98
+	CanCertify        bool                `json:"can_certify"`
99
+	CreatedAt         string              `json:"created_at"`
100
+	ExpiresAt         *string             `json:"expires_at"`
101
+	RawKey            *string             `json:"raw_key"`
102
+	Revoked           bool                `json:"revoked"`
103
+}
104
+
105
+// presentGPGKey transforms a sqlc row + the user's verified-email
106
+// set into the wire response. The verified-emails parameter is the
107
+// expensive lookup; the caller does it once per request and passes
108
+// it through so we don't re-query inside the loop.
109
+func presentGPGKey(k usersdb.UserGpgKey, verifiedEmails map[string]bool) gpgKeyResponse {
110
+	resp := gpgKeyResponse{
111
+		ID:                k.ID,
112
+		Name:              k.Name,
113
+		KeyID:             strings.ToUpper(k.KeyID),
114
+		PublicKey:         k.Armored,
115
+		RawKey:            k.Armored,
116
+		CanSign:           k.CanSign,
117
+		CanEncryptComms:   k.CanEncryptComms,
118
+		CanEncryptStorage: k.CanEncryptStorage,
119
+		CanCertify:        k.CanCertify,
120
+		CreatedAt:         k.CreatedAt.Time.UTC().Format(time.RFC3339),
121
+		Revoked:           k.RevokedAt.Valid,
122
+		Emails:            []gpgEmailResponse{},
123
+		Subkeys:           []gpgSubkeyResponse{},
124
+	}
125
+	if k.ExpiresAt.Valid {
126
+		exp := k.ExpiresAt.Time.UTC().Format(time.RFC3339)
127
+		resp.ExpiresAt = &exp
128
+	}
129
+	for _, uid := range k.Uids {
130
+		resp.Emails = append(resp.Emails, gpgEmailResponse{
131
+			Email:    uid,
132
+			Verified: verifiedEmails[strings.ToLower(uid)],
133
+		})
134
+	}
135
+
136
+	// Subkeys are stored as JSONB at upload time in the gpgkey
137
+	// parser's ParsedSubkey shape; transform here to the gh-exact
138
+	// nested shape. Best-effort: a bad JSONB blob (corrupted at
139
+	// rest) skips the subkeys array, leaving it empty rather than
140
+	// failing the whole response.
141
+	var parsedSubkeys []struct {
142
+		Fingerprint       string     `json:"Fingerprint"`
143
+		KeyID             string     `json:"KeyID"`
144
+		CanSign           bool       `json:"CanSign"`
145
+		CanEncryptComms   bool       `json:"CanEncryptComms"`
146
+		CanEncryptStorage bool       `json:"CanEncryptStorage"`
147
+		CanCertify        bool       `json:"CanCertify"`
148
+		ExpiresAt         *time.Time `json:"ExpiresAt"`
149
+	}
150
+	_ = json.Unmarshal(k.Subkeys, &parsedSubkeys)
151
+	for _, sk := range parsedSubkeys {
152
+		sub := gpgSubkeyResponse{
153
+			PrimaryKeyID:      k.ID,
154
+			KeyID:             strings.ToUpper(sk.KeyID),
155
+			PublicKey:         "",
156
+			Emails:            []gpgEmailResponse{},
157
+			Subkeys:           []gpgSubkeyResponse{},
158
+			CanSign:           sk.CanSign,
159
+			CanEncryptComms:   sk.CanEncryptComms,
160
+			CanEncryptStorage: sk.CanEncryptStorage,
161
+			CanCertify:        sk.CanCertify,
162
+			CreatedAt:         k.CreatedAt.Time.UTC().Format(time.RFC3339),
163
+			Revoked:           false,
164
+		}
165
+		if sk.ExpiresAt != nil {
166
+			exp := sk.ExpiresAt.UTC().Format(time.RFC3339)
167
+			sub.ExpiresAt = &exp
168
+		}
169
+		resp.Subkeys = append(resp.Subkeys, sub)
170
+	}
171
+	return resp
172
+}
173
+
174
+// loadVerifiedEmails returns a lowercase-keyed set of the user's
175
+// verified-email addresses for the emails[].verified cross-check.
176
+// One DB hit per request; the caller threads the result through
177
+// presentGPGKey to avoid re-querying per row.
178
+func (h *Handlers) loadVerifiedEmails(ctx context.Context, userID int64) (map[string]bool, error) {
179
+	rows, err := h.q.ListUserEmailsForUser(ctx, h.d.Pool, userID)
180
+	if err != nil {
181
+		return nil, err
182
+	}
183
+	out := make(map[string]bool, len(rows))
184
+	for _, row := range rows {
185
+		out[strings.ToLower(string(row.Email))] = row.Verified
186
+	}
187
+	return out, nil
188
+}
189
+
190
+func (h *Handlers) gpgKeysList(w http.ResponseWriter, r *http.Request) {
191
+	auth := middleware.PATAuthFromContext(r.Context())
192
+	if auth.UserID == 0 {
193
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
194
+		return
195
+	}
196
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
197
+	total, err := h.q.CountUserGPGKeys(r.Context(), h.d.Pool, auth.UserID)
198
+	if err != nil {
199
+		h.d.Logger.ErrorContext(r.Context(), "api: count gpg keys", "error", err)
200
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
201
+		return
202
+	}
203
+	rows, err := h.q.ListUserGPGKeys(r.Context(), h.d.Pool, usersdb.ListUserGPGKeysParams{
204
+		UserID: auth.UserID,
205
+		Limit:  int32(perPage), //nolint:gosec // bounded by apipage.MaxPerPage
206
+		Offset: int32((page - 1) * perPage),
207
+	})
208
+	if err != nil {
209
+		h.d.Logger.ErrorContext(r.Context(), "api: list gpg keys", "error", err)
210
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
211
+		return
212
+	}
213
+	verified, err := h.loadVerifiedEmails(r.Context(), auth.UserID)
214
+	if err != nil {
215
+		h.d.Logger.ErrorContext(r.Context(), "api: load user emails for gpg list", "error", err)
216
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
217
+		return
218
+	}
219
+	link := apipage.Page{
220
+		Current: page, PerPage: perPage, Total: int(total),
221
+	}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
222
+	if link != "" {
223
+		w.Header().Set("Link", link)
224
+	}
225
+	out := make([]gpgKeyResponse, 0, len(rows))
226
+	for _, k := range rows {
227
+		out = append(out, presentGPGKey(k, verified))
228
+	}
229
+	writeJSON(w, http.StatusOK, out)
230
+}
231
+
232
+func (h *Handlers) gpgKeyGet(w http.ResponseWriter, r *http.Request) {
233
+	auth := middleware.PATAuthFromContext(r.Context())
234
+	if auth.UserID == 0 {
235
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
236
+		return
237
+	}
238
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
239
+	if err != nil {
240
+		writeAPIError(w, http.StatusNotFound, "key not found")
241
+		return
242
+	}
243
+	k, err := h.q.GetUserGPGKey(r.Context(), h.d.Pool, usersdb.GetUserGPGKeyParams{
244
+		ID: id, UserID: auth.UserID,
245
+	})
246
+	if err != nil {
247
+		if errors.Is(err, pgx.ErrNoRows) {
248
+			writeAPIError(w, http.StatusNotFound, "key not found")
249
+			return
250
+		}
251
+		h.d.Logger.ErrorContext(r.Context(), "api: get gpg key", "error", err)
252
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
253
+		return
254
+	}
255
+	verified, err := h.loadVerifiedEmails(r.Context(), auth.UserID)
256
+	if err != nil {
257
+		h.d.Logger.ErrorContext(r.Context(), "api: load user emails for gpg get", "error", err)
258
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
259
+		return
260
+	}
261
+	writeJSON(w, http.StatusOK, presentGPGKey(k, verified))
262
+}
263
+
264
+type gpgKeyCreateRequest struct {
265
+	Name             string `json:"name"`
266
+	ArmoredPublicKey string `json:"armored_public_key"`
267
+}
268
+
269
+func (h *Handlers) gpgKeyCreate(w http.ResponseWriter, r *http.Request) {
270
+	auth := middleware.PATAuthFromContext(r.Context())
271
+	if auth.UserID == 0 {
272
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
273
+		return
274
+	}
275
+	var body gpgKeyCreateRequest
276
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
277
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
278
+		return
279
+	}
280
+	parsed, err := gpgkey.Parse(body.Name, body.ArmoredPublicKey)
281
+	if err != nil {
282
+		writeAPIError(w, http.StatusUnprocessableEntity, gpgKeyAPIErrorMessage(err))
283
+		return
284
+	}
285
+	count, err := h.q.CountUserGPGKeys(r.Context(), h.d.Pool, auth.UserID)
286
+	if err != nil {
287
+		h.d.Logger.ErrorContext(r.Context(), "api: count gpg keys", "error", err)
288
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
289
+		return
290
+	}
291
+	if count >= int64(gpgkey.MaxKeysPerUser) {
292
+		writeAPIError(w, http.StatusUnprocessableEntity, "per-user GPG-key cap reached")
293
+		return
294
+	}
295
+	row, err := h.insertGPGKeyTx(r.Context(), auth.UserID, parsed)
296
+	if err != nil {
297
+		if errors.Is(err, errAPIDuplicateGPGFingerprint) {
298
+			writeAPIError(w, http.StatusUnprocessableEntity, "key already registered")
299
+			return
300
+		}
301
+		h.d.Logger.ErrorContext(r.Context(), "api: insert gpg key", "error", err)
302
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
303
+		return
304
+	}
305
+
306
+	// Audit + backfill dispatch are best-effort; failures don't
307
+	// undo the insert, but they DO get logged. The nil-check guards
308
+	// the test router (Deps without an Audit recorder) — production
309
+	// always supplies one.
310
+	if h.d.Audit != nil {
311
+		if err := h.d.Audit.Record(r.Context(), h.d.Pool, auth.UserID,
312
+			audit.ActionGPGKeyAdded, audit.TargetUser, auth.UserID, map[string]any{
313
+				"fingerprint": parsed.Fingerprint,
314
+				"key_id":      parsed.KeyID,
315
+				"name":        parsed.Name,
316
+			}); err != nil {
317
+			h.d.Logger.WarnContext(r.Context(), "api: audit gpg add", "error", err)
318
+		}
319
+	}
320
+	if err := sigverify.DispatchForKey(r.Context(), h.d.Pool, auth.UserID); err != nil {
321
+		h.d.Logger.WarnContext(r.Context(), "api: dispatch gpg backfill", "error", err)
322
+	}
323
+
324
+	verified, err := h.loadVerifiedEmails(r.Context(), auth.UserID)
325
+	if err != nil {
326
+		h.d.Logger.ErrorContext(r.Context(), "api: load user emails after create", "error", err)
327
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
328
+		return
329
+	}
330
+	writeJSON(w, http.StatusCreated, presentGPGKey(row, verified))
331
+}
332
+
333
+func (h *Handlers) gpgKeyDelete(w http.ResponseWriter, r *http.Request) {
334
+	auth := middleware.PATAuthFromContext(r.Context())
335
+	if auth.UserID == 0 {
336
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
337
+		return
338
+	}
339
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
340
+	if err != nil {
341
+		writeAPIError(w, http.StatusNotFound, "key not found")
342
+		return
343
+	}
344
+	deleted, err := h.softDeleteGPGKeyTx(r.Context(), id, auth.UserID)
345
+	if err != nil {
346
+		h.d.Logger.ErrorContext(r.Context(), "api: delete gpg key", "error", err)
347
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
348
+		return
349
+	}
350
+	if !deleted {
351
+		writeAPIError(w, http.StatusNotFound, "key not found")
352
+		return
353
+	}
354
+	if h.d.Audit != nil {
355
+		if err := h.d.Audit.Record(r.Context(), h.d.Pool, auth.UserID,
356
+			audit.ActionGPGKeyDeleted, audit.TargetUser, auth.UserID, map[string]any{
357
+				"key_id": id,
358
+			}); err != nil {
359
+			h.d.Logger.WarnContext(r.Context(), "api: audit gpg delete", "error", err)
360
+		}
361
+	}
362
+	w.WriteHeader(http.StatusNoContent)
363
+}
364
+
365
+// gpgKeyAPIErrorMessage maps the gpgkey parser sentinels to
366
+// API-client-appropriate strings (terse, no UI prose).
367
+func gpgKeyAPIErrorMessage(err error) string {
368
+	switch {
369
+	case errors.Is(err, gpgkey.ErrPrivateKeyBlock):
370
+		return "uploaded block is a private key, not a public key"
371
+	case errors.Is(err, gpgkey.ErrSignatureBlock):
372
+		return "uploaded block is a signature, not a public key"
373
+	case errors.Is(err, gpgkey.ErrUnparseable):
374
+		return "could not parse armored public key block"
375
+	case errors.Is(err, gpgkey.ErrNoIdentities):
376
+		return "key has no user IDs"
377
+	case errors.Is(err, gpgkey.ErrExpired):
378
+		return "key has expired"
379
+	case errors.Is(err, gpgkey.ErrUnsupportedAlgo):
380
+		return "unsupported key algorithm"
381
+	case errors.Is(err, gpgkey.ErrRSATooShort):
382
+		return "RSA keys must be at least 2048 bits"
383
+	case errors.Is(err, gpgkey.ErrMultipleEntities):
384
+		return "upload one public key at a time"
385
+	case errors.Is(err, gpgkey.ErrNameTooLong):
386
+		return "name must be at most 80 characters"
387
+	case errors.Is(err, gpgkey.ErrNameControl):
388
+		return "name contains control characters"
389
+	default:
390
+		return "invalid key"
391
+	}
392
+}
393
+
394
+// insertGPGKeyTx duplicates the same primary+subkeys atomic-insert
395
+// logic the HTML handler uses, scoped here so the api package
396
+// doesn't have to import the auth handler package.
397
+func (h *Handlers) insertGPGKeyTx(ctx context.Context, userID int64, parsed *gpgkey.Parsed) (usersdb.UserGpgKey, error) {
398
+	tx, err := h.d.Pool.BeginTx(ctx, pgx.TxOptions{})
399
+	if err != nil {
400
+		return usersdb.UserGpgKey{}, err
401
+	}
402
+	defer func() { _ = tx.Rollback(ctx) }()
403
+
404
+	subkeysJSON, err := json.Marshal(parsed.Subkeys)
405
+	if err != nil {
406
+		return usersdb.UserGpgKey{}, err
407
+	}
408
+	row, err := h.q.InsertUserGPGKey(ctx, tx, usersdb.InsertUserGPGKeyParams{
409
+		UserID:            userID,
410
+		Name:              parsed.Name,
411
+		Fingerprint:       parsed.Fingerprint,
412
+		KeyID:             parsed.KeyID,
413
+		Armored:           parsed.Armored,
414
+		CanSign:           parsed.CanSign,
415
+		CanEncryptComms:   parsed.CanEncryptComms,
416
+		CanEncryptStorage: parsed.CanEncryptStorage,
417
+		CanCertify:        parsed.CanCertify,
418
+		CanAuthenticate:   parsed.CanAuthenticate,
419
+		Uids:              parsed.UIDs,
420
+		Subkeys:           subkeysJSON,
421
+		PrimaryAlgo:       parsed.PrimaryAlgo,
422
+		ExpiresAt:         apiNullableTimestamptz(parsed.ExpiresAt),
423
+	})
424
+	if err != nil {
425
+		var pgErr *pgconn.PgError
426
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
427
+			return usersdb.UserGpgKey{}, errAPIDuplicateGPGFingerprint
428
+		}
429
+		return usersdb.UserGpgKey{}, err
430
+	}
431
+	for _, sk := range parsed.Subkeys {
432
+		if _, err := h.q.InsertUserGPGSubkey(ctx, tx, usersdb.InsertUserGPGSubkeyParams{
433
+			GpgKeyID:          row.ID,
434
+			Fingerprint:       sk.Fingerprint,
435
+			KeyID:             sk.KeyID,
436
+			CanSign:           sk.CanSign,
437
+			CanEncryptComms:   sk.CanEncryptComms,
438
+			CanEncryptStorage: sk.CanEncryptStorage,
439
+			CanCertify:        sk.CanCertify,
440
+			ExpiresAt:         apiNullableTimestamptz(sk.ExpiresAt),
441
+		}); err != nil {
442
+			var pgErr *pgconn.PgError
443
+			if errors.As(err, &pgErr) && pgErr.Code == "23505" {
444
+				return usersdb.UserGpgKey{}, errAPIDuplicateGPGFingerprint
445
+			}
446
+			return usersdb.UserGpgKey{}, err
447
+		}
448
+	}
449
+	if err := tx.Commit(ctx); err != nil {
450
+		return usersdb.UserGpgKey{}, err
451
+	}
452
+	return row, nil
453
+}
454
+
455
+// softDeleteGPGKeyTx mirrors the HTML handler's soft-delete +
456
+// cache-invalidation transaction.
457
+func (h *Handlers) softDeleteGPGKeyTx(ctx context.Context, id, userID int64) (bool, error) {
458
+	// Defer to the shared reposdb invalidation query. We don't want
459
+	// the api handler to import the auth handler package, so the
460
+	// query call is inlined here.
461
+	tx, err := h.d.Pool.BeginTx(ctx, pgx.TxOptions{})
462
+	if err != nil {
463
+		return false, err
464
+	}
465
+	defer func() { _ = tx.Rollback(ctx) }()
466
+
467
+	rows, err := h.q.SoftDeleteUserGPGKey(ctx, tx, usersdb.SoftDeleteUserGPGKeyParams{
468
+		ID:     id,
469
+		UserID: userID,
470
+	})
471
+	if err != nil {
472
+		return false, err
473
+	}
474
+	if rows == 0 {
475
+		return false, nil
476
+	}
477
+	subkeys, err := h.q.ListSubkeysForGPGKey(ctx, tx, id)
478
+	if err != nil {
479
+		return false, err
480
+	}
481
+	if err := h.q.SoftDeleteSubkeysForGPGKey(ctx, tx, id); err != nil {
482
+		return false, err
483
+	}
484
+	rq := reposdb.New()
485
+	for _, sk := range subkeys {
486
+		if err := rq.InvalidateVerificationsForSubkey(ctx, tx, pgtype.Int8{Int64: sk.ID, Valid: true}); err != nil {
487
+			return false, err
488
+		}
489
+	}
490
+	if err := tx.Commit(ctx); err != nil {
491
+		return false, err
492
+	}
493
+	return true, nil
494
+}
495
+
496
+// errAPIDuplicateGPGFingerprint is the sentinel for the partial
497
+// unique index violation. Surfaced as 422 "key already registered".
498
+var errAPIDuplicateGPGFingerprint = errors.New("api: duplicate gpg fingerprint")
499
+
500
+// apiNullableTimestamptz mirrors the auth handler's helper. Lives
501
+// here as a private symbol so the api package doesn't take a hard
502
+// dependency on the auth handler package.
503
+func apiNullableTimestamptz(t *time.Time) pgtype.Timestamptz {
504
+	if t == nil {
505
+		return pgtype.Timestamptz{}
506
+	}
507
+	return pgtype.Timestamptz{Time: *t, Valid: true}
508
+}