lfs: a transfer token ends with its key and its access !511

merged merged by cmc on 2026-09-29 02:17 UTC · krz/gitbay:lfs-token-key into main

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
112git transport and returns a one-hour HMAC token scoped to repository 112git transport and returns a one-hour HMAC token scoped to repository,
113and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The 113operation and the SSH key that asked for it, deploy keys included
114HTTP batch, upload and download endpoints verify that token; public 114(=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The HTTP batch, upload
115and download endpoints verify that token and that its key is still
116registered, unexpired and on an enabled account (=store.LiveSSHKeys=),
117and repeat the repository check for the key on each request; public
115repositories allow anonymous download. Objects are verified against 118repositories allow anonymous download. Objects are verified against
116their SHA-256 id on upload. 119their 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
27Generation and hashing: =internal/store/sessions.go= (=NewToken=, 27Generation 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.
177func 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.
37const lastPort = 32000
38
35var nextPort = func() *atomic.Int32 { 39var 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
41func (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.
46func (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.
79func (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
60func lfsError(w http.ResponseWriter, code int, msg string) { 104func 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}.
180func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { 224func (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}.
199func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) { 247func (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 @@
1package httpd
2
3import (
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
14func 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
22func 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).
37func 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.
91func 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
111func 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).
126func 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.
185func 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.
228func 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
126const TokenTTL = time.Hour 128const 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
129func 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.
133func 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.
138func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) { 142type 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.
150func 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 @@
1package lfs
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/base64"
7 "fmt"
8 "testing"
9 "time"
10)
11
12func 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).
33func 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
24func 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).
25func 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{