tenseleyflow/shithub / 8d1327e

Browse files

web/handlers/auth: GPG key settings handlers + combined keys page

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8d1327e53da64db8c38849c69115d117ffe2e917
Parents
b0a0cc3
Tree
7960b7a

3 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 3 0
A internal/web/handlers/auth/gpgkeys.go 336 0
M internal/web/handlers/auth/sshkeys.go 75 12
internal/web/handlers/auth/auth.gomodified
@@ -181,6 +181,9 @@ func (h *Handlers) Mount(r chi.Router) {
181181
 			r.Get("/settings/keys", h.sshKeysList)
182182
 			r.Post("/settings/keys", h.sshKeysAdd)
183183
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
184
+			r.Get("/settings/keys/gpg/new", h.gpgKeysAddForm)
185
+			r.Post("/settings/keys/gpg", h.gpgKeysAdd)
186
+			r.Post("/settings/keys/gpg/{id}/delete", h.gpgKeysDelete)
184187
 			r.Get("/settings/tokens", h.tokensList)
185188
 			r.Post("/settings/tokens", h.tokensCreate)
186189
 			r.Post("/settings/tokens/{id}/revoke", h.tokensRevoke)
internal/web/handlers/auth/gpgkeys.goadded
@@ -0,0 +1,336 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"net/http"
10
+	"strconv"
11
+	"time"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/gpgkey"
20
+	"github.com/tenseleyFlow/shithub/internal/repos/sigverify"
21
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
22
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
24
+)
25
+
26
+// gpgKeysAddForm renders the dedicated add-key form page at
27
+// /settings/keys/gpg/new. The form is on its own page (mirroring
28
+// gh's /settings/gpg/new) so it has room for the multi-line armored-
29
+// key textarea without crowding the list page.
30
+func (h *Handlers) gpgKeysAddForm(w http.ResponseWriter, r *http.Request) {
31
+	h.renderGPGKeysAdd(w, r, "", "", "")
32
+}
33
+
34
+// renderGPGKeysAdd is the shared render path for the add form page.
35
+// addError / addTitle / addBlob preserve form state across the
36
+// re-render on validation failure.
37
+func (h *Handlers) renderGPGKeysAdd(w http.ResponseWriter, r *http.Request, addError, addTitle, addBlob string) {
38
+	h.renderPage(w, r, "settings/keys_gpg_add", map[string]any{
39
+		"Title":          "Add new GPG key",
40
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
41
+		"SettingsActive": "keys",
42
+		"AddError":       addError,
43
+		"AddTitle":       addTitle,
44
+		"AddBlob":        addBlob,
45
+	})
46
+}
47
+
48
+// gpgKeysAdd handles POST /settings/keys/gpg. Parses the armored
49
+// block, inserts the primary row + subkey rows in a tx, dispatches
50
+// the backfill job (so existing signed commits get retroactively
51
+// stamped), records an audit entry, and sends the notification
52
+// email.
53
+func (h *Handlers) gpgKeysAdd(w http.ResponseWriter, r *http.Request) {
54
+	if err := r.ParseForm(); err != nil {
55
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
56
+		return
57
+	}
58
+	user := middleware.CurrentUserFromContext(r.Context())
59
+	title := r.PostFormValue("title")
60
+	blob := r.PostFormValue("armored_key")
61
+
62
+	parsed, err := gpgkey.Parse(title, blob)
63
+	if err != nil {
64
+		h.renderGPGKeysAdd(w, r, friendlyGPGError(err), title, blob)
65
+		return
66
+	}
67
+
68
+	// Per-user cap.
69
+	count, err := h.q.CountUserGPGKeys(r.Context(), h.d.Pool, user.ID)
70
+	if err != nil {
71
+		h.d.Logger.ErrorContext(r.Context(), "gpg: count", "error", err)
72
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
73
+		return
74
+	}
75
+	if count >= int64(gpgkey.MaxKeysPerUser) {
76
+		h.renderGPGKeysAdd(w, r,
77
+			"You've reached the per-user GPG-key cap. Delete an unused key first.",
78
+			title, blob)
79
+		return
80
+	}
81
+
82
+	// Insert primary + subkey rows in a single tx. The subkey
83
+	// fingerprint index must be populated atomically with the
84
+	// primary row or a concurrent verification could resolve to a
85
+	// half-inserted state.
86
+	if err := h.insertGPGKeyTx(r.Context(), user.ID, parsed); err != nil {
87
+		if errors.Is(err, errDuplicateFingerprint) {
88
+			h.renderGPGKeysAdd(w, r,
89
+				"That key is already registered (here or on another account).",
90
+				title, blob)
91
+			return
92
+		}
93
+		h.d.Logger.ErrorContext(r.Context(), "gpg: insert tx", "error", err)
94
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
95
+		return
96
+	}
97
+
98
+	if err := h.d.Audit.Record(r.Context(), h.d.Pool, user.ID,
99
+		audit.ActionGPGKeyAdded, audit.TargetUser, user.ID, map[string]any{
100
+			"fingerprint": parsed.Fingerprint,
101
+			"key_id":      parsed.KeyID,
102
+			"name":        parsed.Name,
103
+		}); err != nil {
104
+		h.d.Logger.WarnContext(r.Context(), "gpg: audit add", "error", err)
105
+	}
106
+
107
+	h.notifyGPGKeyAdded(r, user.ID, parsed)
108
+
109
+	// Dispatch the eager backfill. Best-effort: a dispatch failure
110
+	// (e.g. the worker queue is down) shouldn't block the user from
111
+	// seeing their key on the settings page. The bulk
112
+	// shithubd gpg-backfill-all command picks up the slack.
113
+	if err := sigverify.DispatchForKey(r.Context(), h.d.Pool, user.ID); err != nil {
114
+		h.d.Logger.WarnContext(r.Context(), "gpg: dispatch backfill", "error", err)
115
+	}
116
+
117
+	http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
118
+}
119
+
120
+// gpgKeysDelete handles POST /settings/keys/gpg/{id}/delete.
121
+// Soft-deletes (stamps revoked_at) so historical commit-verification
122
+// attribution is preserved; the same tx also invalidates any cache
123
+// rows that resolved against this key's subkeys.
124
+func (h *Handlers) gpgKeysDelete(w http.ResponseWriter, r *http.Request) {
125
+	idStr := chi.URLParam(r, "id")
126
+	id, err := strconv.ParseInt(idStr, 10, 64)
127
+	if err != nil {
128
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "key not found")
129
+		return
130
+	}
131
+	user := middleware.CurrentUserFromContext(r.Context())
132
+
133
+	deleted, err := h.softDeleteGPGKeyTx(r.Context(), id, user.ID)
134
+	if err != nil {
135
+		h.d.Logger.ErrorContext(r.Context(), "gpg: delete tx", "error", err)
136
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
137
+		return
138
+	}
139
+	if !deleted {
140
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "key not found")
141
+		return
142
+	}
143
+
144
+	if err := h.d.Audit.Record(r.Context(), h.d.Pool, user.ID,
145
+		audit.ActionGPGKeyDeleted, audit.TargetUser, user.ID, map[string]any{
146
+			"key_id": id,
147
+		}); err != nil {
148
+		h.d.Logger.WarnContext(r.Context(), "gpg: audit delete", "error", err)
149
+	}
150
+
151
+	http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
152
+}
153
+
154
+// insertGPGKeyTx inserts the primary user_gpg_keys row and one
155
+// user_gpg_subkeys row per subkey atomically. Returns
156
+// errDuplicateFingerprint if the partial unique index on
157
+// fingerprint trips — the caller surfaces that as a friendly
158
+// "already registered" error.
159
+func (h *Handlers) insertGPGKeyTx(ctx context.Context, userID int64, parsed *gpgkey.Parsed) error {
160
+	tx, err := h.d.Pool.BeginTx(ctx, pgx.TxOptions{})
161
+	if err != nil {
162
+		return err
163
+	}
164
+	defer func() { _ = tx.Rollback(ctx) }()
165
+
166
+	subkeysJSON, err := json.Marshal(parsed.Subkeys)
167
+	if err != nil {
168
+		return err
169
+	}
170
+
171
+	row, err := h.q.InsertUserGPGKey(ctx, tx, usersdb.InsertUserGPGKeyParams{
172
+		UserID:            userID,
173
+		Name:              parsed.Name,
174
+		Fingerprint:       parsed.Fingerprint,
175
+		KeyID:             parsed.KeyID,
176
+		Armored:           parsed.Armored,
177
+		CanSign:           parsed.CanSign,
178
+		CanEncryptComms:   parsed.CanEncryptComms,
179
+		CanEncryptStorage: parsed.CanEncryptStorage,
180
+		CanCertify:        parsed.CanCertify,
181
+		CanAuthenticate:   parsed.CanAuthenticate,
182
+		Uids:              parsed.UIDs,
183
+		Subkeys:           subkeysJSON,
184
+		PrimaryAlgo:       parsed.PrimaryAlgo,
185
+		ExpiresAt:         nullableTimestamptz(parsed.ExpiresAt),
186
+	})
187
+	if err != nil {
188
+		if isPGUniqueViolation(err) {
189
+			return errDuplicateFingerprint
190
+		}
191
+		return err
192
+	}
193
+
194
+	for _, sk := range parsed.Subkeys {
195
+		if _, err := h.q.InsertUserGPGSubkey(ctx, tx, usersdb.InsertUserGPGSubkeyParams{
196
+			GpgKeyID:          row.ID,
197
+			Fingerprint:       sk.Fingerprint,
198
+			KeyID:             sk.KeyID,
199
+			CanSign:           sk.CanSign,
200
+			CanEncryptComms:   sk.CanEncryptComms,
201
+			CanEncryptStorage: sk.CanEncryptStorage,
202
+			CanCertify:        sk.CanCertify,
203
+			ExpiresAt:         nullableTimestamptz(sk.ExpiresAt),
204
+		}); err != nil {
205
+			if isPGUniqueViolation(err) {
206
+				// A subkey of this primary is already registered
207
+				// (someone else's primary owns a subkey with the
208
+				// same fingerprint). Vanishingly rare, but surface
209
+				// the same friendly error as a primary collision.
210
+				return errDuplicateFingerprint
211
+			}
212
+			return err
213
+		}
214
+	}
215
+
216
+	return tx.Commit(ctx)
217
+}
218
+
219
+// softDeleteGPGKeyTx soft-deletes the key + all its subkeys + stamps
220
+// invalidated_at on dependent verification cache rows. All three
221
+// happen atomically so the cache and keyring stay in sync.
222
+func (h *Handlers) softDeleteGPGKeyTx(ctx context.Context, id, userID int64) (bool, error) {
223
+	tx, err := h.d.Pool.BeginTx(ctx, pgx.TxOptions{})
224
+	if err != nil {
225
+		return false, err
226
+	}
227
+	defer func() { _ = tx.Rollback(ctx) }()
228
+
229
+	rows, err := h.q.SoftDeleteUserGPGKey(ctx, tx, usersdb.SoftDeleteUserGPGKeyParams{
230
+		ID:     id,
231
+		UserID: userID,
232
+	})
233
+	if err != nil {
234
+		return false, err
235
+	}
236
+	if rows == 0 {
237
+		return false, nil
238
+	}
239
+
240
+	subkeys, err := h.q.ListSubkeysForGPGKey(ctx, tx, id)
241
+	if err != nil {
242
+		return false, err
243
+	}
244
+	if err := h.q.SoftDeleteSubkeysForGPGKey(ctx, tx, id); err != nil {
245
+		return false, err
246
+	}
247
+
248
+	// Invalidate dependent cache rows for each subkey. Done via the
249
+	// reposdb query (commit_verification_cache lives in the repos
250
+	// domain even though subkeys live in users — the FK reference is
251
+	// what binds them).
252
+	rq := reposdb.New()
253
+	for _, sk := range subkeys {
254
+		if err := rq.InvalidateVerificationsForSubkey(ctx, tx, pgtype.Int8{Int64: sk.ID, Valid: true}); err != nil {
255
+			return false, err
256
+		}
257
+	}
258
+
259
+	if err := tx.Commit(ctx); err != nil {
260
+		return false, err
261
+	}
262
+	return true, nil
263
+}
264
+
265
+// notifyGPGKeyAdded sends the post-add notification. Best-effort —
266
+// any failure logs but doesn't fail the request.
267
+func (h *Handlers) notifyGPGKeyAdded(r *http.Request, userID int64, parsed *gpgkey.Parsed) {
268
+	user, err := h.q.GetUserByID(r.Context(), h.d.Pool, userID)
269
+	if err != nil || !user.PrimaryEmailID.Valid {
270
+		return
271
+	}
272
+	em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, user.PrimaryEmailID.Int64)
273
+	if err != nil {
274
+		return
275
+	}
276
+	msg, err := email.GPGKeyAddedMessage(h.d.Branding, string(em.Email), user.Username,
277
+		parsed.Name, parsed.Fingerprint, clientIP(r))
278
+	if err != nil {
279
+		h.d.Logger.WarnContext(r.Context(), "gpg: build email", "error", err)
280
+		return
281
+	}
282
+	if err := h.d.Email.Send(r.Context(), msg); err != nil {
283
+		h.d.Logger.WarnContext(r.Context(), "gpg: send email", "error", err)
284
+	}
285
+}
286
+
287
+// errDuplicateFingerprint is the sentinel returned from
288
+// insertGPGKeyTx when the partial unique index on fingerprint
289
+// trips. The caller surfaces this as a friendly error rather than a
290
+// generic 500.
291
+var errDuplicateFingerprint = errors.New("gpgkeys: duplicate fingerprint")
292
+
293
+// nullableTimestamptz converts an optional *time.Time (used by the
294
+// parser to indicate "this key never expires" via nil) into the
295
+// pgtype shape sqlc requires.
296
+func nullableTimestamptz(t *time.Time) pgtype.Timestamptz {
297
+	if t == nil {
298
+		return pgtype.Timestamptz{}
299
+	}
300
+	return pgtype.Timestamptz{Time: *t, Valid: true}
301
+}
302
+
303
+// friendlyGPGError translates the gpgkey parser's sentinel errors
304
+// into UI-renderable strings. Keeps the precise distinctions our
305
+// parser draws (private-key block, signature block, expired,
306
+// encryption-only, RSA-too-short) visible to the user — gh's
307
+// "We got an error" generic banner is less helpful.
308
+func friendlyGPGError(err error) string {
309
+	switch {
310
+	case errors.Is(err, gpgkey.ErrPrivateKeyBlock):
311
+		return "That looks like a private key — please upload your public key " +
312
+			"(gpg --armor --export <id>)."
313
+	case errors.Is(err, gpgkey.ErrSignatureBlock):
314
+		return "That looks like a signature, not a public key."
315
+	case errors.Is(err, gpgkey.ErrUnparseable):
316
+		return "We couldn't parse that key. Please paste a public key " +
317
+			"armored block starting with -----BEGIN PGP PUBLIC KEY BLOCK-----."
318
+	case errors.Is(err, gpgkey.ErrNoIdentities):
319
+		return "That key has no user IDs."
320
+	case errors.Is(err, gpgkey.ErrExpired):
321
+		return "That key has expired."
322
+	case errors.Is(err, gpgkey.ErrUnsupportedAlgo):
323
+		return "That key algorithm isn't accepted. Use ed25519, " +
324
+			"ECDSA (NIST), or RSA ≥ 2048 bits."
325
+	case errors.Is(err, gpgkey.ErrRSATooShort):
326
+		return "RSA keys must be at least 2048 bits."
327
+	case errors.Is(err, gpgkey.ErrMultipleEntities):
328
+		return "Please upload one public key at a time."
329
+	case errors.Is(err, gpgkey.ErrNameTooLong):
330
+		return "Name may be at most 80 characters."
331
+	case errors.Is(err, gpgkey.ErrNameControl):
332
+		return "Name contains control characters."
333
+	default:
334
+		return "Could not add key."
335
+	}
336
+}
internal/web/handlers/auth/sshkeys.gomodified
@@ -3,9 +3,12 @@
33
 package auth
