Commit ad34785bfc

ad34785bfc81f3b4ca0f4587f86a29f32bb7d104

parent: a082fdf528

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 01:07 UTC

control, store, web: SSH keys carry a label

Migration 0051 adds ssh_keys.label. keys add takes --label and
defaults to the authorized_keys comment; keys label <fingerprint>
[<text>] renames or clears. keys list, admin user show, repo
deploy-key list and the settings page show it. Deploy, runner and
admin-created keys take the comment as their label.

Closes #208
.gitbay/wiki/Parity.org +1
@@ -283,6 +283,7 @@ client has no use for one (krz/gitbay#57).
283283| capability | cli | web | ios |
284284|-----------------------------+-----+-----+-----|
285285| SSH keys: list, add, remove | yes | yes | yes |
286| SSH key label | yes | yes | no |
286287| PGP keys: list, add, remove | yes | yes | yes |
287288| email add and verify | yes | yes | yes |
288289| email list, remove, primary | yes | yes | yes |
.gitbay/wiki/Users.org +6
@@ -58,9 +58,15 @@ username is always =git= — the key alone determines who you are.
5858#+begin_src sh
5959gitbay auth keys list
6060gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin
61gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub
62gitbay auth keys label SHA256:... "work laptop"
6163gitbay auth keys remove SHA256:...
6264#+end_src
6365
66A key's label is the comment on its =authorized_keys= line unless
67=--label= gives one; =keys label= renames a key, and with no text
68clears the name. Labels are one line of up to 64 bytes.
69
6470Scopes: =full= (default; git plus every control command), =git= (git
6571transport only — right for automation keys, which then cannot touch
6672issues, settings, or your account), or =runner= (the CI runner's
cmd/gitbay/main.go +1
@@ -386,6 +386,7 @@ func authCmd() *cobra.Command {
386386 group("keys", "manage SSH keys",
387387 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
388388 keysAdd,
389 pass("label", "name a key: <fingerprint> [<text>]; no text clears it", passOpts{server: []string{"keys", "label"}}),
389390 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
390391 ),
391392 group("email", "manage email addresses",
e2e/ssh_test.go +12
@@ -227,6 +227,18 @@ func TestControlPlaneOverBareSSH(t *testing.T) {
227227 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
228228 t.Fatalf("keys list exit %d:\n%s", code, out)
229229 }
230 // The key's comment (ssh-keygen -C) is its label; keys label renames it.
231 if !strings.Contains(out, "\tgit\talice2\n") {
232 t.Fatalf("keys list lacks the comment as label:\n%s", out)
233 }
234 secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0]
235 if _, errOut, code := inst.ssh(t, aliceKey, "", "keys", "label", secondFP, "'build box'"); code != 0 {
236 t.Fatalf("keys label exit %d, stderr: %s", code, errOut)
237 }
238 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list")
239 if !strings.Contains(out, "\tgit\tbuild box\n") {
240 t.Fatalf("keys list after label:\n%s", out)
241 }
230242
231243 // The git-scoped key authenticates but is denied control commands.
232244 out, errOut, code = inst.ssh(t, secondKey, "", "whoami")
internal/control/admin.go +2 −1
@@ -149,6 +149,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
149149 Fingerprint string `json:"fingerprint"`
150150 Algo string `json:"algo"`
151151 Scope string `json:"scope"`
152 Label string `json:"label"`
152153 CreatedAt string `json:"created_at"`
153154 LastUsedAt string `json:"last_used_at,omitempty"`
154155 }
@@ -194,7 +195,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
194195 return c.fail(protocol.ExitFailure, "%v", err)
195196 }
196197 for _, k := range keys {
197 d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.CreatedAt, k.LastUsedAt})
198 d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedAt, k.LastUsedAt})
198199 }
199200 emails, err := c.Store.ListEmails(u.ID)
200201 if err != nil {
internal/control/adminhost.go +4 −2
@@ -77,12 +77,13 @@ func runAdminUserCreate(c *Ctx, args []string) int {
7777 // Parse the key before creating anything, so a bad key leaves no
7878 // half-made account behind.
7979 var pub ssh.PublicKey
80 var comment string
8081 if withKey {
8182 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
8283 if err != nil {
8384 return c.fail(protocol.ExitFailure, "reading key: %v", err)
8485 }
85 if pub, _, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil {
86 if pub, comment, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil {
8687 return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err)
8788 }
8889 }
@@ -102,7 +103,8 @@ func runAdminUserCreate(c *Ctx, args []string) int {
102103 fp := ""
103104 if pub != nil {
104105 fp = ssh.FingerprintSHA256(pub)
105 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
106 label, _ := keyLabel(comment)
107 if err := c.Store.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", label); err != nil {
106108 return c.failErr(err)
107109 }
108110 }
internal/control/deploykey.go +9 −4
@@ -50,13 +50,17 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
5050 if err != nil {
5151 return c.fail(protocol.ExitFailure, "reading key: %v", err)
5252 }
53 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
53 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
5454 if err != nil {
5555 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
5656 }
57 label, err := keyLabel(comment)
58 if err != nil {
59 return c.fail(protocol.ExitUsage, "%v", err)
60 }
5761 fp := ssh.FingerprintSHA256(pub)
5862 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode)
59 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
63 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil {
6064 if errors.Is(err, store.ErrDuplicateKey) {
6165 return c.failErr(err)
6266 }
@@ -83,6 +87,7 @@ func runDeployKeyList(c *Ctx, args []string) int {
8387 Fingerprint string `json:"fingerprint"`
8488 Algo string `json:"algo"`
8589 Mode string `json:"mode"`
90 Label string `json:"label"`
8691 }
8792 var ds []out
8893 for _, k := range keys {
@@ -90,11 +95,11 @@ func runDeployKeyList(c *Ctx, args []string) int {
9095 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
9196 mode = "rw"
9297 }
93 ds = append(ds, out{k.Fingerprint, k.Algo, mode})
98 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label})
9499 }
95100 return c.emit(ds, func(w io.Writer) {
96101 for _, d := range ds {
97 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode)
102 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode, d.Label)
98103 }
99104 })
100105}
internal/control/identity.go +76 −7
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "strings"
8 "unicode"
79
810 "golang.org/x/crypto/ssh"
911
@@ -29,10 +31,16 @@ func init() {
2931 register(Command{
3032 Path: []string{"keys", "add"},
3133 Summary: "register an SSH public key (authorized_keys format)",
32 Usage: "keys add [--scope full|git|runner] < key.pub",
34 Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub",
3335 ReadsStdin: true,
3436 Run: runKeysAdd,
3537 })
38 register(Command{
39 Path: []string{"keys", "label"},
40 Summary: "name a key; an empty label clears it",
41 Usage: "keys label <fingerprint> [<text>]",
42 Run: runKeysLabel,
43 })
3644 register(Command{
3745 Path: []string{"keys", "remove"},
3846 Summary: "remove an SSH key by fingerprint",
@@ -68,20 +76,40 @@ func runKeysList(c *Ctx, args []string) int {
6876 Fingerprint string `json:"fingerprint"`
6977 Algo string `json:"algo"`
7078 Scope string `json:"scope"`
79 Label string `json:"label"`
7180 }
7281 var ds []out
7382 for _, k := range keys {
74 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope})
83 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label})
7584 }
7685 return c.emit(ds, func(w io.Writer) {
7786 for _, d := range ds {
78 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope)
87 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope, d.Label)
7988 }
8089 })
8190}
8291
92// maxKeyLabel bounds a key's name. Labels are display text, one line.
93const maxKeyLabel = 64
94
95// keyLabel normalises a label: surrounding space trimmed, control
96// characters refused, length capped. An empty result is a valid "no
97// label".
98func keyLabel(s string) (string, error) {
99 s = strings.TrimSpace(s)
100 if len(s) > maxKeyLabel {
101 return "", fmt.Errorf("label is longer than %d bytes", maxKeyLabel)
102 }
103 for _, r := range s {
104 if unicode.IsControl(r) {
105 return "", errors.New("label must be a single line of printable text")
106 }
107 }
108 return s, nil
109}
110
83111func runKeysAdd(c *Ctx, args []string) int {
84 f, err := parseFlags(args, flagSpec{Values: []string{"--scope"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] < key.pub"})
112 f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"})
85113 if err != nil {
86114 return c.fail(protocol.ExitUsage, "%v", err)
87115 }
@@ -97,12 +125,20 @@ func runKeysAdd(c *Ctx, args []string) int {
97125 if err != nil {
98126 return c.fail(protocol.ExitFailure, "reading key: %v", err)
99127 }
100 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
128 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
101129 if err != nil {
102130 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
103131 }
132 // The key's own comment is the label unless --label says otherwise.
133 label := comment
134 if f.Has("--label") {
135 label = f.Value("--label")
136 }
137 if label, err = keyLabel(label); err != nil {
138 return c.fail(protocol.ExitUsage, "%v", err)
139 }
104140 fp := ssh.FingerprintSHA256(pub)
105 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
141 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil {
106142 if errors.Is(err, store.ErrDuplicateKey) {
107143 return c.failErr(err)
108144 }
@@ -111,13 +147,46 @@ func runKeysAdd(c *Ctx, args []string) int {
111147 type out struct {
112148 Fingerprint string `json:"fingerprint"`
113149 Scope string `json:"scope"`
150 Label string `json:"label"`
114151 }
115 d := out{fp, scope}
152 d := out{fp, scope, label}
116153 return c.emit(d, func(w io.Writer) {
154 if d.Label != "" {
155 fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label)
156 return
157 }
117158 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
118159 })
119160}
120161
162func runKeysLabel(c *Ctx, args []string) int {
163 if len(args) < 1 || len(args) > 2 {
164 return c.fail(protocol.ExitUsage, "usage: keys label <fingerprint> [<text>]")
165 }
166 label := ""
167 if len(args) == 2 {
168 label = args[1]
169 }
170 label, err := keyLabel(label)
171 if err != nil {
172 return c.fail(protocol.ExitUsage, "%v", err)
173 }
174 if err := c.Store.SetSSHKeyLabel(c.User.ID, args[0], label); err != nil {
175 if errors.Is(err, store.ErrNotFound) {
176 return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
177 }
178 return c.fail(protocol.ExitFailure, "labelling key: %v", err)
179 }
180 d := map[string]string{"fingerprint": args[0], "label": label}
181 return c.emit(d, func(w io.Writer) {
182 if label == "" {
183 fmt.Fprintf(w, "cleared label on %s\n", args[0])
184 return
185 }
186 fmt.Fprintf(w, "%s is now %q\n", args[0], label)
187 })
188}
189
121190func runKeysRemove(c *Ctx, args []string) int {
122191 if len(args) != 1 {
123192 return c.fail(protocol.ExitUsage, "usage: keys remove <fingerprint>")
internal/control/keylabel_test.go added +23
@@ -0,0 +1,23 @@
1package control
2
3import "testing"
4
5func TestKeyLabel(t *testing.T) {
6 cases := []struct {
7 in, want string
8 ok bool
9 }{
10 {"", "", true},
11 {" laptop ", "laptop", true},
12 {"you@machine", "you@machine", true},
13 {"two\nlines", "", false},
14 {"tab\there", "", false},
15 {string(make([]byte, 65)), "", false},
16 }
17 for _, c := range cases {
18 got, err := keyLabel(c.in)
19 if (err == nil) != c.ok || got != c.want {
20 t.Errorf("keyLabel(%q) = %q, %v; want %q, ok=%v", c.in, got, err, c.want, c.ok)
21 }
22 }
23}
internal/control/runnerattach_test.go +1 −1
@@ -40,7 +40,7 @@ func newAttachFixture(t *testing.T) attachFixture {
4040 if err != nil {
4141 t.Fatal(err)
4242 }
43 if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner"); err != nil {
43 if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner", ""); err != nil {
4444 t.Fatal(err)
4545 }
4646 k, _ := st.SSHKeyByFingerprint(fp)
internal/control/runnernext_test.go +1 −1
@@ -19,7 +19,7 @@ import (
1919func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) {
2020 var out bytes.Buffer
2121 fp := fmt.Sprintf("SHA256:runner-%d", uid)
22 st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full") // ErrDuplicateKey on reuse is fine
22 st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full", "") // ErrDuplicateKey on reuse is fine
2323 c := &Ctx{
2424 User: store.User{ID: uid, Username: "ci", IsAdmin: true},
2525 Scope: "full",
internal/control/runnerrepo.go +3 −2
@@ -43,7 +43,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
4343 if err != nil {
4444 return c.fail(protocol.ExitFailure, "reading key: %v", err)
4545 }
46 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
46 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
4747 if err != nil {
4848 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
4949 }
@@ -51,7 +51,8 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
5151 key, err := c.Store.SSHKeyByFingerprint(fp)
5252 switch {
5353 case errors.Is(err, store.ErrNotFound):
54 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil {
54 label, _ := keyLabel(comment)
55 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label); err != nil {
5556 return c.fail(protocol.ExitFailure, "adding key: %v", err)
5657 }
5758 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
internal/control/runnerrepo_test.go +2 −2
@@ -89,11 +89,11 @@ func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) {
8989 }
9090 // Someone else's runner key.
9191 bob, _ := st.CreateUser("bob", false)
92 if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil {
92 if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner", ""); err != nil {
9393 t.Fatal(err)
9494 }
9595 st.RemoveSSHKey(uid, keys[0].Fingerprint)
96 if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil {
96 if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner", ""); err != nil {
9797 t.Fatal(err)
9898 }
9999 c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
internal/httpd/account.go +5 −1
@@ -19,6 +19,7 @@ type accountKey struct {
1919 Fingerprint string
2020 Algo string
2121 Scope string
22 Label string
2223}
2324
2425type accountPGP struct {
@@ -34,7 +35,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
3435 var keys []accountKey
3536 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
3637 for _, k := range list {
37 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope})
38 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label})
3839 }
3940 }
4041 var pgp []accountPGP
@@ -141,6 +142,9 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
141142 if scope := r.FormValue("scope"); scope == "git" {
142143 argv = append(argv, "--scope", "git")
143144 }
145 if label := strings.TrimSpace(r.FormValue("label")); label != "" {
146 argv = append(argv, "--label", label)
147 }
144148 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
145149 back(msg, "")
146150 return
internal/store/migrations/0051_ssh_key_label.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE ssh_keys DROP COLUMN label;
internal/store/migrations/0051_ssh_key_label.up.sql added +3
@@ -0,0 +1,3 @@
1-- A name for the key, shown next to its fingerprint. Defaults to the
2-- comment field of the authorized_keys line when the key is added.
3ALTER TABLE ssh_keys ADD COLUMN label TEXT NOT NULL DEFAULT '';
internal/store/runners_test.go +2 −2
@@ -16,7 +16,7 @@ func runnerFixture(t *testing.T) (s *Store, uid, keyID, repoA, repoB int64) {
1616 if err != nil {
1717 t.Fatal(err)
1818 }
19 if err := s.AddSSHKey(uid, "SHA256:runnerkey", "ssh-ed25519", []byte("blob"), "runner"); err != nil {
19 if err := s.AddSSHKey(uid, "SHA256:runnerkey", "ssh-ed25519", []byte("blob"), "runner", ""); err != nil {
2020 t.Fatal(err)
2121 }
2222 k, err := s.SSHKeyByFingerprint("SHA256:runnerkey")
@@ -88,7 +88,7 @@ func TestRunnerAttachmentCascades(t *testing.T) {
8888// repository's runner list shows each key's last poll and the build it holds.
8989func TestRunnerSeenPerKeyAndRepoList(t *testing.T) {
9090 s, uid, keyID, repoA, _ := runnerFixture(t)
91 if err := s.AddSSHKey(uid, "SHA256:second", "ssh-ed25519", []byte("blob2"), "runner"); err != nil {
91 if err := s.AddSSHKey(uid, "SHA256:second", "ssh-ed25519", []byte("blob2"), "runner", ""); err != nil {
9292 t.Fatal(err)
9393 }
9494 k2, _ := s.SSHKeyByFingerprint("SHA256:second")
internal/store/store_test.go +29
@@ -115,3 +115,32 @@ func TestDatabaseFileIsNotWorldReadable(t *testing.T) {
115115 t.Errorf("database mode %04o is other-readable", mode)
116116 }
117117}
118
119func TestSSHKeyLabel(t *testing.T) {
120 s := open(t)
121 if err := s.MigrateUp(); err != nil {
122 t.Fatal(err)
123 }
124 uid, err := s.CreateUser("alice", false)
125 if err != nil {
126 t.Fatal(err)
127 }
128 if err := s.AddSSHKey(uid, "SHA256:aaa", "ssh-ed25519", []byte{0}, "full", "laptop"); err != nil {
129 t.Fatal(err)
130 }
131 keys, err := s.ListSSHKeys(uid)
132 if err != nil || len(keys) != 1 || keys[0].Label != "laptop" {
133 t.Fatalf("ListSSHKeys = %+v, %v; want one key labelled laptop", keys, err)
134 }
135 if err := s.SetSSHKeyLabel(uid, "SHA256:aaa", "desk"); err != nil {
136 t.Fatal(err)
137 }
138 k, err := s.SSHKeyByFingerprint("SHA256:aaa")
139 if err != nil || k.Label != "desk" {
140 t.Fatalf("SSHKeyByFingerprint after relabel: %+v, %v", k, err)
141 }
142 // Only the owner may relabel; someone else's fingerprint is not found.
143 if err := s.SetSSHKeyLabel(uid+1, "SHA256:aaa", "x"); err != ErrNotFound {
144 t.Fatalf("relabel by another user: %v, want ErrNotFound", err)
145 }
146}
internal/store/users.go +25 −11
@@ -22,6 +22,7 @@ type SSHKey struct {
2222 Algo string
2323 Blob []byte
2424 Scope string
25 Label string // "" when the key was added with no name
2526 CreatedAt string
2627 LastUsedAt string // "" when the key has never authenticated
2728}
@@ -230,15 +231,15 @@ func (s *Store) UserByID(id int64) (User, error) {
230231}
231232
232233// AddSSHKey registers a key and bumps the key epoch in one transaction.
233func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error {
234func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
234235 tx, err := s.DB.Begin()
235236 if err != nil {
236237 return err
237238 }
238239 defer tx.Rollback()
239240 if _, err := tx.Exec(
240 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)",
241 userID, fingerprint, algo, blob, scope); err != nil {
241 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)",
242 userID, fingerprint, algo, blob, scope, label); err != nil {
242243 if isUniqueErr(err) {
243244 return ErrDuplicateKey
244245 }
@@ -270,11 +271,24 @@ func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
270271 return tx.Commit()
271272}
272273
274// SetSSHKeyLabel renames a key owned by userID. Labels do not touch the
275// key epoch: nothing about authentication changes.
276func (s *Store) SetSSHKeyLabel(userID int64, fingerprint, label string) error {
277 res, err := s.DB.Exec("UPDATE ssh_keys SET label = ? WHERE user_id = ? AND fingerprint = ?", label, userID, fingerprint)
278 if err != nil {
279 return err
280 }
281 if n, _ := res.RowsAffected(); n == 0 {
282 return ErrNotFound
283 }
284 return nil
285}
286
273287func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
274288 var k SSHKey
275289 err := s.DB.QueryRow(
276 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?",
277 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
290 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE fingerprint = ?",
291 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
278292 if errors.Is(err, sql.ErrNoRows) {
279293 return k, ErrNotFound
280294 }
@@ -283,7 +297,7 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
283297
284298func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
285299 rows, err := s.DB.Query(
286 `SELECT id, user_id, fingerprint, algo, blob, scope, created_at, COALESCE(last_used_at, '')
300 `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '')
287301 FROM ssh_keys WHERE user_id = ? ORDER BY id`,
288302 userID)
289303 if err != nil {
@@ -293,7 +307,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
293307 var keys []SSHKey
294308 for rows.Next() {
295309 var k SSHKey
296 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.CreatedAt, &k.LastUsedAt); err != nil {
310 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt); err != nil {
297311 return nil, err
298312 }
299313 keys = append(keys, k)
@@ -451,8 +465,8 @@ func isUniqueErr(err error) bool {
451465func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
452466 var k SSHKey
453467 err := s.DB.QueryRow(
454 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE id = ?",
455 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
468 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE id = ?",
469 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
456470 if errors.Is(err, sql.ErrNoRows) {
457471 return k, ErrNotFound
458472 }
@@ -462,7 +476,7 @@ func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
462476// ListDeployKeys returns the deploy keys bound to a repository.
463477func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
464478 rows, err := s.DB.Query(
465 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id",
479 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id",
466480 repoID)
467481 if err != nil {
468482 return nil, err
@@ -471,7 +485,7 @@ func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
471485 var keys []SSHKey
472486 for rows.Next() {
473487 var k SSHKey
474 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil {
488 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label); err != nil {
475489 return nil, err
476490 }
477491 keys = append(keys, k)
internal/web/templates/account.html +4 −1
@@ -25,8 +25,9 @@
2525commands and push; a <code>git</code> key can only move git data, which is what
2626a CI checkout wants.</p>
2727{{if .Keys}}<div class="tablewrap"><table class="keys">
28<tr class="cols"><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr>
28<tr class="cols"><th scope="col">label</th><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr>
2929{{range .Keys}}<tr>
30 <td>{{.Label}}</td>
3031 <td class="mono">{{.Fingerprint}}</td>
3132 <td>{{.Algo}}</td>
3233 <td>{{.Scope}}</td>
@@ -40,6 +41,8 @@ a CI checkout wants.</p>
4041 <input type="hidden" name="field" value="key-add">
4142 <label for="key">Public key</label>
4243 <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@machine"></textarea>
44 <label for="key-label">Label</label>
45 <input id="key-label" name="label" maxlength="64" placeholder="defaults to the key's comment">
4346 <label for="scope">Scope</label>
4447 <select id="scope" name="scope">
4548 <option value="full">full — commands and git</option>