Commit 44e5fb3a83

44e5fb3a83d5abea743fa79cb67dbf53e6b5e529

parent: 7f5c45d0af

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:35 UTC

keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry

Ref #277

Layout: unified · split

e2e/ssh_test.go +2 −2
@@ -265,7 +265,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) {
265 t.Fatalf("keys list exit %d:\n%s", code, out) 265 t.Fatalf("keys list exit %d:\n%s", code, out)
266 } 266 }
267 // The key's comment (ssh-keygen -C) is its label; keys label renames it. 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 t.Fatalf("keys list lacks the comment as label:\n%s", out) 269 t.Fatalf("keys list lacks the comment as label:\n%s", out)
270 } 270 }
271 secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0] 271 secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0]
@@ -273,7 +273,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) {
273 t.Fatalf("keys label exit %d, stderr: %s", code, errOut) 273 t.Fatalf("keys label exit %d, stderr: %s", code, errOut)
274 } 274 }
275 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list") 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 t.Fatalf("keys list after label:\n%s", out) 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 "errors" 4 "errors"
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "time"
7 8
8 "golang.org/x/crypto/ssh" 9 "golang.org/x/crypto/ssh"
9 10
@@ -15,11 +16,12 @@ import (
15func init() { 16func init() {
16 register(Command{Path: []string{"repo", "deploy-key", "add"}, 17 register(Command{Path: []string{"repo", "deploy-key", "add"},
17 Summary: "bind a read-only (or --rw) key to one repository", 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 Flags: []Flag{ 20 Flags: []Flag{
20 {"--rw", "", "the key may push, not just fetch", ""}, 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 ReadsStdin: true, 25 ReadsStdin: true,
24 MintsCredential: true, Run: runDeployKeyAdd}) 26 MintsCredential: true, Run: runDeployKeyAdd})
25 register(Command{Path: []string{"repo", "deploy-key", "list"}, 27 register(Command{Path: []string{"repo", "deploy-key", "list"},
@@ -35,22 +37,22 @@ func init() {
35} 37}
36 38
37func runDeployKeyAdd(c *Ctx, args []string) int { 39func runDeployKeyAdd(c *Ctx, args []string) int {
38 mode := "ro" 40 f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
39 var path string 41 if err != nil {
40 for _, a := range args { 42 return c.fail(protocol.ExitUsage, "%v", err)
41 switch a {
42 case "--rw":
43 mode = "rw"
44 default:
45 if path != "" {
46 return c.usage()
47 }
48 path = a
49 }
50 } 43 }
44 path := f.pos(0)
51 if path == "" { 45 if path == "" {
52 return c.usage() 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 repo, code := resolveRepo(c, path, policy.CanAdmin) 56 repo, code := resolveRepo(c, path, policy.CanAdmin)
55 if code >= 0 { 57 if code >= 0 {
56 return code 58 return code
@@ -69,14 +71,22 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
69 } 71 }
70 fp := ssh.FingerprintSHA256(pub) 72 fp := ssh.FingerprintSHA256(pub)
71 scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) 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 if errors.Is(err, store.ErrDuplicateKey) { 75 if errors.Is(err, store.ErrDuplicateKey) {
74 return c.failErr(err) 76 return c.failErr(err)
75 } 77 }
76 return c.fail(protocol.ExitFailure, "%v", err) 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) { 80 d := map[string]any{"fingerprint": fp, "mode": mode}
79 fmt.Fprintf(w, "deploy key %s (%s) bound to %s\n", fp, mode, repo.Path()) 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 return c.fail(protocol.ExitFailure, "%v", err) 103 return c.fail(protocol.ExitFailure, "%v", err)
94 } 104 }
95 type out struct { 105 type out struct {
96 Fingerprint string `json:"fingerprint"` 106 Fingerprint string `json:"fingerprint"`
97 Algo string `json:"algo"` 107 Algo string `json:"algo"`
98 Mode string `json:"mode"` 108 Mode string `json:"mode"`
99 Label string `json:"label"` 109 Label string `json:"label"`
110 LastUsedAt string `json:"last_used_at,omitempty"`
111 ExpiresAt *time.Time `json:"expires_at,omitempty"`
100 } 112 }
101 var ds []out 113 var ds []out
102 for _, k := range keys { 114 for _, k := range keys {
@@ -104,12 +116,14 @@ func runDeployKeyList(c *Ctx, args []string) int {
104 if policy.DeployScopeAllows(k.Scope, repo.ID, true) { 116 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
105 mode = "rw" 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 return c.emit(ds, func(w io.Writer) { 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 for _, d := range ds { 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 tb.flush() 128 tb.flush()
115 }) 129 })
internal/control/identity.go +57 −19
@@ -5,6 +5,7 @@ import (
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "strings" 7 "strings"
8 "time"
8 "unicode" 9 "unicode"
9 10
10 "golang.org/x/crypto/ssh" 11 "golang.org/x/crypto/ssh"
@@ -33,12 +34,13 @@ func init() {
33 register(Command{ 34 register(Command{
34 Path: []string{"keys", "add"}, 35 Path: []string{"keys", "add"},
35 Summary: "register an SSH public key (authorized_keys format)", 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 Flags: []Flag{ 38 Flags: []Flag{
38 {"--scope", "full|git|runner", "what the key may do", "full"}, 39 {"--scope", "full|git|runner", "what the key may do", "full"},
39 {"--label", "<text>", "a name for the key", ""}, 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 ReadsStdin: true, 44 ReadsStdin: true,
43 MintsCredential: true, 45 MintsCredential: true,
44 Run: runKeysAdd, 46 Run: runKeysAdd,
@@ -83,20 +85,24 @@ func runKeysList(c *Ctx, args []string) int {
83 return c.fail(protocol.ExitFailure, "listing keys: %v", err) 85 return c.fail(protocol.ExitFailure, "listing keys: %v", err)
84 } 86 }
85 type out struct { 87 type out struct {
86 Fingerprint string `json:"fingerprint"` 88 Fingerprint string `json:"fingerprint"`
87 Algo string `json:"algo"` 89 Algo string `json:"algo"`
88 Scope string `json:"scope"` 90 Scope string `json:"scope"`
89 Label string `json:"label"` 91 Label string `json:"label"`
90 CreatedBy string `json:"created_by,omitempty"` 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 var ds []out 96 var ds []out
93 for _, k := range keys { 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 return c.emit(ds, func(w io.Writer) { 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 for _, d := range ds { 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 tb.flush() 107 tb.flush()
102 }) 108 })
@@ -121,8 +127,32 @@ func keyLabel(s string) (string, error) {
121 return s, nil 127 return s, nil
122} 128}
123 129
130// usedText is a key's last use as a list shows it.
131func (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.
143func 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
124func runKeysAdd(c *Ctx, args []string) int { 154func 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 if err != nil { 156 if err != nil {
127 return c.fail(protocol.ExitUsage, "%v", err) 157 return c.fail(protocol.ExitUsage, "%v", err)
128 } 158 }
@@ -134,6 +164,10 @@ func runKeysAdd(c *Ctx, args []string) int {
134 // deploy:* scopes are granted via repo settings, not self-service. 164 // deploy:* scopes are granted via repo settings, not self-service.
135 return c.fail(protocol.ExitUsage, "scope must be full, git or runner") 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 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) 171 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
138 if err != nil { 172 if err != nil {
139 return c.fail(protocol.ExitFailure, "reading key: %v", err) 173 return c.fail(protocol.ExitFailure, "reading key: %v", err)
@@ -151,24 +185,28 @@ func runKeysAdd(c *Ctx, args []string) int {
151 return c.fail(protocol.ExitUsage, "%v", err) 185 return c.fail(protocol.ExitUsage, "%v", err)
152 } 186 }
153 fp := ssh.FingerprintSHA256(pub) 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 if errors.Is(err, store.ErrDuplicateKey) { 189 if errors.Is(err, store.ErrDuplicateKey) {
156 return c.failErr(err) 190 return c.failErr(err)
157 } 191 }
158 return c.fail(protocol.ExitFailure, "adding key: %v", err) 192 return c.fail(protocol.ExitFailure, "adding key: %v", err)
159 } 193 }
160 type out struct { 194 type out struct {
161 Fingerprint string `json:"fingerprint"` 195 Fingerprint string `json:"fingerprint"`
162 Scope string `json:"scope"` 196 Scope string `json:"scope"`
163 Label string `json:"label"` 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 return c.emit(d, func(w io.Writer) { 201 return c.emit(d, func(w io.Writer) {
202 line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
167 if d.Label != "" { 203 if d.Label != "" {
168 fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label) 204 line += " " + d.Label
169 return 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 @@
1package control
2
3import (
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.
18func 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
31func 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 return time.ParseDuration(s) 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.
55func (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
53func runTokenCreate(c *Ctx, args []string) int { 67func runTokenCreate(c *Ctx, args []string) int {
54 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) 68 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
55 if err != nil { 69 if err != nil {