Commit bd5cf5d7d1

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27

parent: b5cc83bf50

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:23 UTC

lfs: e2e for a token outliving its key; document the binding

Closes #285

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 +12
@@ -265,6 +265,18 @@ 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 (#285).
268 280
269* v1.36.0 — 2026-09-23 281* v1.36.0 — 2026-09-23
270 282
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}