Commit 682aca3520

682aca35203a1e35814c71c747334cbccefbf08b

parent: f8b976a972

Verified · cmc

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

lfs: a transfer token names the key that obtained it

A pre-upgrade token, which names none, no longer verifies.

Ref #285

Layout: unified · split

internal/httpd/lfs.go +23 −14
@@ -36,25 +36,26 @@ func (s *Server) lfsSecret() ([]byte, error) {
3636}
3737
3838// 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 {
39// "download", or "" for no access, and the key the grant rests on (0
40// for none). Tokens are repo-scoped; without one, public repos allow
41// anonymous download only.
42func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
4243 auth := r.Header.Get("Authorization")
4344 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
4445 secret, err := s.lfsSecret()
4546 if err != nil {
46 return ""
47 return "", 0
4748 }
48 repoID, op, ok := lfs.Verify(secret, tok, time.Now())
49 if !ok || repoID != repo.ID {
50 return ""
49 g, ok := lfs.Verify(secret, tok, time.Now())
50 if !ok || g.RepoID != repo.ID {
51 return "", 0
5152 }
52 return op
53 return g.Op, g.KeyID
5354 }
5455 if repo.Visibility == "public" {
55 return "download"
56 return "download", 0
5657 }
57 return ""
58 return "", 0
5859}
5960
6061func lfsError(w http.ResponseWriter, code int, msg string) {
@@ -96,7 +97,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
9697 lfsError(w, http.StatusNotFound, "repository not found")
9798 return
9899 }
99 granted := s.lfsAuth(r, repo)
100 granted, keyID := s.lfsAuth(r, repo)
100101 if granted == "" {
101102 // Not naming whether the repo exists, per the enumeration rule.
102103 lfsError(w, http.StatusNotFound, "repository not found")
@@ -127,7 +128,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
127128 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
128129 return
129130 }
130 transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now())
131 transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
131132 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
132133 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
133134 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
@@ -179,7 +180,11 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
179180// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
180181func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
181182 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
182 if err != nil || s.lfsAuth(r, repo) == "" {
183 if err != nil {
184 lfsError(w, http.StatusNotFound, "not found")
185 return
186 }
187 if op, _ := s.lfsAuth(r, repo); op == "" {
183188 lfsError(w, http.StatusNotFound, "not found")
184189 return
185190 }
@@ -198,7 +203,11 @@ func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
198203// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
199204func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
200205 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
201 if err != nil || s.lfsAuth(r, repo) != "upload" {
206 if err != nil {
207 lfsError(w, http.StatusNotFound, "not found")
208 return
209 }
210 if op, _ := s.lfsAuth(r, repo); op != "upload" {
202211 lfsError(w, http.StatusNotFound, "not found")
203212 return
204213 }
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{