Commit 44e5fb3a83
Verified · cmc
Layout: unified · split
e2e/ssh_test.go +2 −2
| @@ -265,7 +265,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) { | ||
| 265 | 265 | t.Fatalf("keys list exit %d:\n%s", code, out) |
| 266 | 266 | } |
| 267 | 267 | // The key's comment (ssh-keygen -C) is its label; keys label renames it. |
| 268 | if !strings.Contains(out, "\tgit\talice2\n") { | |
| 268 | if !strings.Contains(out, "\tgit\talice2\t") { | |
| 269 | 269 | t.Fatalf("keys list lacks the comment as label:\n%s", out) |
| 270 | 270 | } |
| 271 | 271 | secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0] |
| @@ -273,7 +273,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) { | ||
| 273 | 273 | t.Fatalf("keys label exit %d, stderr: %s", code, errOut) |
| 274 | 274 | } |
| 275 | 275 | out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list") |
| 276 | if !strings.Contains(out, "\tgit\tbuild box\n") { | |
| 276 | if !strings.Contains(out, "\tgit\tbuild box\t") { | |
| 277 | 277 | t.Fatalf("keys list after label:\n%s", out) |
| 278 | 278 | } |
| 279 | 279 | |
internal/control/deploykey.go +38 −24
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "time" | |
| 7 | 8 | |
| 8 | 9 | "golang.org/x/crypto/ssh" |
| 9 | 10 | |
| @@ -15,11 +16,12 @@ import ( | ||
| 15 | 16 | func init() { |
| 16 | 17 | register(Command{Path: []string{"repo", "deploy-key", "add"}, |
| 17 | 18 | Summary: "bind a read-only (or --rw) key to one repository", |
| 18 | Usage: "repo deploy-key add <owner/name> [--rw] < key.pub", | |
| 19 | Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub", | |
| 19 | 20 | Flags: []Flag{ |
| 20 | 21 | {"--rw", "", "the key may push, not just fetch", ""}, |
| 22 | {"--ttl", "30d|720h", "how long the key authenticates", "never expires"}, | |
| 21 | 23 | }, |
| 22 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub"}, | |
| 24 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, | |
| 23 | 25 | ReadsStdin: true, |
| 24 | 26 | MintsCredential: true, Run: runDeployKeyAdd}) |
| 25 | 27 | register(Command{Path: []string{"repo", "deploy-key", "list"}, |
| @@ -35,22 +37,22 @@ func init() { | ||
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | func runDeployKeyAdd(c *Ctx, args []string) int { |
| 38 | mode := "ro" | |
| 39 | var path string | |
| 40 | for _, a := range args { | |
| 41 | switch a { | |
| 42 | case "--rw": | |
| 43 | mode = "rw" | |
| 44 | default: | |
| 45 | if path != "" { | |
| 46 | return c.usage() | |
| 47 | } | |
| 48 | path = a | |
| 49 | } | |
| 40 | f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 41 | if err != nil { | |
| 42 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 50 | 43 | } |
| 44 | path := f.pos(0) | |
| 51 | 45 | if path == "" { |
| 52 | 46 | return c.usage() |
| 53 | 47 | } |
| 48 | mode := "ro" | |
| 49 | if f.Has("--rw") { | |
| 50 | mode = "rw" | |
| 51 | } | |
| 52 | expires, code := c.ttlFlag(f) | |
| 53 | if code >= 0 { | |
| 54 | return code | |
| 55 | } | |
| 54 | 56 | repo, code := resolveRepo(c, path, policy.CanAdmin) |
| 55 | 57 | if code >= 0 { |
| 56 | 58 | return code |
| @@ -69,14 +71,22 @@ func runDeployKeyAdd(c *Ctx, args []string) int { | ||
| 69 | 71 | } |
| 70 | 72 | fp := ssh.FingerprintSHA256(pub) |
| 71 | 73 | scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) |
| 72 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 74 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { | |
| 73 | 75 | if errors.Is(err, store.ErrDuplicateKey) { |
| 74 | 76 | return c.failErr(err) |
| 75 | 77 | } |
| 76 | 78 | return c.fail(protocol.ExitFailure, "%v", err) |
| 77 | 79 | } |
| 78 | return c.emit(map[string]string{"fingerprint": fp, "mode": mode}, func(w io.Writer) { | |
| 79 | fmt.Fprintf(w, "deploy key %s (%s) bound to %s\n", fp, mode, repo.Path()) | |
| 80 | d := map[string]any{"fingerprint": fp, "mode": mode} | |
| 81 | if expires != nil { | |
| 82 | d["expires_at"] = expires | |
| 83 | } | |
| 84 | return c.emit(d, func(w io.Writer) { | |
| 85 | line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) | |
| 86 | if expires != nil { | |
| 87 | line += ", " + expiresText(expires, time.Now()) | |
| 88 | } | |
| 89 | fmt.Fprintln(w, line) | |
| 80 | 90 | }) |
| 81 | 91 | } |
| 82 | 92 | |
| @@ -93,10 +103,12 @@ func runDeployKeyList(c *Ctx, args []string) int { | ||
| 93 | 103 | return c.fail(protocol.ExitFailure, "%v", err) |
| 94 | 104 | } |
| 95 | 105 | type out struct { |
| 96 | Fingerprint string `json:"fingerprint"` | |
| 97 | Algo string `json:"algo"` | |
| 98 | Mode string `json:"mode"` | |
| 99 | Label string `json:"label"` | |
| 106 | Fingerprint string `json:"fingerprint"` | |
| 107 | Algo string `json:"algo"` | |
| 108 | Mode string `json:"mode"` | |
| 109 | Label string `json:"label"` | |
| 110 | LastUsedAt string `json:"last_used_at,omitempty"` | |
| 111 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 100 | 112 | } |
| 101 | 113 | var ds []out |
| 102 | 114 | for _, k := range keys { |
| @@ -104,12 +116,14 @@ func runDeployKeyList(c *Ctx, args []string) int { | ||
| 104 | 116 | if policy.DeployScopeAllows(k.Scope, repo.ID, true) { |
| 105 | 117 | mode = "rw" |
| 106 | 118 | } |
| 107 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label}) | |
| 119 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt}) | |
| 108 | 120 | } |
| 121 | now := time.Now() | |
| 109 | 122 | return c.emit(ds, func(w io.Writer) { |
| 110 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL") | |
| 123 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") | |
| 111 | 124 | for _, d := range ds { |
| 112 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label)) | |
| 125 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), | |
| 126 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | |
| 113 | 127 | } |
| 114 | 128 | tb.flush() |
| 115 | 129 | }) |
internal/control/identity.go +57 −19
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | 7 | "strings" |
| 8 | "time" | |
| 8 | 9 | "unicode" |
| 9 | 10 | |
| 10 | 11 | "golang.org/x/crypto/ssh" |
| @@ -33,12 +34,13 @@ func init() { | ||
| 33 | 34 | register(Command{ |
| 34 | 35 | Path: []string{"keys", "add"}, |
| 35 | 36 | Summary: "register an SSH public key (authorized_keys format)", |
| 36 | Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub", | |
| 37 | Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub", | |
| 37 | 38 | Flags: []Flag{ |
| 38 | 39 | {"--scope", "full|git|runner", "what the key may do", "full"}, |
| 39 | 40 | {"--label", "<text>", "a name for the key", ""}, |
| 41 | {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"}, | |
| 40 | 42 | }, |
| 41 | Examples: []string{"keys add --label laptop < key.pub"}, | |
| 43 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, | |
| 42 | 44 | ReadsStdin: true, |
| 43 | 45 | MintsCredential: true, |
| 44 | 46 | Run: runKeysAdd, |
| @@ -83,20 +85,24 @@ func runKeysList(c *Ctx, args []string) int { | ||
| 83 | 85 | return c.fail(protocol.ExitFailure, "listing keys: %v", err) |
| 84 | 86 | } |
| 85 | 87 | type out struct { |
| 86 | Fingerprint string `json:"fingerprint"` | |
| 87 | Algo string `json:"algo"` | |
| 88 | Scope string `json:"scope"` | |
| 89 | Label string `json:"label"` | |
| 90 | CreatedBy string `json:"created_by,omitempty"` | |
| 88 | Fingerprint string `json:"fingerprint"` | |
| 89 | Algo string `json:"algo"` | |
| 90 | Scope string `json:"scope"` | |
| 91 | Label string `json:"label"` | |
| 92 | CreatedBy string `json:"created_by,omitempty"` | |
| 93 | LastUsedAt string `json:"last_used_at,omitempty"` | |
| 94 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 91 | 95 | } |
| 92 | 96 | var ds []out |
| 93 | 97 | for _, k := range keys { |
| 94 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy}) | |
| 98 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) | |
| 95 | 99 | } |
| 100 | now := time.Now() | |
| 96 | 101 | return c.emit(ds, func(w io.Writer) { |
| 97 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") | |
| 102 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") | |
| 98 | 103 | for _, d := range ds { |
| 99 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label)) | |
| 104 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), | |
| 105 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | |
| 100 | 106 | } |
| 101 | 107 | tb.flush() |
| 102 | 108 | }) |
| @@ -121,8 +127,32 @@ func keyLabel(s string) (string, error) { | ||
| 121 | 127 | return s, nil |
| 122 | 128 | } |
| 123 | 129 | |
| 130 | // usedText is a key's last use as a list shows it. | |
| 131 | func (c *Ctx) usedText(ts string) string { | |
| 132 | switch { | |
| 133 | case ts == "": | |
| 134 | return "never used" | |
| 135 | case c.Term.Cols == 0: | |
| 136 | return "used " + stamp(ts) | |
| 137 | } | |
| 138 | return "used " + relAge(ts, termNow()) | |
| 139 | } | |
| 140 | ||
| 141 | // expiresText is a credential's expiry as a list shows it. It is | |
| 142 | // absolute at a terminal too: relAge reads only the past. | |
| 143 | func expiresText(t *time.Time, now time.Time) string { | |
| 144 | if t == nil { | |
| 145 | return "never expires" | |
| 146 | } | |
| 147 | s := stamp(t.UTC().Format(time.RFC3339Nano)) | |
| 148 | if !t.After(now) { | |
| 149 | return "expired " + s | |
| 150 | } | |
| 151 | return "expires " + s | |
| 152 | } | |
| 153 | ||
| 124 | 154 | func runKeysAdd(c *Ctx, args []string) int { |
| 125 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"}) | |
| 155 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | |
| 126 | 156 | if err != nil { |
| 127 | 157 | return c.fail(protocol.ExitUsage, "%v", err) |
| 128 | 158 | } |
| @@ -134,6 +164,10 @@ func runKeysAdd(c *Ctx, args []string) int { | ||
| 134 | 164 | // deploy:* scopes are granted via repo settings, not self-service. |
| 135 | 165 | return c.fail(protocol.ExitUsage, "scope must be full, git or runner") |
| 136 | 166 | } |
| 167 | expires, code := c.ttlFlag(f) | |
| 168 | if code >= 0 { | |
| 169 | return code | |
| 170 | } | |
| 137 | 171 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) |
| 138 | 172 | if err != nil { |
| 139 | 173 | return c.fail(protocol.ExitFailure, "reading key: %v", err) |
| @@ -151,24 +185,28 @@ func runKeysAdd(c *Ctx, args []string) int { | ||
| 151 | 185 | return c.fail(protocol.ExitUsage, "%v", err) |
| 152 | 186 | } |
| 153 | 187 | fp := ssh.FingerprintSHA256(pub) |
| 154 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 188 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { | |
| 155 | 189 | if errors.Is(err, store.ErrDuplicateKey) { |
| 156 | 190 | return c.failErr(err) |
| 157 | 191 | } |
| 158 | 192 | return c.fail(protocol.ExitFailure, "adding key: %v", err) |
| 159 | 193 | } |
| 160 | 194 | type out struct { |
| 161 | Fingerprint string `json:"fingerprint"` | |
| 162 | Scope string `json:"scope"` | |
| 163 | Label string `json:"label"` | |
| 195 | Fingerprint string `json:"fingerprint"` | |
| 196 | Scope string `json:"scope"` | |
| 197 | Label string `json:"label"` | |
| 198 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 164 | 199 | } |
| 165 | d := out{fp, scope, label} | |
| 200 | d := out{fp, scope, label, expires} | |
| 166 | 201 | return c.emit(d, func(w io.Writer) { |
| 202 | line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope) | |
| 167 | 203 | if d.Label != "" { |
| 168 | fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label) | |
| 169 | return | |
| 204 | line += " " + d.Label | |
| 205 | } | |
| 206 | if d.ExpiresAt != nil { | |
| 207 | line += ", " + expiresText(d.ExpiresAt, time.Now()) | |
| 170 | 208 | } |
| 171 | fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope) | |
| 209 | fmt.Fprintln(w, line) | |
| 172 | 210 | }) |
| 173 | 211 | } |
| 174 | 212 | |
internal/control/keyexpiry_test.go added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "crypto/ed25519" | |
| 6 | "crypto/rand" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | "time" | |
| 10 | ||
| 11 | "golang.org/x/crypto/ssh" | |
| 12 | ||
| 13 | "gitbay.org/gitbay/internal/protocol" | |
| 14 | "gitbay.org/gitbay/internal/store" | |
| 15 | ) | |
| 16 | ||
| 17 | // authorizedKey is a fresh public key as an authorized_keys line. | |
| 18 | func authorizedKey(t *testing.T, comment string) string { | |
| 19 | t.Helper() | |
| 20 | pub, _, err := ed25519.GenerateKey(rand.Reader) | |
| 21 | if err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | sp, err := ssh.NewPublicKey(pub) | |
| 25 | if err != nil { | |
| 26 | t.Fatal(err) | |
| 27 | } | |
| 28 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n" | |
| 29 | } | |
| 30 | ||
| 31 | func TestKeysAddTTLAndList(t *testing.T) { | |
| 32 | st, repo, uid := newQueueTestRepo(t) | |
| 33 | user := store.User{ID: uid, Username: "alice"} | |
| 34 | run := func(stdin string, argv ...string) (string, string, int) { | |
| 35 | c, errOut := pruneCtx(st, t.TempDir(), user) | |
| 36 | c.Cfg.Limits.WriteRate = -1 | |
| 37 | c.Stdin = strings.NewReader(stdin) | |
| 38 | code := Dispatch(c, argv) | |
| 39 | return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code | |
| 40 | } | |
| 41 | if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK { | |
| 42 | t.Fatalf("keys add --ttl: %d %s", code, errOut) | |
| 43 | } | |
| 44 | if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK { | |
| 45 | t.Fatalf("deploy-key add --ttl: %d %s", code, errOut) | |
| 46 | } | |
| 47 | if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage { | |
| 48 | t.Fatalf("bad ttl: exit %d", code) | |
| 49 | } | |
| 50 | ||
| 51 | keys, err := st.ListSSHKeys(uid) | |
| 52 | if err != nil || len(keys) != 2 { | |
| 53 | t.Fatalf("keys: %+v %v", keys, err) | |
| 54 | } | |
| 55 | for _, k := range keys { | |
| 56 | if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) { | |
| 57 | t.Errorf("%s expires %v", k.Label, k.ExpiresAt) | |
| 58 | } | |
| 59 | } | |
| 60 | out, _, _ := run("", "keys", "list") | |
| 61 | if !strings.Contains(out, "\tlaptop\tnever used\texpires ") { | |
| 62 | t.Fatalf("keys list:\n%s", out) | |
| 63 | } | |
| 64 | out, _, _ = run("", "repo", "deploy-key", "list", repo.Path()) | |
| 65 | if !strings.Contains(out, "\tci\tnever used\texpires ") { | |
| 66 | t.Fatalf("deploy-key list:\n%s", out) | |
| 67 | } | |
| 68 | } | |
internal/control/token.go +14
| @@ -50,6 +50,20 @@ func parseTTL(s string) (time.Duration, error) { | ||
| 50 | 50 | return time.ParseDuration(s) |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | // ttlFlag reads --ttl as an expiry; nil when the flag is absent. The | |
| 54 | // code is -1 when the caller may go on. | |
| 55 | func (c *Ctx) ttlFlag(f flags) (*time.Time, int) { | |
| 56 | if !f.Has("--ttl") { | |
| 57 | return nil, -1 | |
| 58 | } | |
| 59 | d, err := parseTTL(f.Value("--ttl")) | |
| 60 | if err != nil || d <= 0 { | |
| 61 | return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl")) | |
| 62 | } | |
| 63 | t := time.Now().Add(d) | |
| 64 | return &t, -1 | |
| 65 | } | |
| 66 | ||
| 53 | 67 | func runTokenCreate(c *Ctx, args []string) int { |
| 54 | 68 | f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) |
| 55 | 69 | if err != nil { |