Commit 682aca3520
Verified · cmc
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 |
| 41 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string { | 41 | // anonymous download only. |
| 42 | func (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 | ||
| 60 | func lfsError(w http.ResponseWriter, code int, msg string) { | 61 | func 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}. |
| 180 | func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) { | 181 | func (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}. |
| 199 | func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) { | 204 | func (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 | ||
| 126 | const TokenTTL = time.Hour | 128 | const 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 |
| 129 | func 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. |
| 133 | func 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. |
| 138 | func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) { | 142 | type 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. | ||
| 150 | func 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 @@ | |||
| 1 | package lfs | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/hmac" | ||
| 5 | "crypto/sha256" | ||
| 6 | "encoding/base64" | ||
| 7 | "fmt" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func 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). | ||
| 33 | func 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 |
| 24 | func 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). |
| 25 | func 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{ |