Commit fc755889ba
Verified · cmc
Layout: unified · split
CHANGELOG.org +6
| @@ -300,6 +300,12 @@ missing, =gitbayd admin backup --verify <archive>= names it, and | |||
| 300 | of a public repository made private, loses the token's use with the | 300 | of a public repository made private, loses the token's use with the |
| 301 | access, and an upload token stops working once its repository is | 301 | access, and an upload token stops working once its repository is |
| 302 | archived (#285). | 302 | 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). | ||
| 303 | 309 | ||
| 304 | * v1.36.0 — 2026-09-23 | 310 | * v1.36.0 — 2026-09-23 |
| 305 | 311 | ||
internal/httpd/lfs.go +17 −6
| @@ -41,7 +41,8 @@ func (s *Server) lfsSecret() ([]byte, error) { | |||
| 41 | // for none). A token is bound to the SSH key that obtained it and | 41 | // for none). A token is bound to the SSH key that obtained it and |
| 42 | // works only while that key is registered, unexpired and on an enabled | 42 | // works only while that key is registered, unexpired and on an enabled |
| 43 | // account, and while the key still has the access its operation needs | 43 | // 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 | ||
| 45 | // download only. | 46 | // download only. |
| 46 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | 47 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { |
| 47 | auth := r.Header.Get("Authorization") | 48 | auth := r.Header.Get("Authorization") |
| @@ -62,7 +63,7 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | |||
| 62 | return "", 0 | 63 | return "", 0 |
| 63 | } | 64 | } |
| 64 | live, err := s.st.LiveSSHKeys([]int64{g.KeyID}) | 65 | 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") { |
| 66 | return "", 0 | 67 | return "", 0 |
| 67 | } | 68 | } |
| 68 | return g.Op, g.KeyID | 69 | return g.Op, g.KeyID |
| @@ -75,13 +76,14 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | |||
| 75 | 76 | ||
| 76 | // lfsKeyAllows repeats git-lfs-authenticate's access check for the key | 77 | // lfsKeyAllows repeats git-lfs-authenticate's access check for the key |
| 77 | // now: a deploy key by its binding, any other key by its account's | 78 | // 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. | 79 | // access narrowed by the key's scope. The key must be the one the token |
| 79 | func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool { | 80 | // was minted for, by fingerprint pin. An archived repo takes no uploads. |
| 81 | func (s *Server) lfsKeyAllows(keyID int64, pin string, repo store.Repo, write bool) bool { | ||
| 80 | if write && repo.Settings.Archived { | 82 | if write && repo.Settings.Archived { |
| 81 | return false | 83 | return false |
| 82 | } | 84 | } |
| 83 | key, err := s.st.SSHKeyByID(keyID) | 85 | key, err := s.st.SSHKeyByID(keyID) |
| 84 | if err != nil { | 86 | if err != nil || lfs.KeyPin(key.Fingerprint) != pin { |
| 85 | return false | 87 | return false |
| 86 | } | 88 | } |
| 87 | if policy.IsDeployScope(key.Scope) { | 89 | if policy.IsDeployScope(key.Scope) { |
| @@ -171,7 +173,16 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) { | |||
| 171 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") | 173 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") |
| 172 | return | 174 | return |
| 173 | } | 175 | } |
| 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()) | ||
| 175 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", | 186 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", |
| 176 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) | 187 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) |
| 177 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} | 188 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} |
internal/httpd/lfsauth_test.go +44 −16
| @@ -54,14 +54,14 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) { | |||
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | live := addKey("SHA256:live", nil) | 56 | 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()) |
| 58 | if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live { | 58 | if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live { |
| 59 | t.Fatalf("live key: %q, %d", op, key) | 59 | t.Fatalf("live key: %q, %d", op, key) |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | past := time.Now().Add(-time.Minute) | 62 | past := time.Now().Add(-time.Minute) |
| 63 | expired := addKey("SHA256:expired", &past) | 63 | 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 != "" { |
| 65 | t.Errorf("expired key: %q", op) | 65 | t.Errorf("expired key: %q", op) |
| 66 | } | 66 | } |
| 67 | 67 | ||
| @@ -73,7 +73,7 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) { | |||
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | other := addKey("SHA256:other", nil) | 75 | 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()) |
| 77 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" { | 77 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" { |
| 78 | t.Fatalf("second key before disable: %q", op) | 78 | t.Fatalf("second key before disable: %q", op) |
| 79 | } | 79 | } |
| @@ -97,13 +97,13 @@ func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) { | |||
| 97 | t.Fatal(err) | 97 | t.Fatal(err) |
| 98 | } | 98 | } |
| 99 | now := time.Now() | 99 | 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" { |
| 101 | t.Errorf("public download: %q", op) | 101 | t.Errorf("public download: %q", op) |
| 102 | } | 102 | } |
| 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 != "" { |
| 104 | t.Errorf("anonymous upload: %q", op) | 104 | t.Errorf("anonymous upload: %q", op) |
| 105 | } | 105 | } |
| 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 != "" { |
| 107 | t.Errorf("private download: %q", op) | 107 | t.Errorf("private download: %q", op) |
| 108 | } | 108 | } |
| 109 | } | 109 | } |
| @@ -140,7 +140,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 140 | t.Fatal(err) | 140 | t.Fatal(err) |
| 141 | } | 141 | } |
| 142 | bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full") | 142 | 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) |
| 144 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | 144 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { |
| 145 | t.Fatalf("collaborator upload: %q", op) | 145 | t.Fatalf("collaborator upload: %q", op) |
| 146 | } | 146 | } |
| @@ -153,7 +153,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 153 | if err := st.RevokeAccess(repo.ID, bob); err != nil { | 153 | if err := st.RevokeAccess(repo.ID, bob); err != nil { |
| 154 | t.Fatal(err) | 154 | t.Fatal(err) |
| 155 | } | 155 | } |
| 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 != "" { |
| 157 | t.Errorf("download after access was revoked: %q", op) | 157 | t.Errorf("download after access was revoked: %q", op) |
| 158 | } | 158 | } |
| 159 | 159 | ||
| @@ -163,7 +163,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 163 | t.Fatal(err) | 163 | t.Fatal(err) |
| 164 | } | 164 | } |
| 165 | carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full") | 165 | 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) |
| 167 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" { | 167 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" { |
| 168 | t.Fatalf("public download: %q", op) | 168 | t.Fatalf("public download: %q", op) |
| 169 | } | 169 | } |
| @@ -194,12 +194,12 @@ func TestLFSDeployKeyToken(t *testing.T) { | |||
| 194 | ro := fmt.Sprintf("deploy:%d:ro", repo.ID) | 194 | ro := fmt.Sprintf("deploy:%d:ro", repo.ID) |
| 195 | 195 | ||
| 196 | live := lfsTestKey(t, st, u.ID, "SHA256:deploy-live", rw) | 196 | 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 { |
| 198 | t.Fatalf("live deploy key: %q, %d", op, key) | 198 | t.Fatalf("live deploy key: %q, %d", op, key) |
| 199 | } | 199 | } |
| 200 | 200 | ||
| 201 | removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw) | 201 | 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) |
| 203 | if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil { | 203 | if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil { |
| 204 | t.Fatal(err) | 204 | t.Fatal(err) |
| 205 | } | 205 | } |
| @@ -208,17 +208,17 @@ func TestLFSDeployKeyToken(t *testing.T) { | |||
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | readOnly := lfsTestKey(t, st, u.ID, "SHA256:deploy-ro", ro) | 210 | 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" { |
| 212 | t.Errorf("read-only deploy key download: %q", op) | 212 | t.Errorf("read-only deploy key download: %q", op) |
| 213 | } | 213 | } |
| 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 != "" { |
| 215 | t.Errorf("read-only deploy key upload: %q", op) | 215 | t.Errorf("read-only deploy key upload: %q", op) |
| 216 | } | 216 | } |
| 217 | 217 | ||
| 218 | if err := st.SetUserDisabled(u.ID, true); err != nil { | 218 | if err := st.SetUserDisabled(u.ID, true); err != nil { |
| 219 | t.Fatal(err) | 219 | t.Fatal(err) |
| 220 | } | 220 | } |
| 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 != "" { |
| 222 | t.Errorf("deploy key of a disabled account: %q", op) | 222 | t.Errorf("deploy key of a disabled account: %q", op) |
| 223 | } | 223 | } |
| 224 | } | 224 | } |
| @@ -234,7 +234,7 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | |||
| 234 | } | 234 | } |
| 235 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") | 235 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") |
| 236 | now := time.Now() | 236 | 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) |
| 238 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | 238 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { |
| 239 | t.Fatalf("upload before archiving: %q", op) | 239 | t.Fatalf("upload before archiving: %q", op) |
| 240 | } | 240 | } |
| @@ -248,7 +248,35 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | |||
| 248 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { | 248 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { |
| 249 | t.Errorf("upload after archiving: %q", op) | 249 | t.Errorf("upload after archiving: %q", op) |
| 250 | } | 250 | } |
| 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" { |
| 252 | t.Errorf("download after archiving: %q", op) | 252 | t.Errorf("download after archiving: %q", op) |
| 253 | } | 253 | } |
| 254 | } | 254 | } |
| 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). | ||
| 259 | func 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 | |||
| 129 | 129 | ||
| 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound | 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 | 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an |
| 132 | // anonymous download of a public repository. | 132 | // anonymous download of a public repository. fingerprint is that key's |
| 133 | func Sign(secret []byte, repoID, keyID int64, op string, now time.Time) string { | 133 | // fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so |
| 134 | payload := fmt.Sprintf("%d:%d:%s:%d", repoID, keyID, op, now.Add(TokenTTL).Unix()) | 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). | ||
| 136 | func 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()) | ||
| 135 | mac := hmac.New(sha256.New, secret) | 138 | mac := hmac.New(sha256.New, secret) |
| 136 | mac.Write([]byte(payload)) | 139 | mac.Write([]byte(payload)) |
| 137 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + | 140 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + |
| 138 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) | 141 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) |
| 139 | } | 142 | } |
| 140 | 143 | ||
| 144 | // KeyPin is the fingerprint's form in a token: the first 16 hex | ||
| 145 | // characters of its SHA-256, or "" for no key. | ||
| 146 | func KeyPin(fingerprint string) string { | ||
| 147 | if fingerprint == "" { | ||
| 148 | return "" | ||
| 149 | } | ||
| 150 | sum := sha256.Sum256([]byte(fingerprint)) | ||
| 151 | return hex.EncodeToString(sum[:8]) | ||
| 152 | } | ||
| 153 | |||
| 141 | // Grant is what a verified token authorizes. | 154 | // Grant is what a verified token authorizes. |
| 142 | type Grant struct { | 155 | type Grant struct { |
| 143 | RepoID int64 | 156 | 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 | ||
| 145 | Op string | 159 | Op string |
| 146 | } | 160 | } |
| 147 | 161 | ||
| 148 | // Verify checks a token's MAC, shape and expiry. A token from before | 162 | // 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. | ||
| 150 | func Verify(secret []byte, token string, now time.Time) (Grant, bool) { | 165 | func Verify(secret []byte, token string, now time.Time) (Grant, bool) { |
| 151 | payloadB64, macB64, found := strings.Cut(token, ".") | 166 | payloadB64, macB64, found := strings.Cut(token, ".") |
| 152 | if !found { | 167 | if !found { |
| @@ -166,19 +181,22 @@ func Verify(secret []byte, token string, now time.Time) (Grant, bool) { | |||
| 166 | return Grant{}, false | 181 | return Grant{}, false |
| 167 | } | 182 | } |
| 168 | parts := strings.Split(string(payload), ":") | 183 | parts := strings.Split(string(payload), ":") |
| 169 | if len(parts) != 4 { | 184 | if len(parts) != 5 { |
| 170 | return Grant{}, false | 185 | return Grant{}, false |
| 171 | } | 186 | } |
| 172 | repoID, err1 := strconv.ParseInt(parts[0], 10, 64) | 187 | repoID, err1 := strconv.ParseInt(parts[0], 10, 64) |
| 173 | keyID, err2 := strconv.ParseInt(parts[1], 10, 64) | 188 | 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) |
| 175 | if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { | 190 | if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { |
| 176 | return Grant{}, false | 191 | return Grant{}, false |
| 177 | } | 192 | } |
| 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" { | ||
| 179 | return Grant{}, false | 197 | return Grant{}, false |
| 180 | } | 198 | } |
| 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 |
| 182 | } | 200 | } |
| 183 | 201 | ||
| 184 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. | 202 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. |
internal/lfs/lfs_test.go +17 −3
| @@ -12,9 +12,9 @@ import ( | |||
| 12 | func TestTokenCarriesTheKey(t *testing.T) { | 12 | func TestTokenCarriesTheKey(t *testing.T) { |
| 13 | secret := []byte("secret") | 13 | secret := []byte("secret") |
| 14 | now := time.Now() | 14 | now := time.Now() |
| 15 | tok := Sign(secret, 7, 42, "upload", now) | 15 | tok := Sign(secret, 7, 42, "SHA256:k", "upload", now) |
| 16 | g, ok := Verify(secret, tok, now) | 16 | 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"}) { |
| 18 | t.Fatalf("Verify = %+v, %v", g, ok) | 18 | t.Fatalf("Verify = %+v, %v", g, ok) |
| 19 | } | 19 | } |
| 20 | if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok { | 20 | if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok { |
| @@ -23,7 +23,7 @@ func TestTokenCarriesTheKey(t *testing.T) { | |||
| 23 | if _, ok := Verify([]byte("other"), tok, now); ok { | 23 | if _, ok := Verify([]byte("other"), tok, now); ok { |
| 24 | t.Error("a token verified under another secret") | 24 | t.Error("a token verified under another secret") |
| 25 | } | 25 | } |
| 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 != "" { |
| 27 | t.Errorf("anonymous grant = %+v, %v", g, ok) | 27 | t.Errorf("anonymous grant = %+v, %v", g, ok) |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
| @@ -41,3 +41,17 @@ func TestUnboundTokenRefused(t *testing.T) { | |||
| 41 | t.Fatalf("a pre-upgrade token verified: %+v", g) | 41 | t.Fatalf("a pre-upgrade token verified: %+v", g) |
| 42 | } | 42 | } |
| 43 | } | 43 | } |
| 44 | |||
| 45 | // A token minted before tokens carried the key's fingerprint has four | ||
| 46 | // fields. It is refused (#303). | ||
| 47 | func 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 | |||
| 69 | fmt.Fprintln(stderr, "internal error") | 69 | fmt.Fprintln(stderr, "internal error") |
| 70 | return protocol.ExitFailure | 70 | return protocol.ExitFailure |
| 71 | } | 71 | } |
| 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()) |
| 73 | json.NewEncoder(stdout).Encode(map[string]any{ | 73 | json.NewEncoder(stdout).Encode(map[string]any{ |
| 74 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", | 74 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", |
| 75 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), | 75 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), |