44
 
55
 import (
6
+	"encoding/json"
67
 	"errors"
78
 	"net/http"
89
 	"strconv"
10
+	"strings"
11
+	"time"
912
 
1013
 	"github.com/go-chi/chi/v5"
1114
 	"github.com/jackc/pgx/v5/pgconn"
@@ -17,33 +20,93 @@ import (
1720
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1821
 )
1922
 
20
-// sshKeysList renders /settings/keys with the user's existing keys + a
21
-// blank add form.
23
+// sshKeysList renders the combined /settings/keys page (both SSH and
24
+// GPG sections) with the user's existing keys + a blank SSH add form.
25
+// The sidebar entry was renamed to "SSH and GPG keys" in S51 to match
26
+// gh's pattern of bundling both surfaces on one settings page.
2227
 func (h *Handlers) sshKeysList(w http.ResponseWriter, r *http.Request) {
23
-	h.renderSSHKeysList(w, r, "", "", "")
28
+	h.renderKeysList(w, r, "", "", "")
2429
 }
2530
 
26
-// renderSSHKeysList is the shared render path for the list page; addError /
27
-// addTitle / addBlob preserve form state when the add path re-renders.
28
-func (h *Handlers) renderSSHKeysList(w http.ResponseWriter, r *http.Request, addError, addTitle, addBlob string) {
31
+// renderKeysList is the shared render path for the combined SSH+GPG
32
+// list page. addError / addTitle / addBlob preserve SSH-side form
33
+// state when the SSH add path re-renders; the GPG add path renders a
34
+// separate page (settings/keys_gpg_add) so its form state lives there.
35
+func (h *Handlers) renderKeysList(w http.ResponseWriter, r *http.Request, addError, addTitle, addBlob string) {
2936
 	user := middleware.CurrentUserFromContext(r.Context())
30
-	keys, err := h.q.ListUserSSHKeys(r.Context(), h.d.Pool, user.ID)
37
+	sshKeys, err := h.q.ListUserSSHKeys(r.Context(), h.d.Pool, user.ID)
3138
 	if err != nil {
3239
 		h.d.Logger.ErrorContext(r.Context(), "ssh: list", "error", err)
3340
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
3441
 		return
3542
 	}
43
+	gpgKeys, err := h.q.ListUserGPGKeys(r.Context(), h.d.Pool, usersdb.ListUserGPGKeysParams{
44
+		UserID: user.ID,
45
+		Limit:  int32(gpgListPageSize), //nolint:gosec // bounded constant
46
+		Offset: 0,
47
+	})
48
+	if err != nil {
49
+		h.d.Logger.ErrorContext(r.Context(), "gpg: list", "error", err)
50
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
51
+		return
52
+	}
3653
 	h.renderPage(w, r, "settings/keys", map[string]any{
37
-		"Title":          "SSH keys",
54
+		"Title":          "SSH and GPG keys",
3855
 		"CSRFToken":      middleware.CSRFTokenForRequest(r),
3956
 		"SettingsActive": "keys",
40
-		"Keys":           keys,
57
+		"Keys":           sshKeys,
58
+		"GPGKeys":        viewModelGPGKeys(gpgKeys),
4159
 		"AddError":       addError,
4260
 		"AddTitle":       addTitle,
4361
 		"AddBlob":        addBlob,
4462
 	})
4563
 }
4664
 
65
+// gpgListPageSize is the SHOULD-be-enough cap for the per-user GPG
66
+// key count in the settings UI. Mirrors the parser's MaxKeysPerUser;
67
+// the list is short by definition.
68
+const gpgListPageSize = 100
69
+
70
+// gpgKeyView is the template-facing shape of a GPG key row. Decoupled
71
+// from the sqlc UserGpgKey row so the template doesn't have to know
72
+// about pgtype wrappers or unmarshal subkeys JSON inline.
73
+type gpgKeyView struct {
74
+	ID        int64
75
+	Name      string
76
+	KeyID     string
77
+	Emails    []struct{ Email string }
78
+	SubkeyIDs []string
79
+	CreatedAt time.Time
80
+}
81
+
82
+// viewModelGPGKeys transforms the sqlc row shape into the template-
83
+// facing struct. Unmarshals the subkeys JSONB once per row so the
84
+// template can render the comma-separated subkey-IDs line without
85
+// re-parsing.
86
+func viewModelGPGKeys(rows []usersdb.UserGpgKey) []gpgKeyView {
87
+	views := make([]gpgKeyView, 0, len(rows))
88
+	for _, row := range rows {
89
+		view := gpgKeyView{
90
+			ID:        row.ID,
91
+			Name:      row.Name,
92
+			KeyID:     strings.ToUpper(row.KeyID),
93
+			CreatedAt: row.CreatedAt.Time,
94
+		}
95
+		for _, uid := range row.Uids {
96
+			view.Emails = append(view.Emails, struct{ Email string }{Email: uid})
97
+		}
98
+		var subkeys []struct {
99
+			KeyID string `json:"KeyID"`
100
+		}
101
+		_ = json.Unmarshal(row.Subkeys, &subkeys)
102
+		for _, sk := range subkeys {
103
+			view.SubkeyIDs = append(view.SubkeyIDs, strings.ToUpper(sk.KeyID))
104
+		}
105
+		views = append(views, view)
106
+	}
107
+	return views
108
+}
109
+
47110
 // sshKeysAdd handles POST /settings/keys.
48111
 func (h *Handlers) sshKeysAdd(w http.ResponseWriter, r *http.Request) {
49112
 	if err := r.ParseForm(); err != nil {
@@ -56,7 +119,7 @@ func (h *Handlers) sshKeysAdd(w http.ResponseWriter, r *http.Request) {
56119
 
57120
 	parsed, err := sshkey.Parse(title, blob)
58121
 	if err != nil {
59
-		h.renderSSHKeysList(w, r, friendlySSHError(err), title, blob)
122
+		h.renderKeysList(w, r, friendlySSHError(err), title, blob)
60123
 		return
61124
 	}
62125
 
@@ -68,7 +131,7 @@ func (h *Handlers) sshKeysAdd(w http.ResponseWriter, r *http.Request) {
68131
 		return
69132
 	}
70133
 	if count >= int64(sshkey.MaxKeysPerUser) {
71
-		h.renderSSHKeysList(w, r,
134
+		h.renderKeysList(w, r,
72135
 			"You've reached the per-user SSH-key cap. Delete an unused key first.",
73136
 			title, blob)
74137
 		return
@@ -84,7 +147,7 @@ func (h *Handlers) sshKeysAdd(w http.ResponseWriter, r *http.Request) {
84147
 		Kind:              "authentication",
85148
 	}); err != nil {
86149
 		if isPGUniqueViolation(err) {
87
-			h.renderSSHKeysList(w, r,
150
+			h.renderKeysList(w, r,
88151
 				"That key is already registered (here or on another account).",
89152
 				title, blob)
90153
 			return