Commit fc755889ba

fc755889baf27e51072434797bb4de2076d3f7eb

parent: c2e04ef116

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 02:26 UTC

lfs: tokens carry the key's fingerprint pin beside its id

Closes #303

Layout: unified · split

CHANGELOG.org +6
@@ -300,6 +300,12 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
300300 of a public repository made private, loses the token's use with the
301301 access, and an upload token stops working once its repository is
302302 archived (#285).
303- LFS transfer tokens carry a hash of their key's fingerprint beside
304 its id, and a token whose key id now belongs to another key is
305 refused, since SQLite gives a new key the id of the last deleted one.
306 The token format changed: tokens minted before the upgrade are
307 refused, and a transfer running across the restart needs running
308 again (#303).
303309
304310* v1.36.0 — 2026-09-23
305311
internal/httpd/lfs.go +17 −6
@@ -41,7 +41,8 @@ func (s *Server) lfsSecret() ([]byte, error) {
4141// for none). A token is bound to the SSH key that obtained it and
4242// works only while that key is registered, unexpired and on an enabled
4343// account, and while the key still has the access its operation needs
44// on the repo (#285). Without one, public repos allow anonymous
44// on the repo (#285). A key that took over a deleted key's id does not
45// match the token's fingerprint pin (#303). Without one, public repos allow anonymous
4546// download only.
4647func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
4748 auth := r.Header.Get("Authorization")
@@ -62,7 +63,7 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
6263 return "", 0
6364 }
6465 live, err := s.st.LiveSSHKeys([]int64{g.KeyID})
65 if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") {
66 if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, g.KeyPin, repo, g.Op == "upload") {
6667 return "", 0
6768 }
6869 return g.Op, g.KeyID
@@ -75,13 +76,14 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
7576
7677// lfsKeyAllows repeats git-lfs-authenticate's access check for the key
7778// 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 {
79// access narrowed by the key's scope. The key must be the one the token
80// was minted for, by fingerprint pin. An archived repo takes no uploads.
81func (s *Server) lfsKeyAllows(keyID int64, pin string, repo store.Repo, write bool) bool {
8082 if write && repo.Settings.Archived {
8183 return false
8284 }
8385 key, err := s.st.SSHKeyByID(keyID)
84 if err != nil {
86 if err != nil || lfs.KeyPin(key.Fingerprint) != pin {
8587 return false
8688 }
8789 if policy.IsDeployScope(key.Scope) {
@@ -171,7 +173,16 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
171173 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
172174 return
173175 }
174 transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
176 fingerprint := ""
177 if keyID != 0 {
178 key, err := s.st.SSHKeyByID(keyID)
179 if err != nil {
180 lfsError(w, http.StatusNotFound, "repository not found")
181 return
182 }
183 fingerprint = key.Fingerprint
184 }
185 transferToken := lfs.Sign(secret, repo.ID, keyID, fingerprint, req.Operation, time.Now())
175186 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
176187 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
177188 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
internal/httpd/lfsauth_test.go +44 −16
@@ -54,14 +54,14 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) {
5454 }
5555
5656 live := addKey("SHA256:live", nil)
57 tok := lfs.Sign(secret, repo.ID, live, "upload", time.Now())
57 tok := lfs.Sign(secret, repo.ID, live, "SHA256:live", "upload", time.Now())
5858 if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live {
5959 t.Fatalf("live key: %q, %d", op, key)
6060 }
6161
6262 past := time.Now().Add(-time.Minute)
6363 expired := addKey("SHA256:expired", &past)
64 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "upload", time.Now())), repo); op != "" {
64 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "SHA256:expired", "upload", time.Now())), repo); op != "" {
6565 t.Errorf("expired key: %q", op)
6666 }
6767
@@ -73,7 +73,7 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) {
7373 }
7474
7575 other := addKey("SHA256:other", nil)
76 otherTok := lfs.Sign(secret, repo.ID, other, "download", time.Now())
76 otherTok := lfs.Sign(secret, repo.ID, other, "SHA256:other", "download", time.Now())
7777 if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" {
7878 t.Fatalf("second key before disable: %q", op)
7979 }
@@ -97,13 +97,13 @@ func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) {
9797 t.Fatal(err)
9898 }
9999 now := time.Now()
100 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "download", now)), pub); op != "download" {
100 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "download", now)), pub); op != "download" {
101101 t.Errorf("public download: %q", op)
102102 }
103 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "upload", now)), pub); op != "" {
103 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "upload", now)), pub); op != "" {
104104 t.Errorf("anonymous upload: %q", op)
105105 }
106 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "download", now)), priv); op != "" {
106 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "", "download", now)), priv); op != "" {
107107 t.Errorf("private download: %q", op)
108108 }
109109}
@@ -140,7 +140,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
140140 t.Fatal(err)
141141 }
142142 bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full")
143 up := lfs.Sign(secret, repo.ID, bobKey, "upload", now)
143 up := lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "upload", now)
144144 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" {
145145 t.Fatalf("collaborator upload: %q", op)
146146 }
@@ -153,7 +153,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
153153 if err := st.RevokeAccess(repo.ID, bob); err != nil {
154154 t.Fatal(err)
155155 }
156 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "download", now)), repo); op != "" {
156 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "download", now)), repo); op != "" {
157157 t.Errorf("download after access was revoked: %q", op)
158158 }
159159
@@ -163,7 +163,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
163163 t.Fatal(err)
164164 }
165165 carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full")
166 down := lfs.Sign(secret, pub.ID, carolKey, "download", now)
166 down := lfs.Sign(secret, pub.ID, carolKey, "SHA256:carol", "download", now)
167167 if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" {
168168 t.Fatalf("public download: %q", op)
169169 }
@@ -194,12 +194,12 @@ func TestLFSDeployKeyToken(t *testing.T) {
194194 ro := fmt.Sprintf("deploy:%d:ro", repo.ID)
195195
196196 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 {
197 if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "upload", now)), repo); op != "upload" || key != live {
198198 t.Fatalf("live deploy key: %q, %d", op, key)
199199 }
200200
201201 removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw)
202 tok := lfs.Sign(secret, repo.ID, removed, "download", now)
202 tok := lfs.Sign(secret, repo.ID, removed, "SHA256:deploy-removed", "download", now)
203203 if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil {
204204 t.Fatal(err)
205205 }
@@ -208,17 +208,17 @@ func TestLFSDeployKeyToken(t *testing.T) {
208208 }
209209
210210 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" {
211 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "download", now)), repo); op != "download" {
212212 t.Errorf("read-only deploy key download: %q", op)
213213 }
214 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "upload", now)), repo); op != "" {
214 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "upload", now)), repo); op != "" {
215215 t.Errorf("read-only deploy key upload: %q", op)
216216 }
217217
218218 if err := st.SetUserDisabled(u.ID, true); err != nil {
219219 t.Fatal(err)
220220 }
221 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "download", now)), repo); op != "" {
221 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "download", now)), repo); op != "" {
222222 t.Errorf("deploy key of a disabled account: %q", op)
223223 }
224224}
@@ -234,7 +234,7 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) {
234234 }
235235 key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full")
236236 now := time.Now()
237 up := lfs.Sign(secret, repo.ID, key, "upload", now)
237 up := lfs.Sign(secret, repo.ID, key, "SHA256:owner", "upload", now)
238238 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" {
239239 t.Fatalf("upload before archiving: %q", op)
240240 }
@@ -248,7 +248,35 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) {
248248 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" {
249249 t.Errorf("upload after archiving: %q", op)
250250 }
251 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "download", now)), repo); op != "download" {
251 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "SHA256:owner", "download", now)), repo); op != "download" {
252252 t.Errorf("download after archiving: %q", op)
253253 }
254254}
255
256// SQLite gives a new key the id of the highest deleted one. A token
257// minted for the deleted key is refused when presented against the new
258// key; the new key's own token works (#303).
259func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) {
260 s, st, u := newTokenTestServer(t)
261 repo := lfsTestRepo(t, st, u.ID, "app", "private")
262 secret, err := s.lfsSecret()
263 if err != nil {
264 t.Fatal(err)
265 }
266 now := time.Now()
267 oldID := lfsTestKey(t, st, u.ID, "SHA256:old", "full")
268 old := lfs.Sign(secret, repo.ID, oldID, "SHA256:old", "upload", now)
269 if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil {
270 t.Fatal(err)
271 }
272 newID := lfsTestKey(t, st, u.ID, "SHA256:new", "full")
273 if newID != oldID {
274 t.Fatalf("new key got id %d, want the reused %d", newID, oldID)
275 }
276 if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" {
277 t.Errorf("old key's token on the reused id: %q", op)
278 }
279 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, newID, "SHA256:new", "upload", now)), repo); op != "upload" {
280 t.Errorf("new key's own token: %q", op)
281 }
282}
internal/lfs/lfs.go +27 −9
@@ -129,24 +129,39 @@ const TokenTTL = time.Hour
129129
130130// Sign mints a token for op ("download" or "upload") on repoID, bound
131131// 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())
132// anonymous download of a public repository. fingerprint is that key's
133// fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so
134// the token carries a hash of the fingerprint as well and a new key
135// given the old id does not inherit the old key's tokens (#303).
136func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string {
137 payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix())
135138 mac := hmac.New(sha256.New, secret)
136139 mac.Write([]byte(payload))
137140 return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
138141 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
139142}
140143
144// KeyPin is the fingerprint's form in a token: the first 16 hex
145// characters of its SHA-256, or "" for no key.
146func KeyPin(fingerprint string) string {
147 if fingerprint == "" {
148 return ""
149 }
150 sum := sha256.Sum256([]byte(fingerprint))
151 return hex.EncodeToString(sum[:8])
152}
153
141154// Grant is what a verified token authorizes.
142155type Grant struct {
143156 RepoID int64
144 KeyID int64 // 0: an anonymous download of a public repository
157 KeyID int64 // 0: an anonymous download of a public repository
158 KeyPin string // KeyPin of the key's fingerprint; "" when KeyID is 0
145159 Op string
146160}
147161
148162// Verify checks a token's MAC, shape and expiry. A token from before
149// tokens named their key does not verify.
163// tokens named their key, or before they carried its fingerprint, does
164// not verify.
150165func Verify(secret []byte, token string, now time.Time) (Grant, bool) {
151166 payloadB64, macB64, found := strings.Cut(token, ".")
152167 if !found {
@@ -166,19 +181,22 @@ func Verify(secret []byte, token string, now time.Time) (Grant, bool) {
166181 return Grant{}, false
167182 }
168183 parts := strings.Split(string(payload), ":")
169 if len(parts) != 4 {
184 if len(parts) != 5 {
170185 return Grant{}, false
171186 }
172187 repoID, err1 := strconv.ParseInt(parts[0], 10, 64)
173188 keyID, err2 := strconv.ParseInt(parts[1], 10, 64)
174 exp, err3 := strconv.ParseInt(parts[3], 10, 64)
189 exp, err3 := strconv.ParseInt(parts[4], 10, 64)
175190 if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp {
176191 return Grant{}, false
177192 }
178 if parts[2] != "download" && parts[2] != "upload" {
193 if (keyID == 0) != (parts[2] == "") {
194 return Grant{}, false
195 }
196 if parts[3] != "download" && parts[3] != "upload" {
179197 return Grant{}, false
180198 }
181 return Grant{RepoID: repoID, KeyID: keyID, Op: parts[2]}, true
199 return Grant{RepoID: repoID, KeyID: keyID, KeyPin: parts[2], Op: parts[3]}, true
182200}
183201
184202// NewSecret returns 32 random bytes, hex-encoded for the settings table.
internal/lfs/lfs_test.go +17 −3
@@ -12,9 +12,9 @@ import (
1212func TestTokenCarriesTheKey(t *testing.T) {
1313 secret := []byte("secret")
1414 now := time.Now()
15 tok := Sign(secret, 7, 42, "upload", now)
15 tok := Sign(secret, 7, 42, "SHA256:k", "upload", now)
1616 g, ok := Verify(secret, tok, now)
17 if !ok || g != (Grant{RepoID: 7, KeyID: 42, Op: "upload"}) {
17 if !ok || g != (Grant{RepoID: 7, KeyID: 42, KeyPin: KeyPin("SHA256:k"), Op: "upload"}) {
1818 t.Fatalf("Verify = %+v, %v", g, ok)
1919 }
2020 if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok {
@@ -23,7 +23,7 @@ func TestTokenCarriesTheKey(t *testing.T) {
2323 if _, ok := Verify([]byte("other"), tok, now); ok {
2424 t.Error("a token verified under another secret")
2525 }
26 if g, ok := Verify(secret, Sign(secret, 7, 0, "download", now), now); !ok || g.KeyID != 0 {
26 if g, ok := Verify(secret, Sign(secret, 7, 0, "", "download", now), now); !ok || g.KeyID != 0 || g.KeyPin != "" {
2727 t.Errorf("anonymous grant = %+v, %v", g, ok)
2828 }
2929}
@@ -41,3 +41,17 @@ func TestUnboundTokenRefused(t *testing.T) {
4141 t.Fatalf("a pre-upgrade token verified: %+v", g)
4242 }
4343}
44
45// A token minted before tokens carried the key's fingerprint has four
46// fields. It is refused (#303).
47func TestUnpinnedTokenRefused(t *testing.T) {
48 secret := []byte("secret")
49 payload := fmt.Sprintf("%d:%d:%s:%d", 7, 42, "upload", time.Now().Add(TokenTTL).Unix())
50 mac := hmac.New(sha256.New, secret)
51 mac.Write([]byte(payload))
52 tok := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
53 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
54 if g, ok := Verify(secret, tok, time.Now()); ok {
55 t.Fatalf("an unpinned token verified: %+v", g)
56 }
57}
internal/sshd/lfs.go +1 −1
@@ -69,7 +69,7 @@ func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, key
6969 fmt.Fprintln(stderr, "internal error")
7070 return protocol.ExitFailure
7171 }
72 token := lfs.Sign([]byte(secret), repo.ID, key.ID, op, time.Now())
72 token := lfs.Sign([]byte(secret), repo.ID, key.ID, key.Fingerprint, op, time.Now())
7373 json.NewEncoder(stdout).Encode(map[string]any{
7474 "href": fmt.Sprintf("%s/%s/%s.git/info/lfs",
7575 cfg.Server.SiteURL, repo.OwnerName, repo.Name),