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) {
36} 36}
37 37
38// lfsAuth resolves what the request may do to the repo: "upload", 38// lfsAuth resolves what the request may do to the repo: "upload",
39// "download", or "" for no access. Tokens are repo-scoped; without one, 39// "download", or "" for no access, and the key the grant rests on (0
40// public repos allow anonymous download only. 40// for none). Tokens are repo-scoped; without one, public repos allow
41func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string { 41// anonymous download only.
42func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
42 auth := r.Header.Get("Authorization") 43 auth := r.Header.Get("Authorization")
43 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok { 44 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
44 secret, err := s.lfsSecret() 45 secret, err := s.lfsSecret()
45 if err != nil { 46 if err != nil {
46 return "" 47 return "", 0
47 } 48 }
48 repoID, op, ok := lfs.Verify(secret, tok, time.Now()) 49 g, ok := lfs.Verify(secret, tok, time.Now())
49 if !ok || repoID != repo.ID { 50 if !ok || g.RepoID != repo.ID {
50 return "" 51 return "", 0
51 } 52 }
52 return op 53 return g.Op, g.KeyID
53 } 54 }
54 if repo.Visibility == "public" { 55 if repo.Visibility == "public" {
55 return "download" 56 return "download", 0
56 } 57 }
57 return "" 58 return "", 0
58} 59}
59 60
60func lfsError(w http.ResponseWriter, code int, msg string) { 61func lfsError(w http.ResponseWriter, code int, msg string) {
@@ -96,7 +97,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
96 lfsError(w, http.StatusNotFound, "repository not found") 97 lfsError(w, http.StatusNotFound, "repository not found")
97 return 98 return
98 } 99 }
99 granted := s.lfsAuth(r, repo) 100 granted, keyID := s.lfsAuth(r, repo)
100 if granted == "" { 101 if granted == "" {
101 // Not naming whether the repo exists, per the enumeration rule. 102 // Not naming whether the repo exists, per the enumeration rule.
102 lfsError(w, http.StatusNotFound, "repository not found") 103 lfsError(w, http.StatusNotFound, "repository not found")
@@ -127,7 +128,7 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
127 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") 128 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
128 return 129 return
129 } 130 }
130 transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now()) 131 transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now())
131 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", 132 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
132 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) 133 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
133 authHeader := map[string]string{"Authorization": "Bearer " + transferToken} 134 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
@@ -179,7 +180,11 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
179// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}. 180// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
180func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { 181func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
181 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) 182 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 == "" {
183 lfsError(w, http.StatusNotFound, "not found") 188 lfsError(w, http.StatusNotFound, "not found")
184 return 189 return
185 } 190 }
@@ -198,7 +203,11 @@ func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
198// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}. 203// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
199func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) { 204func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
200 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) 205 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" {
202 lfsError(w, http.StatusNotFound, "not found") 211 lfsError(w, http.StatusNotFound, "not found")
203 return 212 return
204 } 213 }
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{