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
16Tokens are minted wherever the registry is reached: over SSH, on the 16Tokens are minted wherever the registry is reached: over SSH, on the
17API, anywhere. =token create= makes a =read= token unless =--scope full= 17API, anywhere. =token create= makes a =read= token unless =--scope full=
18is given; a read token runs only commands marked read-only. A full-scope 18is 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
20command that creates a credential — =token create=, =keys add=, 20command that creates a credential — =token create=, =keys add=,
21=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=, 21=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
22=admin user create=, =email verify=, =admin email verify= — since what 22=admin user create=, =email verify=, =admin email verify= — since what
.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −2
@@ -15,8 +15,8 @@
15 15
16| Credential | Format and generation | Stored as | Scope | Expiry | Revocation | 16| Credential | Format and generation | Stored as | Scope | Expiry | Revocation |
17|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------| 17|--------------------+-------------------------------------------------+----------------------------------+--------------------------------------------+-------------------------------+-------------------------------------|
18| SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | none | =keys remove= (own keys); 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 | none | =repo deploy-key remove= (repo admin); 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 |
20| 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]= | 20| 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]= |
21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= | 21| Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 7 days, no sliding renewal | logout, =web sessions revoke= |
22| 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 | 22| 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.
24| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) | 24| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) |
25| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | 25| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
26| Session lifetime | partial | 7 days absolute, no idle timeout (#276) | 26| 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 |
28| 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=) | 28| 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=) |
29| 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) | 29| 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) |
30 30
.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.
20| #274 | Backups | The local backup archive is not encrypted | medium | 20| #274 | Backups | The local backup archive is not encrypted | medium |
21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | 21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
22| #276 | Sessions | Web sessions last 7 days with no idle timeout | low | 22| #276 | Sessions | Web sessions last 7 days with no idle timeout | low |
23| #277 | Credentials | SSH and deploy keys never expire | low |
24| #278 | Login links | =web login= over SSH skips the login-link rate limit | low | 23| #278 | Login links | =web login= over SSH skips the login-link rate limit | low |
25| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium | 24| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
26| #280 | Mail | STARTTLS only when the relay offers it | medium | 25| #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).
343|-----------------------------+-----+-----+-----| 343|-----------------------------+-----+-----+-----|
344| SSH keys: list, add, remove | yes | yes | yes | 344| SSH keys: list, add, remove | yes | yes | yes |
345| SSH key label | yes | yes | yes | 345| SSH key label | yes | yes | yes |
346| SSH key expiry and last use | yes | no | no |
346| PGP keys: list, add, remove | yes | yes | yes | 347| PGP keys: list, add, remove | yes | yes | yes |
347| email add and verify | yes | yes | yes | 348| email add and verify | yes | yes | yes |
348| email list, remove, primary | yes | yes | yes | 349| 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.
61gitbay auth keys list 61gitbay auth keys list
62gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin 62gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin
63gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub 63gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub
64gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
64gitbay auth keys label SHA256:... "work laptop" 65gitbay auth keys label SHA256:... "work laptop"
65gitbay auth keys remove SHA256:... 66gitbay auth keys remove SHA256:...
66#+end_src 67#+end_src
@@ -69,6 +70,12 @@ A key's label is the comment on its =authorized_keys= line unless
69=--label= gives one; =keys label= renames a key, and with no text 70=--label= gives one; =keys label= renames a key, and with no text
70clears the name. Labels are one line of up to 64 bytes. 71clears the name. Labels are one line of up to 64 bytes.
71 72
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
72Removing a key closes every connection it opened, including the CLI's 79Removing a key closes every connection it opened, including the CLI's
73shared one; removing the key the current command runs on ends that 80shared one; removing the key the current command runs on ends that
74command's connection too. 81command's connection too.
CHANGELOG.org +8 −1
@@ -6,7 +6,7 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9Credentials: revocation and delegation (#256, #257). 9Credentials: revocation, delegation and expiry (#256, #257, #277).
10 10
11*Upgrade note.* =token create= makes a =read= token unless given 11*Upgrade note.* =token create= makes a =read= token unless given
12=--scope full=. A script that mints a token and then writes with it 12=--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.
20 those too (#257). 20 those too (#257).
21- Removing an SSH key, a deploy key, or disabling an account closes the 21- Removing an SSH key, a deploy key, or disabling an account closes the
22 connections the key opened, a push in flight included (#256). 22 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).
23 30
24* v1.36.0 — 2026-09-23 31* v1.36.0 — 2026-09-23
25 32
cmd/gitbayd/system.go +7 −2
@@ -3,6 +3,7 @@ package main
3import ( 3import (
4 "fmt" 4 "fmt"
5 "os" 5 "os"
6 "time"
6 7
7 "github.com/spf13/cobra" 8 "github.com/spf13/cobra"
8 "golang.org/x/crypto/ssh" 9 "golang.org/x/crypto/ssh"
@@ -43,8 +44,8 @@ func authorizedKeysCmd() *cobra.Command {
43 return nil // unparseable key: no output, auth fails 44 return nil // unparseable key: no output, auth fails
44 } 45 }
45 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub)) 46 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
46 if err != nil { 47 if err != nil || key.Expired(time.Now()) {
47 return nil // unknown key: no output, auth fails 48 return nil // unknown or expired key: no output, auth fails
48 } 49 }
49 self, err := os.Executable() 50 self, err := os.Executable()
50 if err != nil { 51 if err != nil {
@@ -82,6 +83,10 @@ func shellCmd() *cobra.Command {
82 fmt.Fprintln(os.Stderr, "key no longer registered") 83 fmt.Fprintln(os.Stderr, "key no longer registered")
83 os.Exit(protocol.ExitDenied) 84 os.Exit(protocol.ExitDenied)
84 } 85 }
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 }
85 user, err := st.UserByID(key.UserID) 90 user, err := st.UserByID(key.UserID)
86 if err != nil { 91 if err != nil {
87 fmt.Fprintln(os.Stderr, "account no longer exists") 92 fmt.Fprintln(os.Stderr, "account no longer exists")
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 += ", expires " + 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 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
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 += ", expires " + 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 +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() {
95 line := make([]string, n) 95 line := make([]string, n)
96 for i := range line { 96 for i := range line {
97 if i < len(t.header) { 97 if i < len(t.header) {
98 line[i] = t.header[i] 98 line[i] = clip(t.header[i], widths[i])
99 } 99 }
100 } 100 }
101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n") 101 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) {
35 } 35 }
36} 36}
37 37
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
38func TestTableColourOnlyAddsSGR(t *testing.T) { 54func TestTableColourOnlyAddsSGR(t *testing.T) {
39 var mono, colour bytes.Buffer 55 var mono, colour bytes.Buffer
40 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono) 56 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
internal/control/token.go +20 −19
@@ -50,26 +50,35 @@ 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 {
56 return c.fail(protocol.ExitUsage, "%v", err) 70 return c.fail(protocol.ExitUsage, "%v", err)
57 } 71 }
58 name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl") 72 name, scope := f.Value("--name"), "read"
59 if f.Has("--scope") { 73 if f.Has("--scope") {
60 scope = f.Value("--scope") 74 scope = f.Value("--scope")
61 } 75 }
62 if name == "" || (scope != "full" && scope != "read") { 76 if name == "" || (scope != "full" && scope != "read") {
63 return c.usage() 77 return c.usage()
64 } 78 }
65 var expires *time.Time 79 expires, code := c.ttlFlag(f)
66 if ttl != "" { 80 if code >= 0 {
67 d, err := parseTTL(ttl) 81 return code
68 if err != nil {
69 return c.failInput(err)
70 }
71 t := time.Now().Add(d)
72 expires = &t
73 } 82 }
74 raw, _, err := store.NewToken() 83 raw, _, err := store.NewToken()
75 if err != nil { 84 if err != nil {
@@ -108,19 +117,11 @@ func runTokenList(c *Ctx, args []string) int {
108 for _, t := range tokens { 117 for _, t := range tokens {
109 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}) 118 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy})
110 } 119 }
120 now := time.Now()
111 return c.emit(ds, func(w io.Writer) { 121 return c.emit(ds, func(w io.Writer) {
112 tb := c.table(w, "NAME", "SCOPE", "EXPIRES") 122 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
113 for _, d := range ds { 123 for _, d := range ds {
114 exp := "never expires" 124 tb.row(cRef(d.Name), cState(d.Scope), cText(expiresText(d.ExpiresAt, now)))
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 } 125 }
125 tb.flush() 126 tb.flush()
126 }) 127 })
internal/control/token_test.go +38
@@ -45,6 +45,30 @@ func TestExpiringCredentialCannotMint(t *testing.T) {
45 } 45 }
46} 46}
47 47
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
48func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) { 72func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
49 st, _, uid := newQueueTestRepo(t) 73 st, _, uid := newQueueTestRepo(t)
50 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil { 74 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
@@ -78,3 +102,17 @@ func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
78 t.Fatal(`no token named "child"`) 102 t.Fatal(`no token named "child"`)
79 } 103 }
80} 104}
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
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "crypto/ed25519"
6 "crypto/rand"
5 "errors" 7 "errors"
6 "strings" 8 "strings"
7 "testing" 9 "testing"
8 "time" 10 "time"
9 11
10 "golang.org/x/crypto/ssh" 12 "golang.org/x/crypto/ssh"
13
14 "gitbay.org/gitbay/internal/store"
11) 15)
12 16
13// execStatus runs cmd on a new session and returns its exit status and 17// execStatus runs cmd on a new session and returns its exit status and
@@ -110,3 +114,65 @@ func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
110 ts.srv.sweepOnce() 114 ts.srv.sweepOnce()
111 waitClosed(t, ts.client) 115 waitClosed(t, ts.client)
112} 116}
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
162 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp}) 162 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
163 return nil, fmt.Errorf("unknown key %s", fp) 163 return nil, fmt.Errorf("unknown key %s", fp)
164 } 164 }
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 }
165 s.authLimiter.success(ip) 170 s.authLimiter.success(ip)
166 return &ssh.Permissions{Extensions: map[string]string{ 171 return &ssh.Permissions{Extensions: map[string]string{
167 "user-id": strconv.FormatInt(key.UserID, 10), 172 "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
407 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable") 412 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
408 return protocol.ExitFailure 413 return protocol.ExitFailure
409 } 414 }
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 }
410 user, err := s.st.UserByID(userID) 419 user, err := s.st.UserByID(userID)
411 if err != nil { 420 if err != nil {
412 fmt.Fprintln(ch.Stderr(), "account no longer exists") 421 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,
484 Stderr: stderr, 493 Stderr: stderr,
485 Done: done, 494 Done: done,
486 Stopping: stopping, 495 Stopping: stopping,
496 Expires: key.ExpiresAt,
487 } 497 }
488 return control.Dispatch(ctx, argv) 498 return control.Dispatch(ctx, argv)
489} 499}
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
3import ( 3import (
4 "slices" 4 "slices"
5 "strings" 5 "strings"
6 "time"
6) 7)
7 8
8// Revoked names SSH keys that stopped being valid: by id, or every key 9// Revoked names SSH keys that stopped being valid: by id, or every key
@@ -32,19 +33,20 @@ func (s *Store) announce(r Revoked) {
32 } 33 }
33} 34}
34 35
35// LiveSSHKeys reports which of ids still name a registered key on an 36// LiveSSHKeys reports which of ids still name a registered, unexpired
36// account that is not disabled. 37// key on an account that is not disabled.
37func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) { 38func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
38 live := map[int64]bool{} 39 live := map[int64]bool{}
39 if len(ids) == 0 { 40 if len(ids) == 0 {
40 return live, nil 41 return live, nil
41 } 42 }
42 args := make([]any, len(ids)) 43 args := []any{fmtTime(time.Now())}
43 for i, id := range ids { 44 for _, id := range ids {
44 args[i] = id 45 args = append(args, id)
45 } 46 }
46 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id 47 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...)
48 if err != nil { 50 if err != nil {
49 return nil, err 51 return nil, err
50 } 52 }
internal/store/users.go +34 −13
@@ -5,6 +5,7 @@ import (
5 "errors" 5 "errors"
6 "fmt" 6 "fmt"
7 "strings" 7 "strings"
8 "time"
8) 9)
9 10
10type User struct { 11type User struct {
@@ -24,8 +25,14 @@ type SSHKey struct {
24 Scope string 25 Scope string
25 Label string // "" when the key was added with no name 26 Label string // "" when the key was added with no name
26 CreatedAt string 27 CreatedAt string
27 LastUsedAt string // "" when the key has never authenticated 28 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only. 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)
29} 36}
30 37
31// ErrDuplicateKey carries the exact user-facing message from the spec. It 38// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -273,7 +280,8 @@ func (s *Store) UserByID(id int64) (User, error) {
273 280
274// KeyOrigin is how a key came to be. 281// KeyOrigin is how a key came to be.
275type KeyOrigin struct { 282type 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
277} 285}
278 286
279// AddSSHKey registers a key and bumps the key epoch in one transaction. 287// 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
288 return err 296 return err
289 } 297 }
290 defer tx.Rollback() 298 defer tx.Rollback()
299 var exp any
300 if o.ExpiresAt != nil {
301 exp = fmtTime(*o.ExpiresAt)
302 }
291 if _, err := tx.Exec( 303 if _, err := tx.Exec(
292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)", 304 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil { 305 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
294 if isUniqueErr(err) { 306 if isUniqueErr(err) {
295 return ErrDuplicateKey 307 return ErrDuplicateKey
296 } 308 }
@@ -343,19 +355,21 @@ func (s *Store) SetSSHKeyLabel(userID int64, fingerprint, label string) error {
343 355
344func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { 356func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
345 var k SSHKey 357 var k SSHKey
358 var exp sql.NullString
346 err := s.DB.QueryRow( 359 err := s.DB.QueryRow(
347 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE fingerprint = ?", 360 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
348 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label) 361 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
349 if errors.Is(err, sql.ErrNoRows) { 362 if errors.Is(err, sql.ErrNoRows) {
350 return k, ErrNotFound 363 return k, ErrNotFound
351 } 364 }
365 k.ExpiresAt = parseTime(exp)
352 return k, err 366 return k, err
353} 367}
354 368
355func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { 369func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
356 rows, err := s.DB.Query( 370 rows, err := s.DB.Query(
357 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at, 371 `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
359 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token 373 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
360 WHERE k.user_id = ? ORDER BY k.id`, 374 WHERE k.user_id = ? ORDER BY k.id`,
361 userID) 375 userID)
@@ -366,9 +380,11 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
366 var keys []SSHKey 380 var keys []SSHKey
367 for rows.Next() { 381 for rows.Next() {
368 var k SSHKey 382 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 {
370 return nil, err 385 return nil, err
371 } 386 }
387 k.ExpiresAt = parseTime(exp)
372 keys = append(keys, k) 388 keys = append(keys, k)
373 } 389 }
374 return keys, rows.Err() 390 return keys, rows.Err()
@@ -523,19 +539,22 @@ func isUniqueErr(err error) bool {
523 539
524func (s *Store) SSHKeyByID(id int64) (SSHKey, error) { 540func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
525 var k SSHKey 541 var k SSHKey
542 var exp sql.NullString
526 err := s.DB.QueryRow( 543 err := s.DB.QueryRow(
527 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE id = ?", 544 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE id = ?",
528 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label) 545 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
529 if errors.Is(err, sql.ErrNoRows) { 546 if errors.Is(err, sql.ErrNoRows) {
530 return k, ErrNotFound 547 return k, ErrNotFound
531 } 548 }
549 k.ExpiresAt = parseTime(exp)
532 return k, err 550 return k, err
533} 551}
534 552
535// ListDeployKeys returns the deploy keys bound to a repository. 553// ListDeployKeys returns the deploy keys bound to a repository.
536func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) { 554func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
537 rows, err := s.DB.Query( 555 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`,
539 repoID) 558 repoID)
540 if err != nil { 559 if err != nil {
541 return nil, err 560 return nil, err
@@ -544,9 +563,11 @@ func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
544 var keys []SSHKey 563 var keys []SSHKey
545 for rows.Next() { 564 for rows.Next() {
546 var k SSHKey 565 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 {
548 return nil, err 568 return nil, err
549 } 569 }
570 k.ExpiresAt = parseTime(exp)
550 keys = append(keys, k) 571 keys = append(keys, k)
551 } 572 }
552 return keys, rows.Err() 573 return keys, rows.Err()