lfs: a transfer token ends with its key and its access !511
13 files changed, +489 −49
Layout: unified · split
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +6 −3
| @@ -109,9 +109,12 @@ no HTTP write path (=internal/httpd/smart.go=, | |||
| 109 | ** F. LFS | 109 | ** F. LFS |
| 110 | 110 | ||
| 111 | =git-lfs-authenticate= over SSH applies the same repository checks as | 111 | =git-lfs-authenticate= over SSH applies the same repository checks as |
| 112 | git transport and returns a one-hour HMAC token scoped to repository | 112 | git transport and returns a one-hour HMAC token scoped to repository, |
| 113 | and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The | 113 | operation and the SSH key that asked for it, deploy keys included |
| 114 | HTTP batch, upload and download endpoints verify that token; public | 114 | (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The HTTP batch, upload |
| 115 | and download endpoints verify that token and that its key is still | ||
| 116 | registered, unexpired and on an enabled account (=store.LiveSSHKeys=), | ||
| 117 | and repeat the repository check for the key on each request; public | ||
| 115 | repositories allow anonymous download. Objects are verified against | 118 | repositories allow anonymous download. Objects are verified against |
| 116 | their SHA-256 id on upload. | 119 | their SHA-256 id on upload. |
| 117 | 120 | ||
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1 −1
| @@ -22,7 +22,7 @@ | |||
| 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 | |
| 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | | 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | |
| 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | | 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | |
| 25 | | LFS transfer token | HMAC-SHA256 over repo, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | expiry only | | 25 | | LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs | |
| 26 | 26 | ||
| 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, | 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, |
| 28 | =HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=, | 28 | =HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=, |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -25,7 +25,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 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 | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | | 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | |
| 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | | 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=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.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 | ||
| 31 | ** Access control (V4) | 31 | ** Access control (V4) |
.gitbay/wiki/Threat-Model.org +3 −1
| @@ -45,7 +45,9 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite | |||
| 45 | re-reads its key. Removing a key, removing a deploy key, disabling or | 45 | re-reads its key. Removing a key, removing a deploy key, disabling or |
| 46 | deleting an account closes the connections the affected keys opened: | 46 | deleting an account closes the connections the affected keys opened: |
| 47 | a git transport is killed with its children, and a push killed before | 47 | a git transport is killed with its children, and a push killed before |
| 48 | its pre-receive hook answers moves no ref. A control command already | 48 | its pre-receive hook answers moves no ref. |
| 49 | An LFS transfer token names the key that obtained it and is refused | ||
| 50 | from the moment that key is. A control command already | ||
| 49 | inside its database write finishes it; its output is lost. A | 51 | inside its database write finishes it; its output is lost. A |
| 50 | revocation made by =gitbayd admin= on the host, another process, is | 52 | revocation made by =gitbayd admin= on the host, another process, is |
| 51 | found within 15 seconds. In =ssh.mode = "system"= each exec is its | 53 | found within 15 seconds. In =ssh.mode = "system"= each exec is its |
CHANGELOG.org +13
| @@ -265,6 +265,19 @@ missing, =gitbayd admin backup --verify <archive>= names it, and | |||
| 265 | - Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take | 265 | - Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take |
| 266 | a pack slot, answer 503 with =Retry-After: 30= when none is free, and | 266 | a pack slot, answer 503 with =Retry-After: 30= when none is free, and |
| 267 | are killed when the client leaves or stops reading (#262). | 267 | are killed when the client leaves or stops reading (#262). |
| 268 | - LFS transfer tokens name the SSH key that obtained them, deploy keys | ||
| 269 | included, and every LFS request checks that the key is still | ||
| 270 | registered, unexpired and on an enabled account: removing a key or | ||
| 271 | disabling an account ends its tokens at once instead of within the | ||
| 272 | hour (#285). *Operators:* tokens minted before the upgrade are | ||
| 273 | refused. git-lfs asks =git-lfs-authenticate= for a token each time | ||
| 274 | it runs, so only a transfer running across the restart fails, with | ||
| 275 | "repository not found"; running the command again fixes it. | ||
| 276 | - Every LFS request also repeats the repository check for the token's | ||
| 277 | key: a collaborator whose access is revoked or reduced, or a reader | ||
| 278 | of a public repository made private, loses the token's use with the | ||
| 279 | access, and an upload token stops working once its repository is | ||
| 280 | archived (#285). | ||
| 268 | 281 | ||
| 269 | * v1.36.0 — 2026-09-23 | 282 | * v1.36.0 — 2026-09-23 |
| 270 | 283 | ||
e2e/lfs_test.go +54
| @@ -170,3 +170,57 @@ func TestLFS(t *testing.T) { | |||
| 170 | t.Fatal("corrupt object was stored") | 170 | t.Fatal("corrupt object was stored") |
| 171 | } | 171 | } |
| 172 | } | 172 | } |
| 173 | |||
| 174 | // A transfer token works only while the key that obtained it does: | ||
| 175 | // removing the key ends it before its hour is up (#285). No git-lfs | ||
| 176 | // client needed: the token comes from git-lfs-authenticate over SSH. | ||
| 177 | func TestLFSTokenEndsWithItsKey(t *testing.T) { | ||
| 178 | t.Parallel() | ||
| 179 | inst := startInstance(t) | ||
| 180 | aliceKey := inst.newKey(t, "alice") | ||
| 181 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 182 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 { | ||
| 183 | t.Fatalf("repo create: %s", errOut) | ||
| 184 | } | ||
| 185 | spare := inst.newKey(t, "spare") | ||
| 186 | pub, err := os.ReadFile(spare + ".pub") | ||
| 187 | if err != nil { | ||
| 188 | t.Fatal(err) | ||
| 189 | } | ||
| 190 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 { | ||
| 191 | t.Fatalf("keys add: %s", errOut) | ||
| 192 | } | ||
| 193 | out, errOut, code := inst.ssh(t, spare, "", "git-lfs-authenticate", "alice/vault", "download") | ||
| 194 | if code != 0 { | ||
| 195 | t.Fatalf("authenticate: %s", errOut) | ||
| 196 | } | ||
| 197 | var grant struct { | ||
| 198 | Header map[string]string `json:"header"` | ||
| 199 | } | ||
| 200 | if err := json.Unmarshal([]byte(out), &grant); err != nil { | ||
| 201 | t.Fatalf("authenticate JSON: %v\n%s", err, out) | ||
| 202 | } | ||
| 203 | batch := func() int { | ||
| 204 | body := fmt.Sprintf(`{"operation":"download","transfers":["basic"],"objects":[{"oid":%q,"size":4}]}`, strings.Repeat("ab", 32)) | ||
| 205 | req, _ := http.NewRequest("POST", | ||
| 206 | fmt.Sprintf("http://127.0.0.1:%d/alice/vault.git/info/lfs/objects/batch", inst.httpPort), | ||
| 207 | strings.NewReader(body)) | ||
| 208 | req.Header.Set("Content-Type", "application/vnd.git-lfs+json") | ||
| 209 | req.Header.Set("Authorization", grant.Header["Authorization"]) | ||
| 210 | resp, err := http.DefaultClient.Do(req) | ||
| 211 | if err != nil { | ||
| 212 | t.Fatal(err) | ||
| 213 | } | ||
| 214 | resp.Body.Close() | ||
| 215 | return resp.StatusCode | ||
| 216 | } | ||
| 217 | if code := batch(); code != 200 { | ||
| 218 | t.Fatalf("batch with a live key: %d", code) | ||
| 219 | } | ||
| 220 | if _, errOut, code := inst.ssh(t, aliceKey, "", "keys", "remove", fingerprint(t, spare+".pub")); code != 0 { | ||
| 221 | t.Fatalf("keys remove: %s", errOut) | ||
| 222 | } | ||
| 223 | if code := batch(); code != 404 { | ||
| 224 | t.Fatalf("batch after the key was removed: %d, want 404", code) | ||
| 225 | } | ||
| 226 | } | ||
e2e/ssh_test.go +9 −5
| @@ -29,12 +29,16 @@ type instance struct { | |||
| 29 | stderr *tailBuffer // the end of the first serve's stderr | 29 | stderr *tailBuffer // the end of the first serve's stderr |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | // nextPort hands out candidate ports. Seeded randomly so two test processes | 32 | // nextPort hands out candidate ports below 32768, where Linux and macOS |
| 33 | // on one machine — `go test ./...` runs packages concurrently — start in | 33 | // start the ports they give outgoing connections: a git or SMTP client in |
| 34 | // different places. | 34 | // another test cannot take one between the bind test and gitbayd's bind. |
| 35 | // Seeded randomly so two test processes on one machine — `go test ./...` | ||
| 36 | // runs packages concurrently — start in different places. | ||
| 37 | const lastPort = 32000 | ||
| 38 | |||
| 35 | var nextPort = func() *atomic.Int32 { | 39 | var nextPort = func() *atomic.Int32 { |
| 36 | var n atomic.Int32 | 40 | var n atomic.Int32 |
| 37 | n.Store(int32(20000 + rand.IntN(20000))) | 41 | n.Store(int32(10000 + rand.IntN(10000))) |
| 38 | return &n | 42 | return &n |
| 39 | }() | 43 | }() |
| 40 | 44 | ||
| @@ -58,7 +62,7 @@ func freePorts(t *testing.T, n int) []int { | |||
| 58 | ports := make([]int, 0, n) | 62 | ports := make([]int, 0, n) |
| 59 | for len(ports) < n { | 63 | for len(ports) < n { |
| 60 | p := int(nextPort.Add(1)) | 64 | p := int(nextPort.Add(1)) |
| 61 | if p > 60000 { | 65 | if p > lastPort { |
| 62 | t.Fatal("ran out of ports") | 66 | t.Fatal("ran out of ports") |
| 63 | } | 67 | } |
| 64 | ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) | 68 | ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) |
internal/httpd/lfs.go +66 −14
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | "time" | 9 | "time" |
| 10 | 10 | ||
| 11 | "gitbay.org/gitbay/internal/lfs" | 11 | "gitbay.org/gitbay/internal/lfs" |
| 12 | "gitbay.org/gitbay/internal/policy" | ||
| 12 | "gitbay.org/gitbay/internal/store" | 13 | "gitbay.org/gitbay/internal/store" |
| 13 | ) | 14 | ) |
| 14 | 15 | ||
| @@ -36,25 +37,68 @@ func (s *Server) lfsSecret() ([]byte, error) { | |||
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | // lfsAuth resolves what the request may do to the repo: "upload", | 39 | // lfsAuth resolves what the request may do to the repo: "upload", |
| 39 | // "download", or "" for no access. Tokens are repo-scoped; without one, | 40 | // "download", or "" for no access, and the key the grant rests on (0 |
| 40 | // public repos allow anonymous download only. | 41 | // for none). A token is bound to the SSH key that obtained it and |
| 41 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string { | 42 | // works only while that key is registered, unexpired and on an enabled |
| 43 | // account, and while the key still has the access its operation needs | ||
| 44 | // on the repo (#285). Without one, public repos allow anonymous | ||
| 45 | // download only. | ||
| 46 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | ||
| 42 | auth := r.Header.Get("Authorization") | 47 | auth := r.Header.Get("Authorization") |
| 43 | if tok, ok := strings.CutPrefix(auth, "Bearer "); ok { | 48 | if tok, ok := strings.CutPrefix(auth, "Bearer "); ok { |
| 44 | secret, err := s.lfsSecret() | 49 | secret, err := s.lfsSecret() |
| 45 | if err != nil { | 50 | if err != nil { |
| 46 | return "" | 51 | return "", 0 |
| 47 | } | 52 | } |
| 48 | repoID, op, ok := lfs.Verify(secret, tok, time.Now()) | 53 | g, ok := lfs.Verify(secret, tok, time.Now()) |
| 49 | if !ok || repoID != repo.ID { | 54 | if !ok || g.RepoID != repo.ID { |
| 50 | return "" | 55 | return "", 0 |
| 51 | } | 56 | } |
| 52 | return op | 57 | if g.KeyID == 0 { |
| 58 | // Minted by an anonymous batch: worth what anonymous is. | ||
| 59 | if g.Op == "download" && repo.Visibility == "public" { | ||
| 60 | return "download", 0 | ||
| 61 | } | ||
| 62 | return "", 0 | ||
| 63 | } | ||
| 64 | live, err := s.st.LiveSSHKeys([]int64{g.KeyID}) | ||
| 65 | if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") { | ||
| 66 | return "", 0 | ||
| 67 | } | ||
| 68 | return g.Op, g.KeyID | ||
| 53 | } | 69 | } |
| 54 | if repo.Visibility == "public" { | 70 | if repo.Visibility == "public" { |
| 55 | return "download" | 71 | return "download", 0 |
| 72 | } | ||
| 73 | return "", 0 | ||
| 74 | } | ||
| 75 | |||
| 76 | // lfsKeyAllows repeats git-lfs-authenticate's access check for the key | ||
| 77 | // now: a deploy key by its binding, any other key by its account's | ||
| 78 | // access narrowed by the key's scope. An archived repo takes no uploads. | ||
| 79 | func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool { | ||
| 80 | if write && repo.Settings.Archived { | ||
| 81 | return false | ||
| 82 | } | ||
| 83 | key, err := s.st.SSHKeyByID(keyID) | ||
| 84 | if err != nil { | ||
| 85 | return false | ||
| 86 | } | ||
| 87 | if policy.IsDeployScope(key.Scope) { | ||
| 88 | return policy.DeployScopeAllows(key.Scope, repo.ID, write) | ||
| 89 | } | ||
| 90 | user, err := s.st.UserByID(key.UserID) | ||
| 91 | if err != nil { | ||
| 92 | return false | ||
| 56 | } | 93 | } |
| 57 | return "" | 94 | grant, err := s.st.AccessRole(repo.ID, user.ID) |
| 95 | if err != nil { | ||
| 96 | return false | ||
| 97 | } | ||
| 98 | if !policy.CanRead(user, repo, grant) || !policy.ScopeAllowsGit(key.Scope, repo.Path(), write) { | ||
| 99 | return false | ||
| 100 | } | ||
| 101 | return !write || policy.CanWrite(user, repo, grant) | ||
| 58 | } | 102 | } |
| 59 | 103 | ||
| 60 | func lfsError(w http.ResponseWriter, code int, msg string) { | 104 | func lfsError(w http.ResponseWriter, code int, msg string) { |
| @@ -96,7 +140,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) { | |||
| 96 | lfsError(w, http.StatusNotFound, "repository not found") | 140 | lfsError(w, http.StatusNotFound, "repository not found") |
| 97 | return | 141 | return |
| 98 | } | 142 | } |
| 99 | granted := s.lfsAuth(r, repo) | 143 | granted, keyID := s.lfsAuth(r, repo) |
| 100 | if granted == "" { | 144 | if granted == "" { |
| 101 | // Not naming whether the repo exists, per the enumeration rule. | 145 | // Not naming whether the repo exists, per the enumeration rule. |
| 102 | lfsError(w, http.StatusNotFound, "repository not found") | 146 | lfsError(w, http.StatusNotFound, "repository not found") |
| @@ -127,7 +171,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) { | |||
| 127 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") | 171 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") |
| 128 | return | 172 | return |
| 129 | } | 173 | } |
| 130 | transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now()) | 174 | transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now()) |
| 131 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", | 175 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", |
| 132 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) | 176 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) |
| 133 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} | 177 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} |
| @@ -179,7 +223,11 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) { | |||
| 179 | // lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}. | 223 | // lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}. |
| 180 | func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { | 224 | func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { |
| 181 | repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) | 225 | repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) |
| 182 | if err != nil || s.lfsAuth(r, repo) == "" { | 226 | if err != nil { |
| 227 | lfsError(w, http.StatusNotFound, "not found") | ||
| 228 | return | ||
| 229 | } | ||
| 230 | if op, _ := s.lfsAuth(r, repo); op == "" { | ||
| 183 | lfsError(w, http.StatusNotFound, "not found") | 231 | lfsError(w, http.StatusNotFound, "not found") |
| 184 | return | 232 | return |
| 185 | } | 233 | } |
| @@ -198,7 +246,11 @@ func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { | |||
| 198 | // lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}. | 246 | // lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}. |
| 199 | func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) { | 247 | func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) { |
| 200 | repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) | 248 | repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) |
| 201 | if err != nil || s.lfsAuth(r, repo) != "upload" { | 249 | if err != nil { |
| 250 | lfsError(w, http.StatusNotFound, "not found") | ||
| 251 | return | ||
| 252 | } | ||
| 253 | if op, _ := s.lfsAuth(r, repo); op != "upload" { | ||
| 202 | lfsError(w, http.StatusNotFound, "not found") | 254 | lfsError(w, http.StatusNotFound, "not found") |
| 203 | return | 255 | return |
| 204 | } | 256 | } |
internal/httpd/lfsauth_test.go added +254
| @@ -0,0 +1,254 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "net/http/httptest" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/lfs" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func lfsRequest(tok string) *http.Request { | ||
| 15 | r := httptest.NewRequest("GET", "/alice/app.git/info/lfs/objects/x", nil) | ||
| 16 | if tok != "" { | ||
| 17 | r.Header.Set("Authorization", "Bearer "+tok) | ||
| 18 | } | ||
| 19 | return r | ||
| 20 | } | ||
| 21 | |||
| 22 | func lfsTestRepo(t *testing.T, st *store.Store, uid int64, name, visibility string) store.Repo { | ||
| 23 | t.Helper() | ||
| 24 | id, err := st.CreateRepo("user", uid, name, visibility) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | repo, err := st.RepoByID(id) | ||
| 29 | if err != nil { | ||
| 30 | t.Fatal(err) | ||
| 31 | } | ||
| 32 | return repo | ||
| 33 | } | ||
| 34 | |||
| 35 | // A token works only while its key does: removed, expired or on a | ||
| 36 | // disabled account, the key takes its tokens with it (#285). | ||
| 37 | func TestLFSTokenNeedsALiveKey(t *testing.T) { | ||
| 38 | s, st, u := newTokenTestServer(t) | ||
| 39 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | ||
| 40 | secret, err := s.lfsSecret() | ||
| 41 | if err != nil { | ||
| 42 | t.Fatal(err) | ||
| 43 | } | ||
| 44 | addKey := func(fp string, exp *time.Time) int64 { | ||
| 45 | t.Helper() | ||
| 46 | if err := st.AddSSHKeyFrom(u.ID, fp, "ssh-ed25519", []byte(fp), "full", "", store.KeyOrigin{ExpiresAt: exp}); err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | k, err := st.SSHKeyByFingerprint(fp) | ||
| 50 | if err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | return k.ID | ||
| 54 | } | ||
| 55 | |||
| 56 | live := addKey("SHA256:live", nil) | ||
| 57 | tok := lfs.Sign(secret, repo.ID, live, "upload", time.Now()) | ||
| 58 | if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live { | ||
| 59 | t.Fatalf("live key: %q, %d", op, key) | ||
| 60 | } | ||
| 61 | |||
| 62 | past := time.Now().Add(-time.Minute) | ||
| 63 | expired := addKey("SHA256:expired", &past) | ||
| 64 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "upload", time.Now())), repo); op != "" { | ||
| 65 | t.Errorf("expired key: %q", op) | ||
| 66 | } | ||
| 67 | |||
| 68 | if err := st.RemoveSSHKey(u.ID, "SHA256:live"); err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | if op, _ := s.lfsAuth(lfsRequest(tok), repo); op != "" { | ||
| 72 | t.Errorf("removed key: %q", op) | ||
| 73 | } | ||
| 74 | |||
| 75 | other := addKey("SHA256:other", nil) | ||
| 76 | otherTok := lfs.Sign(secret, repo.ID, other, "download", time.Now()) | ||
| 77 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" { | ||
| 78 | t.Fatalf("second key before disable: %q", op) | ||
| 79 | } | ||
| 80 | if err := st.SetUserDisabled(u.ID, true); err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "" { | ||
| 84 | t.Errorf("disabled account: %q", op) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | // A token with no key comes from an anonymous batch on a public | ||
| 89 | // repository and is worth exactly what anonymous is: a download, while | ||
| 90 | // the repository is public. | ||
| 91 | func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) { | ||
| 92 | s, st, u := newTokenTestServer(t) | ||
| 93 | pub := lfsTestRepo(t, st, u.ID, "big", "public") | ||
| 94 | priv := lfsTestRepo(t, st, u.ID, "vault", "private") | ||
| 95 | secret, err := s.lfsSecret() | ||
| 96 | if err != nil { | ||
| 97 | t.Fatal(err) | ||
| 98 | } | ||
| 99 | now := time.Now() | ||
| 100 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "download", now)), pub); op != "download" { | ||
| 101 | t.Errorf("public download: %q", op) | ||
| 102 | } | ||
| 103 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "upload", now)), pub); op != "" { | ||
| 104 | t.Errorf("anonymous upload: %q", op) | ||
| 105 | } | ||
| 106 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "download", now)), priv); op != "" { | ||
| 107 | t.Errorf("private download: %q", op) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | func lfsTestKey(t *testing.T, st *store.Store, uid int64, fp, scope string) int64 { | ||
| 112 | t.Helper() | ||
| 113 | if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), scope, ""); err != nil { | ||
| 114 | t.Fatal(err) | ||
| 115 | } | ||
| 116 | k, err := st.SSHKeyByFingerprint(fp) | ||
| 117 | if err != nil { | ||
| 118 | t.Fatal(err) | ||
| 119 | } | ||
| 120 | return k.ID | ||
| 121 | } | ||
| 122 | |||
| 123 | // A token carries only the access its key's account still has: a | ||
| 124 | // collaborator removed from the repository, or a reader of a public | ||
| 125 | // repository made private, loses the token with the access (#285). | ||
| 126 | func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | ||
| 127 | s, st, u := newTokenTestServer(t) | ||
| 128 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | ||
| 129 | secret, err := s.lfsSecret() | ||
| 130 | if err != nil { | ||
| 131 | t.Fatal(err) | ||
| 132 | } | ||
| 133 | now := time.Now() | ||
| 134 | |||
| 135 | bob, err := st.CreateUser("bob", false) | ||
| 136 | if err != nil { | ||
| 137 | t.Fatal(err) | ||
| 138 | } | ||
| 139 | if err := st.GrantAccess(repo.ID, bob, "write"); err != nil { | ||
| 140 | t.Fatal(err) | ||
| 141 | } | ||
| 142 | bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full") | ||
| 143 | up := lfs.Sign(secret, repo.ID, bobKey, "upload", now) | ||
| 144 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | ||
| 145 | t.Fatalf("collaborator upload: %q", op) | ||
| 146 | } | ||
| 147 | if err := st.GrantAccess(repo.ID, bob, "read"); err != nil { | ||
| 148 | t.Fatal(err) | ||
| 149 | } | ||
| 150 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { | ||
| 151 | t.Errorf("upload after write was taken away: %q", op) | ||
| 152 | } | ||
| 153 | if err := st.RevokeAccess(repo.ID, bob); err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "download", now)), repo); op != "" { | ||
| 157 | t.Errorf("download after access was revoked: %q", op) | ||
| 158 | } | ||
| 159 | |||
| 160 | pub := lfsTestRepo(t, st, u.ID, "big", "public") | ||
| 161 | carol, err := st.CreateUser("carol", false) | ||
| 162 | if err != nil { | ||
| 163 | t.Fatal(err) | ||
| 164 | } | ||
| 165 | carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full") | ||
| 166 | down := lfs.Sign(secret, pub.ID, carolKey, "download", now) | ||
| 167 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" { | ||
| 168 | t.Fatalf("public download: %q", op) | ||
| 169 | } | ||
| 170 | if err := st.SetRepoVisibility(pub.ID, "private"); err != nil { | ||
| 171 | t.Fatal(err) | ||
| 172 | } | ||
| 173 | pub, err = st.RepoByID(pub.ID) | ||
| 174 | if err != nil { | ||
| 175 | t.Fatal(err) | ||
| 176 | } | ||
| 177 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "" { | ||
| 178 | t.Errorf("download after the repository went private: %q", op) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | // A deploy key's token lasts as long as the deploy key: removed from the | ||
| 183 | // repository or on a disabled account it is refused, and a read-only | ||
| 184 | // binding never uploads. | ||
| 185 | func TestLFSDeployKeyToken(t *testing.T) { | ||
| 186 | s, st, u := newTokenTestServer(t) | ||
| 187 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | ||
| 188 | secret, err := s.lfsSecret() | ||
| 189 | if err != nil { | ||
| 190 | t.Fatal(err) | ||
| 191 | } | ||
| 192 | now := time.Now() | ||
| 193 | rw := fmt.Sprintf("deploy:%d:rw", repo.ID) | ||
| 194 | ro := fmt.Sprintf("deploy:%d:ro", repo.ID) | ||
| 195 | |||
| 196 | live := lfsTestKey(t, st, u.ID, "SHA256:deploy-live", rw) | ||
| 197 | if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "upload", now)), repo); op != "upload" || key != live { | ||
| 198 | t.Fatalf("live deploy key: %q, %d", op, key) | ||
| 199 | } | ||
| 200 | |||
| 201 | removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw) | ||
| 202 | tok := lfs.Sign(secret, repo.ID, removed, "download", now) | ||
| 203 | if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | if op, _ := s.lfsAuth(lfsRequest(tok), repo); op != "" { | ||
| 207 | t.Errorf("removed deploy key: %q", op) | ||
| 208 | } | ||
| 209 | |||
| 210 | readOnly := lfsTestKey(t, st, u.ID, "SHA256:deploy-ro", ro) | ||
| 211 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "download", now)), repo); op != "download" { | ||
| 212 | t.Errorf("read-only deploy key download: %q", op) | ||
| 213 | } | ||
| 214 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "upload", now)), repo); op != "" { | ||
| 215 | t.Errorf("read-only deploy key upload: %q", op) | ||
| 216 | } | ||
| 217 | |||
| 218 | if err := st.SetUserDisabled(u.ID, true); err != nil { | ||
| 219 | t.Fatal(err) | ||
| 220 | } | ||
| 221 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "download", now)), repo); op != "" { | ||
| 222 | t.Errorf("deploy key of a disabled account: %q", op) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // An upload token minted before the repository was archived uploads | ||
| 227 | // nothing after it, as git-lfs-authenticate would refuse to mint one. | ||
| 228 | func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | ||
| 229 | s, st, u := newTokenTestServer(t) | ||
| 230 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | ||
| 231 | secret, err := s.lfsSecret() | ||
| 232 | if err != nil { | ||
| 233 | t.Fatal(err) | ||
| 234 | } | ||
| 235 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") | ||
| 236 | now := time.Now() | ||
| 237 | up := lfs.Sign(secret, repo.ID, key, "upload", now) | ||
| 238 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | ||
| 239 | t.Fatalf("upload before archiving: %q", op) | ||
| 240 | } | ||
| 241 | if _, err := st.UpdateRepoSettings(repo.ID, func(rs *store.RepoSettings) { rs.Archived = true }); err != nil { | ||
| 242 | t.Fatal(err) | ||
| 243 | } | ||
| 244 | repo, err = st.RepoByID(repo.ID) | ||
| 245 | if err != nil { | ||
| 246 | t.Fatal(err) | ||
| 247 | } | ||
| 248 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { | ||
| 249 | t.Errorf("upload after archiving: %q", op) | ||
| 250 | } | ||
| 251 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "download", now)), repo); op != "download" { | ||
| 252 | t.Errorf("download after archiving: %q", op) | ||
| 253 | } | ||
| 254 | } | ||
internal/lfs/lfs.go +33 −20
| @@ -120,52 +120,65 @@ func (s LocalStore) Delete(oid string) error { | |||
| 120 | } | 120 | } |
| 121 | 121 | ||
| 122 | // Tokens bridge SSH authentication to the HTTP endpoints: stateless, | 122 | // Tokens bridge SSH authentication to the HTTP endpoints: stateless, |
| 123 | // HMAC-signed, scoped to one repo and one operation, short-lived. The | 123 | // HMAC-signed, scoped to one repo and one operation, short-lived, and |
| 124 | // secret persists in the settings table so tokens survive restarts. | 124 | // bound to the SSH key that obtained them, which must still be live |
| 125 | // when the token is used (#285). The secret persists in the settings | ||
| 126 | // table so tokens survive restarts. | ||
| 125 | 127 | ||
| 126 | const TokenTTL = time.Hour | 128 | const TokenTTL = time.Hour |
| 127 | 129 | ||
| 128 | // Sign mints a token for op ("download" or "upload") on repoID. | 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound |
| 129 | func Sign(secret []byte, repoID int64, op string, now time.Time) string { | 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an |
| 130 | payload := fmt.Sprintf("%d:%s:%d", repoID, op, now.Add(TokenTTL).Unix()) | 132 | // anonymous download of a public repository. |
| 133 | func Sign(secret []byte, repoID, keyID int64, op string, now time.Time) string { | ||
| 134 | payload := fmt.Sprintf("%d:%d:%s:%d", repoID, keyID, op, now.Add(TokenTTL).Unix()) | ||
| 131 | mac := hmac.New(sha256.New, secret) | 135 | mac := hmac.New(sha256.New, secret) |
| 132 | mac.Write([]byte(payload)) | 136 | mac.Write([]byte(payload)) |
| 133 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + | 137 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + |
| 134 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) | 138 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) |
| 135 | } | 139 | } |
| 136 | 140 | ||
| 137 | // Verify checks a token and returns the repo and operation it authorizes. | 141 | // Grant is what a verified token authorizes. |
| 138 | func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) { | 142 | type Grant struct { |
| 143 | RepoID int64 | ||
| 144 | KeyID int64 // 0: an anonymous download of a public repository | ||
| 145 | Op string | ||
| 146 | } | ||
| 147 | |||
| 148 | // Verify checks a token's MAC, shape and expiry. A token from before | ||
| 149 | // tokens named their key does not verify. | ||
| 150 | func Verify(secret []byte, token string, now time.Time) (Grant, bool) { | ||
| 139 | payloadB64, macB64, found := strings.Cut(token, ".") | 151 | payloadB64, macB64, found := strings.Cut(token, ".") |
| 140 | if !found { | 152 | if !found { |
| 141 | return 0, "", false | 153 | return Grant{}, false |
| 142 | } | 154 | } |
| 143 | payload, err := base64.RawURLEncoding.DecodeString(payloadB64) | 155 | payload, err := base64.RawURLEncoding.DecodeString(payloadB64) |
| 144 | if err != nil { | 156 | if err != nil { |
| 145 | return 0, "", false | 157 | return Grant{}, false |
| 146 | } | 158 | } |
| 147 | gotMAC, err := base64.RawURLEncoding.DecodeString(macB64) | 159 | gotMAC, err := base64.RawURLEncoding.DecodeString(macB64) |
| 148 | if err != nil { | 160 | if err != nil { |
| 149 | return 0, "", false | 161 | return Grant{}, false |
| 150 | } | 162 | } |
| 151 | mac := hmac.New(sha256.New, secret) | 163 | mac := hmac.New(sha256.New, secret) |
| 152 | mac.Write(payload) | 164 | mac.Write(payload) |
| 153 | if !hmac.Equal(mac.Sum(nil), gotMAC) { | 165 | if !hmac.Equal(mac.Sum(nil), gotMAC) { |
| 154 | return 0, "", false | 166 | return Grant{}, false |
| 155 | } | 167 | } |
| 156 | parts := strings.Split(string(payload), ":") | 168 | parts := strings.Split(string(payload), ":") |
| 157 | if len(parts) != 3 { | 169 | if len(parts) != 4 { |
| 158 | return 0, "", false | 170 | return Grant{}, false |
| 159 | } | 171 | } |
| 160 | id, err1 := strconv.ParseInt(parts[0], 10, 64) | 172 | repoID, err1 := strconv.ParseInt(parts[0], 10, 64) |
| 161 | exp, err2 := strconv.ParseInt(parts[2], 10, 64) | 173 | keyID, err2 := strconv.ParseInt(parts[1], 10, 64) |
| 162 | if err1 != nil || err2 != nil || now.Unix() > exp { | 174 | exp, err3 := strconv.ParseInt(parts[3], 10, 64) |
| 163 | return 0, "", false | 175 | if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { |
| 176 | return Grant{}, false | ||
| 164 | } | 177 | } |
| 165 | if parts[1] != "download" && parts[1] != "upload" { | 178 | if parts[2] != "download" && parts[2] != "upload" { |
| 166 | return 0, "", false | 179 | return Grant{}, false |
| 167 | } | 180 | } |
| 168 | return id, parts[1], true | 181 | return Grant{RepoID: repoID, KeyID: keyID, Op: parts[2]}, true |
| 169 | } | 182 | } |
| 170 | 183 | ||
| 171 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. | 184 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. |
internal/lfs/lfs_test.go added +43
| @@ -0,0 +1,43 @@ | |||
| 1 | package lfs | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/hmac" | ||
| 5 | "crypto/sha256" | ||
| 6 | "encoding/base64" | ||
| 7 | "fmt" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestTokenCarriesTheKey(t *testing.T) { | ||
| 13 | secret := []byte("secret") | ||
| 14 | now := time.Now() | ||
| 15 | tok := Sign(secret, 7, 42, "upload", now) | ||
| 16 | g, ok := Verify(secret, tok, now) | ||
| 17 | if !ok || g != (Grant{RepoID: 7, KeyID: 42, Op: "upload"}) { | ||
| 18 | t.Fatalf("Verify = %+v, %v", g, ok) | ||
| 19 | } | ||
| 20 | if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok { | ||
| 21 | t.Error("an expired token verified") | ||
| 22 | } | ||
| 23 | if _, ok := Verify([]byte("other"), tok, now); ok { | ||
| 24 | t.Error("a token verified under another secret") | ||
| 25 | } | ||
| 26 | if g, ok := Verify(secret, Sign(secret, 7, 0, "download", now), now); !ok || g.KeyID != 0 { | ||
| 27 | t.Errorf("anonymous grant = %+v, %v", g, ok) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | // A token minted before tokens named their key has three fields. It is | ||
| 32 | // refused, not read as a grant bound to no key (#285). | ||
| 33 | func TestUnboundTokenRefused(t *testing.T) { | ||
| 34 | secret := []byte("secret") | ||
| 35 | payload := fmt.Sprintf("%d:%s:%d", 7, "upload", time.Now().Add(TokenTTL).Unix()) | ||
| 36 | mac := hmac.New(sha256.New, secret) | ||
| 37 | mac.Write([]byte(payload)) | ||
| 38 | tok := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + | ||
| 39 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) | ||
| 40 | if g, ok := Verify(secret, tok, time.Now()); ok { | ||
| 41 | t.Fatalf("a pre-upgrade token verified: %+v", g) | ||
| 42 | } | ||
| 43 | } | ||
internal/sshd/lfs.go +5 −3
| @@ -20,9 +20,11 @@ import ( | |||
| 20 | // with the HTTP endpoint and a short-lived repo- and operation-scoped | 20 | // with the HTTP endpoint and a short-lived repo- and operation-scoped |
| 21 | // token. Access rules mirror the git transports: download needs read, | 21 | // token. Access rules mirror the git transports: download needs read, |
| 22 | // upload needs write; deploy keys authorize by their binding alone, and | 22 | // upload needs write; deploy keys authorize by their binding alone, and |
| 23 | // every denial on an invisible repo reads as nonexistence. | 23 | // every denial on an invisible repo reads as nonexistence. The token |
| 24 | func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, scope string, | 24 | // names the key, so it stops working when the key does (#285). |
| 25 | func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, | ||
| 25 | argv []string, stdout, stderr io.Writer) int { | 26 | argv []string, stdout, stderr io.Writer) int { |
| 27 | scope := key.Scope | ||
| 26 | if len(argv) != 3 || (argv[2] != "download" && argv[2] != "upload") { | 28 | if len(argv) != 3 || (argv[2] != "download" && argv[2] != "upload") { |
| 27 | fmt.Fprintln(stderr, "usage: git-lfs-authenticate <path> download|upload") | 29 | fmt.Fprintln(stderr, "usage: git-lfs-authenticate <path> download|upload") |
| 28 | return protocol.ExitUsage | 30 | return protocol.ExitUsage |
| @@ -67,7 +69,7 @@ func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, sco | |||
| 67 | fmt.Fprintln(stderr, "internal error") | 69 | fmt.Fprintln(stderr, "internal error") |
| 68 | return protocol.ExitFailure | 70 | return protocol.ExitFailure |
| 69 | } | 71 | } |
| 70 | token := lfs.Sign([]byte(secret), repo.ID, op, time.Now()) | 72 | token := lfs.Sign([]byte(secret), repo.ID, key.ID, op, time.Now()) |
| 71 | json.NewEncoder(stdout).Encode(map[string]any{ | 73 | json.NewEncoder(stdout).Encode(map[string]any{ |
| 72 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", | 74 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", |
| 73 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), | 75 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), |
internal/sshd/sshd.go +1 −1
| @@ -496,7 +496,7 @@ func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user sto | |||
| 496 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | 496 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 497 | return protocol.ExitDenied | 497 | return protocol.ExitDenied |
| 498 | } | 498 | } |
| 499 | return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr) | 499 | return runLFSAuthenticate(cfg, st, user, key, argv, stdout, stderr) |
| 500 | } | 500 | } |
| 501 | } | 501 | } |
| 502 | ctx := &control.Ctx{ | 502 | ctx := &control.Ctx{ |