SSH keys carry a label !370

merged merged by cmc on 2026-09-11 01:19 UTC · krz/gitbay:ssh-key-labels into main

20 files changed, +210 −35

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -283,6 +283,7 @@ client has no use for one (krz/gitbay#57).
283| capability | cli | web | ios | 283| capability | cli | web | ios |
284|-----------------------------+-----+-----+-----| 284|-----------------------------+-----+-----+-----|
285| SSH keys: list, add, remove | yes | yes | yes | 285| SSH keys: list, add, remove | yes | yes | yes |
286| SSH key label | yes | yes | no |
286| PGP keys: list, add, remove | yes | yes | yes | 287| PGP keys: list, add, remove | yes | yes | yes |
287| email add and verify | yes | yes | yes | 288| email add and verify | yes | yes | yes |
288| email list, remove, primary | yes | yes | yes | 289| 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.
58#+begin_src sh 58#+begin_src sh
59gitbay auth keys list 59gitbay auth keys list
60gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin 60gitbay 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"
61gitbay auth keys remove SHA256:... 63gitbay auth keys remove SHA256:...
62#+end_src 64#+end_src
63 65
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
64Scopes: =full= (default; git plus every control command), =git= (git 70Scopes: =full= (default; git plus every control command), =git= (git
65transport only — right for automation keys, which then cannot touch 71transport only — right for automation keys, which then cannot touch
66issues, settings, or your account), or =runner= (the CI runner's 72issues, settings, or your account), or =runner= (the CI runner's
cmd/gitbay/main.go +1
@@ -386,6 +386,7 @@ func authCmd() *cobra.Command {
386 group("keys", "manage SSH keys", 386 group("keys", "manage SSH keys",
387 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), 387 pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}),
388 keysAdd, 388 keysAdd,
389 pass("label", "name a key: <fingerprint> [<text>]; no text clears it", passOpts{server: []string{"keys", "label"}}),
389 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), 390 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
390 ), 391 ),
391 group("email", "manage email addresses", 392 group("email", "manage email addresses",
e2e/ssh_test.go +12
@@ -227,6 +227,18 @@ func TestControlPlaneOverBareSSH(t *testing.T) {
227 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 { 227 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
228 t.Fatalf("keys list exit %d:\n%s", code, out) 228 t.Fatalf("keys list exit %d:\n%s", code, out)
229 } 229 }
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 }
230 242
231 // The git-scoped key authenticates but is denied control commands. 243 // The git-scoped key authenticates but is denied control commands.
232 out, errOut, code = inst.ssh(t, secondKey, "", "whoami") 244 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 {
149 Fingerprint string `json:"fingerprint"` 149 Fingerprint string `json:"fingerprint"`
150 Algo string `json:"algo"` 150 Algo string `json:"algo"`
151 Scope string `json:"scope"` 151 Scope string `json:"scope"`
152 Label string `json:"label"`
152 CreatedAt string `json:"created_at"` 153 CreatedAt string `json:"created_at"`
153 LastUsedAt string `json:"last_used_at,omitempty"` 154 LastUsedAt string `json:"last_used_at,omitempty"`
154 } 155 }
@@ -194,7 +195,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
194 return c.fail(protocol.ExitFailure, "%v", err) 195 return c.fail(protocol.ExitFailure, "%v", err)
195 } 196 }
196 for _, k := range keys { 197 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})
198 } 199 }
199 emails, err := c.Store.ListEmails(u.ID) 200 emails, err := c.Store.ListEmails(u.ID)
200 if err != nil { 201 if err != nil {
internal/control/adminhost.go +4 −2
@@ -77,12 +77,13 @@ func runAdminUserCreate(c *Ctx, args []string) int {
77 // Parse the key before creating anything, so a bad key leaves no 77 // Parse the key before creating anything, so a bad key leaves no
78 // half-made account behind. 78 // half-made account behind.
79 var pub ssh.PublicKey 79 var pub ssh.PublicKey
80 var comment string
80 if withKey { 81 if withKey {
81 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) 82 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
82 if err != nil { 83 if err != nil {
83 return c.fail(protocol.ExitFailure, "reading key: %v", err) 84 return c.fail(protocol.ExitFailure, "reading key: %v", err)
84 } 85 }
85 if pub, _, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil { 86 if pub, comment, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil {
86 return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err) 87 return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err)
87 } 88 }
88 } 89 }
@@ -102,7 +103,8 @@ func runAdminUserCreate(c *Ctx, args []string) int {
102 fp := "" 103 fp := ""
103 if pub != nil { 104 if pub != nil {
104 fp = ssh.FingerprintSHA256(pub) 105 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 {
106 return c.failErr(err) 108 return c.failErr(err)
107 } 109 }
108 } 110 }
internal/control/deploykey.go +9 −4
@@ -50,13 +50,17 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
50 if err != nil { 50 if err != nil {
51 return c.fail(protocol.ExitFailure, "reading key: %v", err) 51 return c.fail(protocol.ExitFailure, "reading key: %v", err)
52 } 52 }
53 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) 53 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
54 if err != nil { 54 if err != nil {
55 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) 55 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
56 } 56 }
57 label, err := keyLabel(comment)
58 if err != nil {
59 return c.fail(protocol.ExitUsage, "%v", err)
60 }
57 fp := ssh.FingerprintSHA256(pub) 61 fp := ssh.FingerprintSHA256(pub)
58 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) 62 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 {
60 if errors.Is(err, store.ErrDuplicateKey) { 64 if errors.Is(err, store.ErrDuplicateKey) {
61 return c.failErr(err) 65 return c.failErr(err)
62 } 66 }
@@ -83,6 +87,7 @@ func runDeployKeyList(c *Ctx, args []string) int {
83 Fingerprint string `json:"fingerprint"` 87 Fingerprint string `json:"fingerprint"`
84 Algo string `json:"algo"` 88 Algo string `json:"algo"`
85 Mode string `json:"mode"` 89 Mode string `json:"mode"`
90 Label string `json:"label"`
86 } 91 }
87 var ds []out 92 var ds []out
88 for _, k := range keys { 93 for _, k := range keys {
@@ -90,11 +95,11 @@ func runDeployKeyList(c *Ctx, args []string) int {
90 if policy.DeployScopeAllows(k.Scope, repo.ID, true) { 95 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
91 mode = "rw" 96 mode = "rw"
92 } 97 }
93 ds = append(ds, out{k.Fingerprint, k.Algo, mode}) 98 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label})
94 } 99 }
95 return c.emit(ds, func(w io.Writer) { 100 return c.emit(ds, func(w io.Writer) {
96 for _, d := range ds { 101 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)
98 } 103 }
99 }) 104 })
100} 105}
internal/control/identity.go +76 −7
@@ -4,6 +4,8 @@ import (
4 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "strings"
8 "unicode"
7 9
8 "golang.org/x/crypto/ssh" 10 "golang.org/x/crypto/ssh"
9 11
@@ -29,10 +31,16 @@ func init() {
29 register(Command{ 31 register(Command{
30 Path: []string{"keys", "add"}, 32 Path: []string{"keys", "add"},
31 Summary: "register an SSH public key (authorized_keys format)", 33 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",
33 ReadsStdin: true, 35 ReadsStdin: true,
34 Run: runKeysAdd, 36 Run: runKeysAdd,
35 }) 37 })
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 })
36 register(Command{ 44 register(Command{
37 Path: []string{"keys", "remove"}, 45 Path: []string{"keys", "remove"},
38 Summary: "remove an SSH key by fingerprint", 46 Summary: "remove an SSH key by fingerprint",
@@ -68,20 +76,40 @@ func runKeysList(c *Ctx, args []string) int {
68 Fingerprint string `json:"fingerprint"` 76 Fingerprint string `json:"fingerprint"`
69 Algo string `json:"algo"` 77 Algo string `json:"algo"`
70 Scope string `json:"scope"` 78 Scope string `json:"scope"`
79 Label string `json:"label"`
71 } 80 }
72 var ds []out 81 var ds []out
73 for _, k := range keys { 82 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})
75 } 84 }
76 return c.emit(ds, func(w io.Writer) { 85 return c.emit(ds, func(w io.Writer) {
77 for _, d := range ds { 86 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)
79 } 88 }
80 }) 89 })
81} 90}
82 91
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
83func runKeysAdd(c *Ctx, args []string) int { 111func 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"})
85 if err != nil { 113 if err != nil {
86 return c.fail(protocol.ExitUsage, "%v", err) 114 return c.fail(protocol.ExitUsage, "%v", err)
87 } 115 }
@@ -97,12 +125,20 @@ func runKeysAdd(c *Ctx, args []string) int {
97 if err != nil { 125 if err != nil {
98 return c.fail(protocol.ExitFailure, "reading key: %v", err) 126 return c.fail(protocol.ExitFailure, "reading key: %v", err)
99 } 127 }
100 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) 128 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
101 if err != nil { 129 if err != nil {
102 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) 130 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
103 } 131 }
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 }
104 fp := ssh.FingerprintSHA256(pub) 140 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 {
106 if errors.Is(err, store.ErrDuplicateKey) { 142 if errors.Is(err, store.ErrDuplicateKey) {
107 return c.failErr(err) 143 return c.failErr(err)
108 } 144 }
@@ -111,13 +147,46 @@ func runKeysAdd(c *Ctx, args []string) int {
111 type out struct { 147 type out struct {
112 Fingerprint string `json:"fingerprint"` 148 Fingerprint string `json:"fingerprint"`
113 Scope string `json:"scope"` 149 Scope string `json:"scope"`
150 Label string `json:"label"`
114 } 151 }
115 d := out{fp, scope} 152 d := out{fp, scope, label}
116 return c.emit(d, func(w io.Writer) { 153 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 }
117 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope) 158 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
118 }) 159 })
119} 160}
120 161
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
121func runKeysRemove(c *Ctx, args []string) int { 190func runKeysRemove(c *Ctx, args []string) int {
122 if len(args) != 1 { 191 if len(args) != 1 {
123 return c.fail(protocol.ExitUsage, "usage: keys remove <fingerprint>") 192 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 {
40 if err != nil { 40 if err != nil {
41 t.Fatal(err) 41 t.Fatal(err)
42 } 42 }
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 {
44 t.Fatal(err) 44 t.Fatal(err)
45 } 45 }
46 k, _ := st.SSHKeyByFingerprint(fp) 46 k, _ := st.SSHKeyByFingerprint(fp)
internal/control/runnernext_test.go +1 −1
@@ -19,7 +19,7 @@ import (
19func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) { 19func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) {
20 var out bytes.Buffer 20 var out bytes.Buffer
21 fp := fmt.Sprintf("SHA256:runner-%d", uid) 21 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
23 c := &Ctx{ 23 c := &Ctx{
24 User: store.User{ID: uid, Username: "ci", IsAdmin: true}, 24 User: store.User{ID: uid, Username: "ci", IsAdmin: true},
25 Scope: "full", 25 Scope: "full",
internal/control/runnerrepo.go +3 −2
@@ -43,7 +43,7 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
43 if err != nil { 43 if err != nil {
44 return c.fail(protocol.ExitFailure, "reading key: %v", err) 44 return c.fail(protocol.ExitFailure, "reading key: %v", err)
45 } 45 }
46 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) 46 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
47 if err != nil { 47 if err != nil {
48 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) 48 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
49 } 49 }
@@ -51,7 +51,8 @@ func runRepoRunnerAdd(c *Ctx, args []string) int {
51 key, err := c.Store.SSHKeyByFingerprint(fp) 51 key, err := c.Store.SSHKeyByFingerprint(fp)
52 switch { 52 switch {
53 case errors.Is(err, store.ErrNotFound): 53 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 {
55 return c.fail(protocol.ExitFailure, "adding key: %v", err) 56 return c.fail(protocol.ExitFailure, "adding key: %v", err)
56 } 57 }
57 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { 58 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) {
89 } 89 }
90 // Someone else's runner key. 90 // Someone else's runner key.
91 bob, _ := st.CreateUser("bob", false) 91 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 {
93 t.Fatal(err) 93 t.Fatal(err)
94 } 94 }
95 st.RemoveSSHKey(uid, keys[0].Fingerprint) 95 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 {
97 t.Fatal(err) 97 t.Fatal(err)
98 } 98 }
99 c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) 99 c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
internal/httpd/account.go +5 −1
@@ -19,6 +19,7 @@ type accountKey struct {
19 Fingerprint string 19 Fingerprint string
20 Algo string 20 Algo string
21 Scope string 21 Scope string
22 Label string
22} 23}
23 24
24type accountPGP struct { 25type accountPGP struct {
@@ -34,7 +35,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
34 var keys []accountKey 35 var keys []accountKey
35 if list, err := s.st.ListSSHKeys(u.ID); err == nil { 36 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
36 for _, k := range list { 37 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})
38 } 39 }
39 } 40 }
40 var pgp []accountPGP 41 var pgp []accountPGP
@@ -141,6 +142,9 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
141 if scope := r.FormValue("scope"); scope == "git" { 142 if scope := r.FormValue("scope"); scope == "git" {
142 argv = append(argv, "--scope", "git") 143 argv = append(argv, "--scope", "git")
143 } 144 }
145 if label := strings.TrimSpace(r.FormValue("label")); label != "" {
146 argv = append(argv, "--label", label)
147 }
144 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok { 148 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
145 back(msg, "") 149 back(msg, "")
146 return 150 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) {
16 if err != nil { 16 if err != nil {
17 t.Fatal(err) 17 t.Fatal(err)
18 } 18 }
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 {
20 t.Fatal(err) 20 t.Fatal(err)
21 } 21 }
22 k, err := s.SSHKeyByFingerprint("SHA256:runnerkey") 22 k, err := s.SSHKeyByFingerprint("SHA256:runnerkey")
@@ -88,7 +88,7 @@ func TestRunnerAttachmentCascades(t *testing.T) {
88// repository's runner list shows each key's last poll and the build it holds. 88// repository's runner list shows each key's last poll and the build it holds.
89func TestRunnerSeenPerKeyAndRepoList(t *testing.T) { 89func TestRunnerSeenPerKeyAndRepoList(t *testing.T) {
90 s, uid, keyID, repoA, _ := runnerFixture(t) 90 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 {
92 t.Fatal(err) 92 t.Fatal(err)
93 } 93 }
94 k2, _ := s.SSHKeyByFingerprint("SHA256:second") 94 k2, _ := s.SSHKeyByFingerprint("SHA256:second")
internal/store/store_test.go +29
@@ -115,3 +115,32 @@ func TestDatabaseFileIsNotWorldReadable(t *testing.T) {
115 t.Errorf("database mode %04o is other-readable", mode) 115 t.Errorf("database mode %04o is other-readable", mode)
116 } 116 }
117} 117}
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 {
22 Algo string 22 Algo string
23 Blob []byte 23 Blob []byte
24 Scope string 24 Scope string
25 Label string // "" when the key was added with no name
25 CreatedAt string 26 CreatedAt string
26 LastUsedAt string // "" when the key has never authenticated 27 LastUsedAt string // "" when the key has never authenticated
27} 28}
@@ -230,15 +231,15 @@ func (s *Store) UserByID(id int64) (User, error) {
230} 231}
231 232
232// AddSSHKey registers a key and bumps the key epoch in one transaction. 233// 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 {
234 tx, err := s.DB.Begin() 235 tx, err := s.DB.Begin()
235 if err != nil { 236 if err != nil {
236 return err 237 return err
237 } 238 }
238 defer tx.Rollback() 239 defer tx.Rollback()
239 if _, err := tx.Exec( 240 if _, err := tx.Exec(
240 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)", 241 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)",
241 userID, fingerprint, algo, blob, scope); err != nil { 242 userID, fingerprint, algo, blob, scope, label); err != nil {
242 if isUniqueErr(err) { 243 if isUniqueErr(err) {
243 return ErrDuplicateKey 244 return ErrDuplicateKey
244 } 245 }
@@ -270,11 +271,24 @@ func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
270 return tx.Commit() 271 return tx.Commit()
271} 272}
272 273
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
273func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { 287func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
274 var k SSHKey 288 var k SSHKey
275 err := s.DB.QueryRow( 289 err := s.DB.QueryRow(
276 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?", 290 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE fingerprint = ?",
277 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope) 291 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
278 if errors.Is(err, sql.ErrNoRows) { 292 if errors.Is(err, sql.ErrNoRows) {
279 return k, ErrNotFound 293 return k, ErrNotFound
280 } 294 }
@@ -283,7 +297,7 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
283 297
284func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { 298func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
285 rows, err := s.DB.Query( 299 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, '')
287 FROM ssh_keys WHERE user_id = ? ORDER BY id`, 301 FROM ssh_keys WHERE user_id = ? ORDER BY id`,
288 userID) 302 userID)
289 if err != nil { 303 if err != nil {
@@ -293,7 +307,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
293 var keys []SSHKey 307 var keys []SSHKey
294 for rows.Next() { 308 for rows.Next() {
295 var k SSHKey 309 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 {
297 return nil, err 311 return nil, err
298 } 312 }
299 keys = append(keys, k) 313 keys = append(keys, k)
@@ -451,8 +465,8 @@ func isUniqueErr(err error) bool {
451func (s *Store) SSHKeyByID(id int64) (SSHKey, error) { 465func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
452 var k SSHKey 466 var k SSHKey
453 err := s.DB.QueryRow( 467 err := s.DB.QueryRow(
454 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE id = ?", 468 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE id = ?",
455 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope) 469 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
456 if errors.Is(err, sql.ErrNoRows) { 470 if errors.Is(err, sql.ErrNoRows) {
457 return k, ErrNotFound 471 return k, ErrNotFound
458 } 472 }
@@ -462,7 +476,7 @@ func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
462// ListDeployKeys returns the deploy keys bound to a repository. 476// ListDeployKeys returns the deploy keys bound to a repository.
463func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) { 477func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
464 rows, err := s.DB.Query( 478 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",
466 repoID) 480 repoID)
467 if err != nil { 481 if err != nil {
468 return nil, err 482 return nil, err
@@ -471,7 +485,7 @@ func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
471 var keys []SSHKey 485 var keys []SSHKey
472 for rows.Next() { 486 for rows.Next() {
473 var k SSHKey 487 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 {
475 return nil, err 489 return nil, err
476 } 490 }
477 keys = append(keys, k) 491 keys = append(keys, k)
internal/web/templates/account.html +4 −1
@@ -25,8 +25,9 @@
25commands and push; a <code>git</code> key can only move git data, which is what 25commands and push; a <code>git</code> key can only move git data, which is what
26a CI checkout wants.</p> 26a CI checkout wants.</p>
27{{if .Keys}}<div class="tablewrap"><table class="keys"> 27{{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>
29{{range .Keys}}<tr> 29{{range .Keys}}<tr>
30 <td>{{.Label}}</td>
30 <td class="mono">{{.Fingerprint}}</td> 31 <td class="mono">{{.Fingerprint}}</td>
31 <td>{{.Algo}}</td> 32 <td>{{.Algo}}</td>
32 <td>{{.Scope}}</td> 33 <td>{{.Scope}}</td>
@@ -40,6 +41,8 @@ a CI checkout wants.</p>
40 <input type="hidden" name="field" value="key-add"> 41 <input type="hidden" name="field" value="key-add">
41 <label for="key">Public key</label> 42 <label for="key">Public key</label>
42 <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@machine"></textarea> 43 <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">
43 <label for="scope">Scope</label> 46 <label for="scope">Scope</label>
44 <select id="scope" name="scope"> 47 <select id="scope" name="scope">
45 <option value="full">full — commands and git</option> 48 <option value="full">full — commands and git</option>