keys: optional expiry for SSH and deploy keys !486

merged merged by cmc on 2026-09-28 21:49 UTC · krz/gitbay:key-expiry into main

23 files changed, +446 −92

Layout: unified · split

.gitbay/wiki/API.org +1 −1
@@ -16,7 +16,7 @@ enabled = true
1616Tokens are minted wherever the registry is reached: over SSH, on the
1717API, anywhere. =token create= makes a =read= token unless =--scope full=
1818is given; a read token runs only commands marked read-only. A full-scope
19token can mint another, but a token with a =--ttl= cannot run any
19token can mint another, but a token or SSH key with a =--ttl= cannot run any
2020command that creates a credential — =token create=, =keys add=,
2121=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
2222=admin user create=, =email verify=, =admin email verify= — since what
.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −2
@@ -15,8 +15,8 @@
1515
1616| Credential | Format and generation | Stored as | Scope | Expiry | Revocation |
1717|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------|
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); closes its connections |
19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | none | =repo deploy-key remove= (repo admin); closes its connections |
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | optional =--ttl=, refused at auth | =keys remove= (own keys); closes its connections |
19| Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | optional =--ttl=, refused at auth | =repo deploy-key remove= (repo admin); closes its connections |
2020| API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= |
2121| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= |
2222| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -24,7 +24,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
2424| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) |
2525| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
2626| Session lifetime | partial | 7 days absolute, no idle timeout (#276) |
27| Credential expiry | partial | API tokens optional; SSH and deploy keys none (#277) |
27| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
2828| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
2929| Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) |
3030
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -20,7 +20,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
2020| #274 | Backups | The local backup archive is not encrypted | medium |
2121| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
2222| #276 | Sessions | Web sessions last 7 days with no idle timeout | low |
23| #277 | Credentials | SSH and deploy keys never expire | low |
2423| #278 | Login links | =web login= over SSH skips the login-link rate limit | low |
2524| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
2625| #280 | Mail | STARTTLS only when the relay offers it | medium |
.gitbay/wiki/Parity.org +1
@@ -343,6 +343,7 @@ client has no use for one (krz/gitbay#57).
343343|-----------------------------+-----+-----+-----|
344344| SSH keys: list, add, remove | yes | yes | yes |
345345| SSH key label | yes | yes | yes |
346| SSH key expiry and last use | yes | no | no |
346347| PGP keys: list, add, remove | yes | yes | yes |
347348| email add and verify | yes | yes | yes |
348349| email list, remove, primary | yes | yes | yes |
.gitbay/wiki/Users.org +7
@@ -61,6 +61,7 @@ username is always =git= — the key alone determines who you are.
6161gitbay auth keys list
6262gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin
6363gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub
64gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
6465gitbay auth keys label SHA256:... "work laptop"
6566gitbay auth keys remove SHA256:...
6667#+end_src
@@ -69,6 +70,12 @@ A key's label is the comment on its =authorized_keys= line unless
6970=--label= gives one; =keys label= renames a key, and with no text
7071clears the name. Labels are one line of up to 64 bytes.
7172
73=--ttl 90d= (or any Go duration, =720h=) makes a key stop
74authenticating after that long; =repo deploy-key add= takes the same
75flag. An expiring key cannot create credentials: tokens, keys, login
76links. =keys list= shows when each key was last used and when it
77expires, so a key nobody uses is easy to spot.
78
7279Removing a key closes every connection it opened, including the CLI's
7380shared one; removing the key the current command runs on ends that
7481command's connection too.
CHANGELOG.org +8 −1
@@ -6,7 +6,7 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9Credentials: revocation and delegation (#256, #257).
9Credentials: revocation, delegation and expiry (#256, #257, #277).
1010
1111*Upgrade note.* =token create= makes a =read= token unless given
1212=--scope full=. A script that mints a token and then writes with it
@@ -20,6 +20,13 @@ must add =--scope full=. Existing tokens keep their scope.
2020 those too (#257).
2121- Removing an SSH key, a deploy key, or disabling an account closes the
2222 connections the key opened, a push in flight included (#256).
23- =keys add= and =repo deploy-key add= take =--ttl=; an expired key is
24 refused at authentication, and an open connection on it closes within
25 15 seconds. An expiring key cannot create credentials, like an
26 expiring token. =keys list= and =repo deploy-key list= gain =USED= and
27 =EXPIRES= columns, after the label (#277).
28- =token list= at a terminal shows a future expiry as a time, not
29 "just now" (#286).
2330
2431* v1.36.0 — 2026-09-23
2532
cmd/gitbayd/system.go +7 −2
@@ -3,6 +3,7 @@ package main
33import (
44 "fmt"
55 "os"
6 "time"
67
78 "github.com/spf13/cobra"
89 "golang.org/x/crypto/ssh"
@@ -43,8 +44,8 @@ func authorizedKeysCmd() *cobra.Command {
4344 return nil // unparseable key: no output, auth fails
4445 }
4546 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
46 if err != nil {
47 return nil // unknown key: no output, auth fails
47 if err != nil || key.Expired(time.Now()) {
48 return nil // unknown or expired key: no output, auth fails
4849 }
4950 self, err := os.Executable()
5051 if err != nil {
@@ -82,6 +83,10 @@ func shellCmd() *cobra.Command {
8283 fmt.Fprintln(os.Stderr, "key no longer registered")
8384 os.Exit(protocol.ExitDenied)
8485 }
86 if key.Expired(time.Now()) {
87 fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one")
88 os.Exit(protocol.ExitDenied)
89 }
8590 user, err := st.UserByID(key.UserID)
8691 if err != nil {
8792 fmt.Fprintln(os.Stderr, "account no longer exists")
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 += ", expires " + 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 USED cell shows it.
131func (c *Ctx) usedText(ts string) string {
132 switch {
133 case ts == "":
134 return "never"
135 case c.Term.Cols == 0:
136 return stamp(ts)
137 }
138 return relAge(ts, termNow())
139}
140
141// expiresText is a credential's expiry as an EXPIRES cell shows it. It
142// is 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"
146 }
147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148 if !t.After(now) {
149 return "expired " + s
150 }
151 return 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 += ", expires " + 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 +78
@@ -0,0 +1,78 @@
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 if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "0h"); code != protocol.ExitUsage {
51 t.Fatalf("zero ttl: exit %d", code)
52 }
53 if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "-1h"); code != protocol.ExitUsage {
54 t.Fatalf("negative ttl: exit %d", code)
55 }
56
57 keys, err := st.ListSSHKeys(uid)
58 if err != nil || len(keys) != 2 {
59 t.Fatalf("keys: %+v %v", keys, err)
60 }
61 for _, k := range keys {
62 if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) {
63 t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
64 }
65 }
66 exp := map[string]string{}
67 for _, k := range keys {
68 exp[k.Label] = k.ExpiresAt.UTC().Format("2006-01-02")
69 }
70 out, _, _ := run("", "keys", "list")
71 if !strings.Contains(out, "\tlaptop\tnever\t"+exp["laptop"]) {
72 t.Fatalf("keys list:\n%s", out)
73 }
74 out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
75 if !strings.Contains(out, "\tci\tnever\t"+exp["ci"]) {
76 t.Fatalf("deploy-key list:\n%s", out)
77 }
78}
internal/control/table.go +1 −1
@@ -95,7 +95,7 @@ func (t *table) flush() {
9595 line := make([]string, n)
9696 for i := range line {
9797 if i < len(t.header) {
98 line[i] = t.header[i]
98 line[i] = clip(t.header[i], widths[i])
9999 }
100100 }
101101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
internal/control/table_test.go +16
@@ -35,6 +35,22 @@ func TestTableTerminalFits(t *testing.T) {
3535 }
3636}
3737
38// A column shrunk below its header's width clips the header too.
39func TestTableClipsHeaderToColumn(t *testing.T) {
40 var b bytes.Buffer
41 tb := (&Ctx{Term: Term{Cols: 16}}).table(&b, "FINGERPRINT", "SCOPE")
42 tb.row(cFlex("SHA256:abcdefghijklmnopqrstuvwxyz"), cState("full"))
43 tb.flush()
44 for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
45 if cells(line) > 16 {
46 t.Errorf("line of %d cells at 16 columns: %q", cells(line), line)
47 }
48 }
49 if !strings.HasPrefix(b.String(), "FINGERPR… SCOPE\n") {
50 t.Errorf("header:\n%s", b.String())
51 }
52}
53
3854func TestTableColourOnlyAddsSGR(t *testing.T) {
3955 var mono, colour bytes.Buffer
4056 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
internal/control/token.go +20 −19
@@ -50,26 +50,35 @@ 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 {
5670 return c.fail(protocol.ExitUsage, "%v", err)
5771 }
58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")
72 name, scope := f.Value("--name"), "read"
5973 if f.Has("--scope") {
6074 scope = f.Value("--scope")
6175 }
6276 if name == "" || (scope != "full" && scope != "read") {
6377 return c.usage()
6478 }
65 var expires *time.Time
66 if ttl != "" {
67 d, err := parseTTL(ttl)
68 if err != nil {
69 return c.failInput(err)
70 }
71 t := time.Now().Add(d)
72 expires = &t
79 expires, code := c.ttlFlag(f)
80 if code >= 0 {
81 return code
7382 }
7483 raw, _, err := store.NewToken()
7584 if err != nil {
@@ -108,19 +117,11 @@ func runTokenList(c *Ctx, args []string) int {
108117 for _, t := range tokens {
109118 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
110119 }
120 now := time.Now()
111121 return c.emit(ds, func(w io.Writer) {
112122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
113123 for _, d := range ds {
114 exp := "never expires"
115 if d.ExpiresAt != nil {
116 ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
117 if c.Term.Cols == 0 {
118 exp = "expires " + stamp(ts)
119 } else {
120 exp = "expires " + relAge(ts, termNow())
121 }
122 }
123 tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
124 tb.row(cRef(d.Name), cState(d.Scope), cText(expiresText(d.ExpiresAt, now)))
124125 }
125126 tb.flush()
126127 })
internal/control/token_test.go +38
@@ -45,6 +45,30 @@ func TestExpiringCredentialCannotMint(t *testing.T) {
4545 }
4646}
4747
48// #286: at a terminal, a token expiring in the future must not render
49// through relAge, which clamps a future time to zero and prints
50// "expires just now".
51func TestTokenListFutureExpiryAtTerminal(t *testing.T) {
52 st, _, uid := newQueueTestRepo(t)
53 exp := time.Now().Add(90 * 24 * time.Hour)
54 if err := st.CreateAPIToken(uid, "laptop", "h-laptop", "read", &exp, 0); err != nil {
55 t.Fatal(err)
56 }
57 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
58 c.Term = Term{Cols: 80}
59 var out bytes.Buffer
60 c.Stdout = &out
61 if code := Dispatch(c, []string{"token", "list"}); code != protocol.ExitOK {
62 t.Fatalf("exit %d: %s", code, errOut)
63 }
64 if strings.Contains(out.String(), "just now") {
65 t.Fatalf("token list at a terminal printed \"just now\" for a future expiry:\n%s", out.String())
66 }
67 if !strings.Contains(out.String(), exp.UTC().Format("2006-01-02")) {
68 t.Fatalf("token list:\n%s", out.String())
69 }
70}
71
4872func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
4973 st, _, uid := newQueueTestRepo(t)
5074 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
@@ -78,3 +102,17 @@ func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
78102 t.Fatal(`no token named "child"`)
79103 }
80104}
105
106func TestTokenCreateRefusesNonPositiveTTL(t *testing.T) {
107 st, _, uid := newQueueTestRepo(t)
108 for _, ttl := range []string{"0s", "-1h"} {
109 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
110 c.Cfg.Limits.WriteRate = -1
111 if code := Dispatch(c, []string{"token", "create", "--name", "x", "--ttl", ttl}); code != protocol.ExitUsage {
112 t.Errorf("--ttl %s: exit %d", ttl, code)
113 }
114 }
115 if toks, err := st.ListAPITokens(uid); err != nil || len(toks) != 0 {
116 t.Fatalf("tokens: %+v %v", toks, err)
117 }
118}
internal/sshd/revoke_test.go +66
@@ -2,12 +2,16 @@ package sshd
22
33import (
44 "bytes"
5 "crypto/ed25519"
6 "crypto/rand"
57 "errors"
68 "strings"
79 "testing"
810 "time"
911
1012 "golang.org/x/crypto/ssh"
13
14 "gitbay.org/gitbay/internal/store"
1115)
1216
1317// execStatus runs cmd on a new session and returns its exit status and
@@ -110,3 +114,65 @@ func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
110114 ts.srv.sweepOnce()
111115 waitClosed(t, ts.client)
112116}
117
118// A key that expires while connected: the next exec is refused, and
119// the sweep closes the connection.
120func TestExpiredKeyRefusedAndCut(t *testing.T) {
121 ts := newTestServer(t)
122 past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
123 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
124 t.Fatal(err)
125 }
126 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
127 t.Fatalf("whoami with an expired key: %d %q", code, errOut)
128 }
129 ts.srv.sweepOnce()
130 waitClosed(t, ts.client)
131}
132
133// An expiring key may not mint.
134func TestExpiringKeyCannotMint(t *testing.T) {
135 ts := newTestServer(t)
136 future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
137 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
138 t.Fatal(err)
139 }
140 if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
141 t.Fatalf("token create with an expiring key: %d %q", code, errOut)
142 }
143}
144
145func TestExpiredKeyRefusedAtAuth(t *testing.T) {
146 ts := newTestServer(t)
147 _, priv, err := ed25519.GenerateKey(rand.Reader)
148 if err != nil {
149 t.Fatal(err)
150 }
151 signer, err := ssh.NewSignerFromKey(priv)
152 if err != nil {
153 t.Fatal(err)
154 }
155 pub := signer.PublicKey()
156 past := time.Now().Add(-time.Minute)
157 if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
158 t.Fatal(err)
159 }
160 _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
161 User: "git",
162 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
163 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
164 Timeout: 5 * time.Second,
165 })
166 if err == nil {
167 t.Fatal("an expired key authenticated")
168 }
169 // Anyone holding only the public key can offer it; each offer
170 // counts against the address like an unknown key.
171 ip := remoteIP(ts.client.LocalAddr())
172 ts.srv.authLimiter.mu.Lock()
173 w := ts.srv.authLimiter.seen[ip]
174 ts.srv.authLimiter.mu.Unlock()
175 if w == nil || w.count < 1 {
176 t.Fatalf("an expired key's attempt from %s did not count against the limiter", ip)
177 }
178}
internal/sshd/sshd.go +10
@@ -162,6 +162,11 @@ func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Pe
162162 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
163163 return nil, fmt.Errorf("unknown key %s", fp)
164164 }
165 if key.Expired(time.Now()) {
166 s.authLimiter.fail(ip)
167 s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
168 return nil, fmt.Errorf("key %s has expired", fp)
169 }
165170 s.authLimiter.success(ip)
166171 return &ssh.Permissions{Extensions: map[string]string{
167172 "user-id": strconv.FormatInt(key.UserID, 10),
@@ -407,6 +412,10 @@ func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term co
407412 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
408413 return protocol.ExitFailure
409414 }
415 if key.Expired(time.Now()) {
416 fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
417 return protocol.ExitDenied
418 }
410419 user, err := s.st.UserByID(userID)
411420 if err != nil {
412421 fmt.Fprintln(ch.Stderr(), "account no longer exists")
@@ -484,6 +493,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey,
484493 Stderr: stderr,
485494 Done: done,
486495 Stopping: stopping,
496 Expires: key.ExpiresAt,
487497 }
488498 return control.Dispatch(ctx, argv)
489499}
internal/store/keyexpiry_test.go added +48
@@ -0,0 +1,48 @@
1package store
2
3import (
4 "testing"
5 "time"
6)
7
8func TestKeyExpiry(t *testing.T) {
9 s, uid, _ := revokeFixture(t)
10 past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
11 for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
12 if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
13 t.Fatal(err)
14 }
15 }
16 now := time.Now()
17 ids := map[string]int64{}
18 for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
19 k, err := s.SSHKeyByFingerprint(fp)
20 if err != nil {
21 t.Fatal(err)
22 }
23 ids[fp] = k.ID
24 byID, err := s.SSHKeyByID(k.ID)
25 if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
26 t.Fatalf("%s by id: %+v %v", fp, byID, err)
27 }
28 if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
29 t.Errorf("%s Expired = %v, want %v", fp, got, want)
30 }
31 }
32 live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
33 if err != nil {
34 t.Fatal(err)
35 }
36 if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
37 t.Fatalf("live = %v", live)
38 }
39 keys, err := s.ListSSHKeys(uid)
40 if err != nil || len(keys) != 3 {
41 t.Fatalf("list: %+v %v", keys, err)
42 }
43 for _, k := range keys {
44 if k.Fingerprint == "SHA256:ever" && k.ExpiresAt != nil {
45 t.Fatalf("list: %s should have nil ExpiresAt: %+v", k.Fingerprint, k)
46 }
47 }
48}
internal/store/migrations/0061_ssh_key_expiry.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE ssh_keys DROP COLUMN expires_at;
internal/store/migrations/0061_ssh_key_expiry.up.sql added +2
@@ -0,0 +1,2 @@
1-- When the key stops authenticating; NULL for never.
2ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
internal/store/revoke.go +8 −6
@@ -3,6 +3,7 @@ package store
33import (
44 "slices"
55 "strings"
6 "time"
67)
78
89// Revoked names SSH keys that stopped being valid: by id, or every key
@@ -32,19 +33,20 @@ func (s *Store) announce(r Revoked) {
3233 }
3334}
3435
35// LiveSSHKeys reports which of ids still name a registered key on an
36// account that is not disabled.
36// LiveSSHKeys reports which of ids still name a registered, unexpired
37// key on an account that is not disabled.
3738func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
3839 live := map[int64]bool{}
3940 if len(ids) == 0 {
4041 return live, nil
4142 }
42 args := make([]any, len(ids))
43 for i, id := range ids {
44 args[i] = id
43 args := []any{fmtTime(time.Now())}
44 for _, id := range ids {
45 args = append(args, id)
4546 }
4647 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
47 WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
48 WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
49 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
4850 if err != nil {
4951 return nil, err
5052 }
internal/store/users.go +34 −13
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "strings"
8 "time"
89)
910
1011type User struct {
@@ -24,8 +25,14 @@ type SSHKey struct {
2425 Scope string
2526 Label string // "" when the key was added with no name
2627 CreatedAt string
27 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
28 LastUsedAt string // "" when the key has never authenticated
29 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
30 ExpiresAt *time.Time // nil when the key never expires
31}
32
33// Expired reports whether the key has lapsed at now.
34func (k SSHKey) Expired(now time.Time) bool {
35 return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
2936}
3037
3138// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -273,7 +280,8 @@ func (s *Store) UserByID(id int64) (User, error) {
273280
274281// KeyOrigin is how a key came to be.
275282type KeyOrigin struct {
276 CreatedByToken int64 // the API token that added it; 0 for none
283 CreatedByToken int64 // the API token that added it; 0 for none
284 ExpiresAt *time.Time // when it stops authenticating; nil for never
277285}
278286
279287// AddSSHKey registers a key and bumps the key epoch in one transaction.
@@ -288,9 +296,13 @@ func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byt
288296 return err
289297 }
290298 defer tx.Rollback()
299 var exp any
300 if o.ExpiresAt != nil {
301 exp = fmtTime(*o.ExpiresAt)
302 }
291303 if _, err := tx.Exec(
292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
304 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
305 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
294306 if isUniqueErr(err) {
295307 return ErrDuplicateKey
296308 }
@@ -343,19 +355,21 @@ func (s *Store) SetSSHKeyLabel(userID int64, fingerprint, label string) error {
343355
344356func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
345357 var k SSHKey
358 var exp sql.NullString
346359 err := s.DB.QueryRow(
347 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE fingerprint = ?",
348 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
360 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
361 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
349362 if errors.Is(err, sql.ErrNoRows) {
350363 return k, ErrNotFound
351364 }
365 k.ExpiresAt = parseTime(exp)
352366 return k, err
353367}
354368
355369func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
356370 rows, err := s.DB.Query(
357371 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
358 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
372 COALESCE(k.last_used_at, ''), COALESCE(t.name, ''), k.expires_at
359373 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
360374 WHERE k.user_id = ? ORDER BY k.id`,
361375 userID)
@@ -366,9 +380,11 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
366380 var keys []SSHKey
367381 for rows.Next() {
368382 var k SSHKey
369 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
383 var exp sql.NullString
384 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy, &exp); err != nil {
370385 return nil, err
371386 }
387 k.ExpiresAt = parseTime(exp)
372388 keys = append(keys, k)
373389 }
374390 return keys, rows.Err()
@@ -523,19 +539,22 @@ func isUniqueErr(err error) bool {
523539
524540func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
525541 var k SSHKey
542 var exp sql.NullString
526543 err := s.DB.QueryRow(
527 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE id = ?",
528 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
544 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE id = ?",
545 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
529546 if errors.Is(err, sql.ErrNoRows) {
530547 return k, ErrNotFound
531548 }
549 k.ExpiresAt = parseTime(exp)
532550 return k, err
533551}
534552
535553// ListDeployKeys returns the deploy keys bound to a repository.
536554func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
537555 rows, err := s.DB.Query(
538 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id",
556 `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
557 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
539558 repoID)
540559 if err != nil {
541560 return nil, err
@@ -544,9 +563,11 @@ func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
544563 var keys []SSHKey
545564 for rows.Next() {
546565 var k SSHKey
547 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label); err != nil {
566 var exp sql.NullString
567 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
548568 return nil, err
549569 }
570 k.ExpiresAt = parseTime(exp)
550571 keys = append(keys, k)
551572 }
552573 return keys, rows.Err()