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) {
265265 t.Fatalf("keys list exit %d:\n%s", code, out)
266266 }
267267 // 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") {
269269 t.Fatalf("keys list lacks the comment as label:\n%s", out)
270270 }
271271 secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0]
@@ -273,7 +273,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) {
273273 t.Fatalf("keys label exit %d, stderr: %s", code, errOut)
274274 }
275275 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") {
277277 t.Fatalf("keys list after label:\n%s", out)
278278 }
279279
internal/control/deploykey.go +38 −24
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "time"
78
89 "golang.org/x/crypto/ssh"
910
@@ -15,11 +16,12 @@ import (
1516func init() {
1617 register(Command{Path: []string{"repo", "deploy-key", "add"},
1718 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",
1920 Flags: []Flag{
2021 {"--rw", "", "the key may push, not just fetch", ""},
22 {"--ttl", "30d|720h", "how long the key authenticates", "never expires"},
2123 },
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"},
2325 ReadsStdin: true,
2426 MintsCredential: true, Run: runDeployKeyAdd})
2527 register(Command{Path: []string{"repo", "deploy-key", "list"},
@@ -35,22 +37,22 @@ func init() {
3537}
3638
3739func 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)
5043 }
44 path := f.pos(0)
5145 if path == "" {
5246 return c.usage()
5347 }
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 }
5456 repo, code := resolveRepo(c, path, policy.CanAdmin)
5557 if code >= 0 {
5658 return code
@@ -69,14 +71,22 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
6971 }
7072 fp := ssh.FingerprintSHA256(pub)
7173 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 {
7375 if errors.Is(err, store.ErrDuplicateKey) {
7476 return c.failErr(err)
7577 }
7678 return c.fail(protocol.ExitFailure, "%v", err)
7779 }
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)
8090 })
8191}
8292
@@ -93,10 +103,12 @@ func runDeployKeyList(c *Ctx, args []string) int {
93103 return c.fail(protocol.ExitFailure, "%v", err)
94104 }
95105 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"`
100112 }
101113 var ds []out
102114 for _, k := range keys {
@@ -104,12 +116,14 @@ func runDeployKeyList(c *Ctx, args []string) int {
104116 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
105117 mode = "rw"
106118 }
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})
108120 }
121 now := time.Now()
109122 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")
111124 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)))
113127 }
114128 tb.flush()
115129 })
internal/control/identity.go +57 −19
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "strings"
8 "time"
89 "unicode"
910
1011 "golang.org/x/crypto/ssh"
@@ -33,12 +34,13 @@ func init() {
3334 register(Command{
3435 Path: []string{"keys", "add"},
3536 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",
3738 Flags: []Flag{
3839 {"--scope", "full|git|runner", "what the key may do", "full"},
3940 {"--label", "<text>", "a name for the key", ""},
41 {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
4042 },
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"},
4244 ReadsStdin: true,
4345 MintsCredential: true,
4446 Run: runKeysAdd,
@@ -83,20 +85,24 @@ func runKeysList(c *Ctx, args []string) int {
8385 return c.fail(protocol.ExitFailure, "listing keys: %v", err)
8486 }
8587 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"`
9195 }
9296 var ds []out
9397 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})
9599 }
100 now := time.Now()
96101 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")
98103 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)))
100106 }
101107 tb.flush()
102108 })
@@ -121,8 +127,32 @@ func keyLabel(s string) (string, error) {
121127 return s, nil
122128}
123129
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
124154func 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})
126156 if err != nil {
127157 return c.fail(protocol.ExitUsage, "%v", err)
128158 }
@@ -134,6 +164,10 @@ func runKeysAdd(c *Ctx, args []string) int {
134164 // deploy:* scopes are granted via repo settings, not self-service.
135165 return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
136166 }
167 expires, code := c.ttlFlag(f)
168 if code >= 0 {
169 return code
170 }
137171 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
138172 if err != nil {
139173 return c.fail(protocol.ExitFailure, "reading key: %v", err)
@@ -151,24 +185,28 @@ func runKeysAdd(c *Ctx, args []string) int {
151185 return c.fail(protocol.ExitUsage, "%v", err)
152186 }
153187 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 {
155189 if errors.Is(err, store.ErrDuplicateKey) {
156190 return c.failErr(err)
157191 }
158192 return c.fail(protocol.ExitFailure, "adding key: %v", err)
159193 }
160194 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"`
164199 }
165 d := out{fp, scope, label}
200 d := out{fp, scope, label, expires}
166201 return c.emit(d, func(w io.Writer) {
202 line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
167203 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())
170208 }
171 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
209 fmt.Fprintln(w, line)
172210 })
173211}
174212
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) {
5050 return time.ParseDuration(s)
5151}
5252
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
5367func runTokenCreate(c *Ctx, args []string) int {
5468 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
5569 if err != nil {