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=,
109109** F. LFS
110110
111111=git-lfs-authenticate= over SSH applies the same repository checks as
112git transport and returns a one-hour HMAC token scoped to repository
113and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The
114HTTP batch, upload and download endpoints verify that token; public
112git transport and returns a one-hour HMAC token scoped to repository,
113operation and the SSH key that asked for it, deploy keys included
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
115118repositories allow anonymous download. Objects are verified against
116119their SHA-256 id on upload.
117120
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1 −1
@@ -22,7 +22,7 @@
2222| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
2323| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
2424| 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 |
2626
2727Generation and hashing: =internal/store/sessions.go= (=NewToken=,
2828=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.
2525| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
2626| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
2727| 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=) |
2929| Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) |
3030
3131** 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
4545 re-reads its key. Removing a key, removing a deploy key, disabling or
4646 deleting an account closes the connections the affected keys opened:
4747 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
4951 inside its database write finishes it; its output is lost. A
5052 revocation made by =gitbayd admin= on the host, another process, is
5153 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
265265- Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take
266266 a pack slot, answer 503 with =Retry-After: 30= when none is free, and
267267 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).
268281
269282* v1.36.0 — 2026-09-23
270283
e2e/lfs_test.go +54
@@ -170,3 +170,57 @@ func TestLFS(t *testing.T) {
170170 t.Fatal("corrupt object was stored")
171171 }
172172}
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 {
2929 stderr *tailBuffer // the end of the first serve's stderr
3030}
3131
32// nextPort hands out candidate ports. Seeded randomly so two test processes
33// on one machine — `go test ./...` runs packages concurrently — start in
34// different places.
32// nextPort hands out candidate ports below 32768, where Linux and macOS
33// start the ports they give outgoing connections: a git or SMTP client in
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
3539var nextPort = func() *atomic.Int32 {
3640 var n atomic.Int32
37 n.Store(int32(20000 + rand.IntN(20000)))
41 n.Store(int32(10000 + rand.IntN(10000)))
3842 return &n
3943}()
4044
@@ -58,7 +62,7 @@ func freePorts(t *testing.T, n int) []int {
5862 ports := make([]int, 0, n)
5963 for len(ports) < n {
6064 p := int(nextPort.Add(1))
61 if p > 60000 {
65 if p > lastPort {
6266 t.Fatal("ran out of ports")
6367 }
6468 ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
internal/httpd/lfs.go +66 −14
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "gitbay.org/gitbay/internal/lfs"
12 "gitbay.org/gitbay/internal/policy"
1213 "gitbay.org/gitbay/internal/store"
1314)
1415
@@ -36,25 +37,68 @@ func (s *Server) lfsSecret() ([]byte, error) {
3637}
3738
3839// lfsAuth resolves what the request may do to the repo: "upload",
39// "download", or "" for no access. Tokens are repo-scoped; without one,
40// public repos allow anonymous download only.
41func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string {
40// "download", or "" for no access, and the key the grant rests on (0
41// for none). A token is bound to the SSH key that obtained it and
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) {
4247 auth := r.Header.Get("Authorization")
4348 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
4449 secret, err := s.lfsSecret()
4550 if err != nil {
46 return ""
51 return "", 0
4752 }
48 repoID, op, ok := lfs.Verify(secret, tok, time.Now())
49 if !ok || repoID != repo.ID {
50 return ""
53 g, ok := lfs.Verify(secret, tok, time.Now())
54 if !ok || g.RepoID != repo.ID {
55 return "", 0
5156 }
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
5369 }
5470 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
5693 }
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)
58102}
59103
60104func lfsError(w http.ResponseWriter, code int, msg string) {
@@ -96,7 +140,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
96140 lfsError(w, http.StatusNotFound, "repository not found")
97141 return
98142 }
99 granted := s.lfsAuth(r, repo)
143 granted, keyID := s.lfsAuth(r, repo)
100144 if granted == "" {
101145 // Not naming whether the repo exists, per the enumeration rule.
102146 lfsError(w, http.StatusNotFound, "repository not found")
@@ -127,7 +171,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
127171 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
128172 return
129173 }
130 transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now())
174 transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
131175 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
132176 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
133177 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
@@ -179,7 +223,11 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
179223// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
180224func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
181225 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 == "" {
183231 lfsError(w, http.StatusNotFound, "not found")
184232 return
185233 }
@@ -198,7 +246,11 @@ func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
198246// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
199247func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
200248 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" {
202254 lfsError(w, http.StatusNotFound, "not found")
203255 return
204256 }
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 {
120120}
121121
122122// Tokens bridge SSH authentication to the HTTP endpoints: stateless,
123// HMAC-signed, scoped to one repo and one operation, short-lived. The
124// secret persists in the settings table so tokens survive restarts.
123// HMAC-signed, scoped to one repo and one operation, short-lived, and
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.
125127
126128const TokenTTL = time.Hour
127129
128// Sign mints a token for op ("download" or "upload") on repoID.
129func Sign(secret []byte, repoID int64, op string, now time.Time) string {
130 payload := fmt.Sprintf("%d:%s:%d", repoID, op, now.Add(TokenTTL).Unix())
130// Sign mints a token for op ("download" or "upload") on repoID, bound
131// to keyID: the SSH key, user or deploy, that asked for it, or 0 for an
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())
131135 mac := hmac.New(sha256.New, secret)
132136 mac.Write([]byte(payload))
133137 return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
134138 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
135139}
136140
137// Verify checks a token and returns the repo and operation it authorizes.
138func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) {
141// Grant is what a verified token authorizes.
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) {
139151 payloadB64, macB64, found := strings.Cut(token, ".")
140152 if !found {
141 return 0, "", false
153 return Grant{}, false
142154 }
143155 payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
144156 if err != nil {
145 return 0, "", false
157 return Grant{}, false
146158 }
147159 gotMAC, err := base64.RawURLEncoding.DecodeString(macB64)
148160 if err != nil {
149 return 0, "", false
161 return Grant{}, false
150162 }
151163 mac := hmac.New(sha256.New, secret)
152164 mac.Write(payload)
153165 if !hmac.Equal(mac.Sum(nil), gotMAC) {
154 return 0, "", false
166 return Grant{}, false
155167 }
156168 parts := strings.Split(string(payload), ":")
157 if len(parts) != 3 {
158 return 0, "", false
169 if len(parts) != 4 {
170 return Grant{}, false
159171 }
160 id, err1 := strconv.ParseInt(parts[0], 10, 64)
161 exp, err2 := strconv.ParseInt(parts[2], 10, 64)
162 if err1 != nil || err2 != nil || now.Unix() > exp {
163 return 0, "", false
172 repoID, err1 := strconv.ParseInt(parts[0], 10, 64)
173 keyID, err2 := strconv.ParseInt(parts[1], 10, 64)
174 exp, err3 := strconv.ParseInt(parts[3], 10, 64)
175 if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp {
176 return Grant{}, false
164177 }
165 if parts[1] != "download" && parts[1] != "upload" {
166 return 0, "", false
178 if parts[2] != "download" && parts[2] != "upload" {
179 return Grant{}, false
167180 }
168 return id, parts[1], true
181 return Grant{RepoID: repoID, KeyID: keyID, Op: parts[2]}, true
169182}
170183
171184// 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 (
2020// with the HTTP endpoint and a short-lived repo- and operation-scoped
2121// token. Access rules mirror the git transports: download needs read,
2222// upload needs write; deploy keys authorize by their binding alone, and
23// every denial on an invisible repo reads as nonexistence.
24func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, scope string,
23// every denial on an invisible repo reads as nonexistence. The token
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,
2526 argv []string, stdout, stderr io.Writer) int {
27 scope := key.Scope
2628 if len(argv) != 3 || (argv[2] != "download" && argv[2] != "upload") {
2729 fmt.Fprintln(stderr, "usage: git-lfs-authenticate <path> download|upload")
2830 return protocol.ExitUsage
@@ -67,7 +69,7 @@ func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, sco
6769 fmt.Fprintln(stderr, "internal error")
6870 return protocol.ExitFailure
6971 }
70 token := lfs.Sign([]byte(secret), repo.ID, op, time.Now())
72 token := lfs.Sign([]byte(secret), repo.ID, key.ID, op, time.Now())
7173 json.NewEncoder(stdout).Encode(map[string]any{
7274 "href": fmt.Sprintf("%s/%s/%s.git/info/lfs",
7375 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
496496 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
497497 return protocol.ExitDenied
498498 }
499 return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)
499 return runLFSAuthenticate(cfg, st, user, key, argv, stdout, stderr)
500500 }
501501 }
502502 ctx := &control.Ctx{