keys: optional expiry for SSH and deploy keys !486
23 files changed, +446 −92
Layout: unified · split
.gitbay/wiki/API.org +1 −1
| @@ -16,7 +16,7 @@ enabled = true | |||
| 16 | Tokens are minted wherever the registry is reached: over SSH, on the | 16 | Tokens are minted wherever the registry is reached: over SSH, on the |
| 17 | API, anywhere. =token create= makes a =read= token unless =--scope full= | 17 | API, anywhere. =token create= makes a =read= token unless =--scope full= |
| 18 | is given; a read token runs only commands marked read-only. A full-scope | 18 | is given; a read token runs only commands marked read-only. A full-scope |
| 19 | token can mint another, but a token with a =--ttl= cannot run any | 19 | token can mint another, but a token or SSH key with a =--ttl= cannot run any |
| 20 | command that creates a credential — =token create=, =keys add=, | 20 | command 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. | |||
| 61 | gitbay auth keys list | 61 | gitbay auth keys list |
| 62 | gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin | 62 | gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin |
| 63 | gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub | 63 | gitbay auth keys add --label laptop < ~/.ssh/id_ed25519.pub |
| 64 | gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub | ||
| 64 | gitbay auth keys label SHA256:... "work laptop" | 65 | gitbay auth keys label SHA256:... "work laptop" |
| 65 | gitbay auth keys remove SHA256:... | 66 | gitbay 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 |
| 70 | clears the name. Labels are one line of up to 64 bytes. | 71 | clears 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 | ||
| 74 | authenticating after that long; =repo deploy-key add= takes the same | ||
| 75 | flag. An expiring key cannot create credentials: tokens, keys, login | ||
| 76 | links. =keys list= shows when each key was last used and when it | ||
| 77 | expires, so a key nobody uses is easy to spot. | ||
| 78 | |||
| 72 | Removing a key closes every connection it opened, including the CLI's | 79 | Removing a key closes every connection it opened, including the CLI's |
| 73 | shared one; removing the key the current command runs on ends that | 80 | shared one; removing the key the current command runs on ends that |
| 74 | command's connection too. | 81 | command'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 | ||
| 9 | Credentials: revocation and delegation (#256, #257). | 9 | Credentials: 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 | |||
| 3 | import ( | 3 | import ( |
| 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 ( | |||
| 15 | func init() { | 16 | func 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 | ||
| 37 | func runDeployKeyAdd(c *Ctx, args []string) int { | 39 | func 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. | ||
| 131 | func (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. | ||
| 143 | func 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 | |||
| 124 | func runKeysAdd(c *Ctx, args []string) int { | 154 | func runKeysAdd(c *Ctx, args []string) int { |
| 125 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"}) | 155 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) |
| 126 | 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "golang.org/x/crypto/ssh" | ||
| 12 | |||
| 13 | "gitbay.org/gitbay/internal/protocol" | ||
| 14 | "gitbay.org/gitbay/internal/store" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // authorizedKey is a fresh public key as an authorized_keys line. | ||
| 18 | func authorizedKey(t *testing.T, comment string) string { | ||
| 19 | t.Helper() | ||
| 20 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 21 | if err != nil { | ||
| 22 | t.Fatal(err) | ||
| 23 | } | ||
| 24 | sp, err := ssh.NewPublicKey(pub) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n" | ||
| 29 | } | ||
| 30 | |||
| 31 | func TestKeysAddTTLAndList(t *testing.T) { | ||
| 32 | st, repo, uid := newQueueTestRepo(t) | ||
| 33 | user := store.User{ID: uid, Username: "alice"} | ||
| 34 | run := func(stdin string, argv ...string) (string, string, int) { | ||
| 35 | c, errOut := pruneCtx(st, t.TempDir(), user) | ||
| 36 | c.Cfg.Limits.WriteRate = -1 | ||
| 37 | c.Stdin = strings.NewReader(stdin) | ||
| 38 | code := Dispatch(c, argv) | ||
| 39 | return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code | ||
| 40 | } | ||
| 41 | if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK { | ||
| 42 | t.Fatalf("keys add --ttl: %d %s", code, errOut) | ||
| 43 | } | ||
| 44 | if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK { | ||
| 45 | t.Fatalf("deploy-key add --ttl: %d %s", code, errOut) | ||
| 46 | } | ||
| 47 | if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage { | ||
| 48 | t.Fatalf("bad ttl: exit %d", code) | ||
| 49 | } | ||
| 50 | 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. | ||
| 39 | func 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 | |||
| 38 | func TestTableColourOnlyAddsSGR(t *testing.T) { | 54 | func 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. | ||
| 55 | func (c *Ctx) ttlFlag(f flags) (*time.Time, int) { | ||
| 56 | if !f.Has("--ttl") { | ||
| 57 | return nil, -1 | ||
| 58 | } | ||
| 59 | d, err := parseTTL(f.Value("--ttl")) | ||
| 60 | if err != nil || d <= 0 { | ||
| 61 | return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl")) | ||
| 62 | } | ||
| 63 | t := time.Now().Add(d) | ||
| 64 | return &t, -1 | ||
| 65 | } | ||
| 66 | |||
| 53 | func runTokenCreate(c *Ctx, args []string) int { | 67 | func 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". | ||
| 51 | func 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 | |||
| 48 | func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) { | 72 | func 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 | |||
| 106 | func 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 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 120 | func 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. | ||
| 134 | func 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 | |||
| 145 | func 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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func 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 @@ | |||
| 1 | ALTER 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. | ||
| 2 | ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT; | ||
internal/store/revoke.go +8 −6
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 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. |
| 37 | func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) { | 38 | func (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 | ||
| 10 | type User struct { | 11 | type 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. | ||
| 34 | func (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. |
| 275 | type KeyOrigin struct { | 282 | type 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 | ||
| 344 | func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { | 356 | func (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 | ||
| 355 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | 369 | func (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 | ||
| 524 | func (s *Store) SSHKeyByID(id int64) (SSHKey, error) { | 540 | func (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. |
| 536 | func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) { | 554 | func (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() |