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