Commit bd5cf5d7d1
Verified · cmc
Layout: unified · split
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +6 −3
| @@ -109,9 +109,12 @@ no HTTP write path (=internal/httpd/smart.go=, | |||
| 109 | ** F. LFS | 109 | ** F. LFS |
| 110 | 110 | ||
| 111 | =git-lfs-authenticate= over SSH applies the same repository checks as | 111 | =git-lfs-authenticate= over SSH applies the same repository checks as |
| 112 | git transport and returns a one-hour HMAC token scoped to repository | 112 | git transport and returns a one-hour HMAC token scoped to repository, |
| 113 | and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The | 113 | operation and the SSH key that asked for it, deploy keys included |
| 114 | HTTP batch, upload and download endpoints verify that token; public | 114 | (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The HTTP batch, upload |
| 115 | and download endpoints verify that token and that its key is still | ||
| 116 | registered, unexpired and on an enabled account (=store.LiveSSHKeys=), | ||
| 117 | and repeat the repository check for the key on each request; public | ||
| 115 | repositories allow anonymous download. Objects are verified against | 118 | repositories allow anonymous download. Objects are verified against |
| 116 | their SHA-256 id on upload. | 119 | their SHA-256 id on upload. |
| 117 | 120 | ||
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1 −1
| @@ -22,7 +22,7 @@ | |||
| 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | | 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | |
| 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | | 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | |
| 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | | 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | |
| 25 | | LFS transfer token | HMAC-SHA256 over repo, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | expiry only | | 25 | | LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs | |
| 26 | 26 | ||
| 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, | 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, |
| 28 | =HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=, | 28 | =HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=, |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -25,7 +25,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 25 | | Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | | 25 | | Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) | |
| 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | | 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | |
| 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | | 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | |
| 28 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) | | 28 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.go=) | |
| 29 | | Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) | | 29 | | Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) | |
| 30 | 30 | ||
| 31 | ** Access control (V4) | 31 | ** Access control (V4) |
.gitbay/wiki/Threat-Model.org +3 −1
| @@ -45,7 +45,9 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite | |||
| 45 | re-reads its key. Removing a key, removing a deploy key, disabling or | 45 | re-reads its key. Removing a key, removing a deploy key, disabling or |
| 46 | deleting an account closes the connections the affected keys opened: | 46 | deleting an account closes the connections the affected keys opened: |
| 47 | a git transport is killed with its children, and a push killed before | 47 | a git transport is killed with its children, and a push killed before |
| 48 | its pre-receive hook answers moves no ref. A control command already | 48 | its pre-receive hook answers moves no ref. |
| 49 | An LFS transfer token names the key that obtained it and is refused | ||
| 50 | from the moment that key is. A control command already | ||
| 49 | inside its database write finishes it; its output is lost. A | 51 | inside its database write finishes it; its output is lost. A |
| 50 | revocation made by =gitbayd admin= on the host, another process, is | 52 | revocation made by =gitbayd admin= on the host, another process, is |
| 51 | found within 15 seconds. In =ssh.mode = "system"= each exec is its | 53 | found within 15 seconds. In =ssh.mode = "system"= each exec is its |
CHANGELOG.org +12
| @@ -265,6 +265,18 @@ missing, =gitbayd admin backup --verify <archive>= names it, and | |||
| 265 | - Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take | 265 | - Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take |
| 266 | a pack slot, answer 503 with =Retry-After: 30= when none is free, and | 266 | a pack slot, answer 503 with =Retry-After: 30= when none is free, and |
| 267 | are killed when the client leaves or stops reading (#262). | 267 | are killed when the client leaves or stops reading (#262). |
| 268 | - LFS transfer tokens name the SSH key that obtained them, deploy keys | ||
| 269 | included, and every LFS request checks that the key is still | ||
| 270 | registered, unexpired and on an enabled account: removing a key or | ||
| 271 | disabling an account ends its tokens at once instead of within the | ||
| 272 | hour (#285). *Operators:* tokens minted before the upgrade are | ||
| 273 | refused. git-lfs asks =git-lfs-authenticate= for a token each time | ||
| 274 | it runs, so only a transfer running across the restart fails, with | ||
| 275 | "repository not found"; running the command again fixes it. | ||
| 276 | - Every LFS request also repeats the repository check for the token's | ||
| 277 | key: a collaborator whose access is revoked or reduced, or a reader | ||
| 278 | of a public repository made private, loses the token's use with the | ||
| 279 | access (#285). | ||
| 268 | 280 | ||
| 269 | * v1.36.0 — 2026-09-23 | 281 | * v1.36.0 — 2026-09-23 |
| 270 | 282 | ||
e2e/lfs_test.go +54
| @@ -170,3 +170,57 @@ func TestLFS(t *testing.T) { | |||
| 170 | t.Fatal("corrupt object was stored") | 170 | t.Fatal("corrupt object was stored") |
| 171 | } | 171 | } |
| 172 | } | 172 | } |
| 173 | |||
| 174 | // A transfer token works only while the key that obtained it does: | ||
| 175 | // removing the key ends it before its hour is up (#285). No git-lfs | ||
| 176 | // client needed: the token comes from git-lfs-authenticate over SSH. | ||
| 177 | func TestLFSTokenEndsWithItsKey(t *testing.T) { | ||
| 178 | t.Parallel() | ||
| 179 | inst := startInstance(t) | ||
| 180 | aliceKey := inst.newKey(t, "alice") | ||
| 181 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 182 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 { | ||
| 183 | t.Fatalf("repo create: %s", errOut) | ||
| 184 | } | ||
| 185 | spare := inst.newKey(t, "spare") | ||
| 186 | pub, err := os.ReadFile(spare + ".pub") | ||
| 187 | if err != nil { | ||
| 188 | t.Fatal(err) | ||
| 189 | } | ||
| 190 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 { | ||
| 191 | t.Fatalf("keys add: %s", errOut) | ||
| 192 | } | ||
| 193 | out, errOut, code := inst.ssh(t, spare, "", "git-lfs-authenticate", "alice/vault", "download") | ||
| 194 | if code != 0 { | ||
| 195 | t.Fatalf("authenticate: %s", errOut) | ||
| 196 | } | ||
| 197 | var grant struct { | ||
| 198 | Header map[string]string `json:"header"` | ||
| 199 | } | ||
| 200 | if err := json.Unmarshal([]byte(out), &grant); err != nil { | ||
| 201 | t.Fatalf("authenticate JSON: %v\n%s", err, out) | ||
| 202 | } | ||
| 203 | batch := func() int { | ||
| 204 | body := fmt.Sprintf(`{"operation":"download","transfers":["basic"],"objects":[{"oid":%q,"size":4}]}`, strings.Repeat("ab", 32)) | ||
| 205 | req, _ := http.NewRequest("POST", | ||
| 206 | fmt.Sprintf("http://127.0.0.1:%d/alice/vault.git/info/lfs/objects/batch", inst.httpPort), | ||
| 207 | strings.NewReader(body)) | ||
| 208 | req.Header.Set("Content-Type", "application/vnd.git-lfs+json") | ||
| 209 | req.Header.Set("Authorization", grant.Header["Authorization"]) | ||
| 210 | resp, err := http.DefaultClient.Do(req) | ||
| 211 | if err != nil { | ||
| 212 | t.Fatal(err) | ||
| 213 | } | ||
| 214 | resp.Body.Close() | ||
| 215 | return resp.StatusCode | ||
| 216 | } | ||
| 217 | if code := batch(); code != 200 { | ||
| 218 | t.Fatalf("batch with a live key: %d", code) | ||
| 219 | } | ||
| 220 | if _, errOut, code := inst.ssh(t, aliceKey, "", "keys", "remove", fingerprint(t, spare+".pub")); code != 0 { | ||
| 221 | t.Fatalf("keys remove: %s", errOut) | ||
| 222 | } | ||
| 223 | if code := batch(); code != 404 { | ||
| 224 | t.Fatalf("batch after the key was removed: %d, want 404", code) | ||
| 225 | } | ||
| 226 | } | ||