git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / docs / ARCHITECTURE.md /
docs/ARCHITECTURE.md · 15.7 KiB · raw
view: SourceMarkdown ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# gitweb architecture: clone, push, and passkey auth

This document describes how `gitweb` serves git repositories out of
[upspin](https://upspin.io) over HTTPS, how public/private access is decided,
and how a user authenticates for private repos and push — either with a
**passkey** (via the `vault` SSO issuer and a git credential helper) or a
**personal access token** (for CI). It complements the per-repo READMEs with the
cross-cutting picture and worked example flows.

## Contents

- [The pieces](#the-pieces)
- [Trust model](#trust-model)
- [How a repo is addressed and served](#how-a-repo-is-addressed-and-served)
- [Public vs private (Access files)](#public-vs-private-access-files)
- [The SSO token](#the-sso-token)
- [Flows](#flows)
  - [1. Anonymous clone of a public repo](#1-anonymous-clone-of-a-public-repo)
  - [2. Passkey login + clone of a private repo (loopback)](#2-passkey-login--clone-of-a-private-repo-loopback)
  - [3. Passkey login on a headless box (device code)](#3-passkey-login-on-a-headless-box-device-code)
  - [4. Push (receive-pack + write-back)](#4-push-receive-pack--write-back)
  - [5. CI with a personal access token](#5-ci-with-a-personal-access-token)
  - [6. Browsing (the web UI)](#6-browsing-the-web-ui)
- [Failure modes and limitations](#failure-modes-and-limitations)

## The pieces

```
        ┌────────────────────┐        passkey (WebAuthn) login
        │  browser           │◀───────────────────────────────┐
        └─────────┬──────────┘                                 │
                  │                                            │
  git clone/push  │ HTTPS Basic auth (token in password)       │ HTTPS
                  ▼                                            ▼
┌──────────┐   ┌──────────────────┐   verify token via   ┌──────────────────┐
│  git CLI │──▶│  gitweb           │   upspin keyserver   │  vault            │
│  + helper│   │  git.cosmocr.at   │─────────────────────▶│  vault.cosmocr.at │
└────┬─────┘   │  :8446            │   (issuer pubkey)     │  :8444            │
     │         │  user git@…       │                       │  user vault@…     │
     │ opens   └─────────┬─────────┘                       └─────────┬─────────┘
     │ browser           │ read/write repos                          │ mints
     │ to vault          ▼ as git@cosmocr.at                         │ signed
     │              ┌─────────┐                                      │ tokens
     └─────────────▶│ upspin  │◀─────────────────────────────────────┘
                    │ (files, │   reads issuer public key
                    │  keys)  │
                    └─────────┘
```

- **gitweb** (`git.cosmocr.at`, listens `:8446`, runs as upspin user
  `git@cosmocr.at`) — serves the browse UI and the git smart-HTTP protocol. It
  reads and writes repositories in upspin **as its own single identity**; there
  is no per-repo service account. It terminates its own TLS (nginx does SNI
  passthrough, so Basic auth and POST bodies reach it directly).
- **vault** (`vault.cosmocr.at`, listens `:8444`, runs as `vault@cosmocr.at`) —
  the passkey identity provider. It already maps a WebAuthn login to an upspin
  username; here it additionally **mints signed SSO tokens** for other services.
- **git credential helper** (`git-credential-cosmocr`) — a small binary on the
  user's workstation that git invokes automatically. It drives the browser
  passkey login against vault, caches the resulting token, and returns it to git
  as Basic-auth credentials. It is git's standard extension point — stock git,
  no add-ons.
- **upspin** — the storage and the **identity/keyserver**. Repositories live in
  users' upspin trees; access is expressed with upspin `Access` files; and every
  user (including `vault@cosmocr.at`) has a public key discoverable through the
  keyserver, which is what lets gitweb verify vault's token signatures.

## Trust model

The design avoids any shared secret between gitweb and vault:

- A user proves their upspin identity to **vault** with a passkey. (The passkey
  ↔ upspin-user binding was established at vault enrollment, where the user
  proved ownership of their upspin tree.)
- Vault mints a token asserting "user X, for audience gitweb, until time T",
  **signed with `vault@cosmocr.at`'s upspin private key**.
- **gitweb** trusts tokens signed by `vault@cosmocr.at`. It fetches that public
  key from the upspin keyserver and verifies the signature — no callback to
  vault, no shared database, no runtime coupling.
- Authorization for a specific repo is then a separate, orthogonal decision made
  by gitweb from the repo's upspin **Access file**.

Because the passkey ceremony always happens on vault's own origin
(`vault.cosmocr.at`), existing vault passkeys work unchanged and gitweb never
implements WebAuthn itself.

The **personal access token (PAT)** path has the same identity anchor by a
different route: the hash of the token lives in the user's *own* upspin tree
(`<user>/gitweb/tokens`), so possessing a matching token proves control of that
tree — i.e., of that upspin identity.

## How a repo is addressed and served

The URL path is the upspin path. A bare repo at upspin path
`u@example.com/proj.git` is served at
`https://git.cosmocr.at/u@example.com/proj.git`.

gitweb does not keep a repo index. On each request it walks path prefixes to
find the shallowest directory that is a bare repo (`HEAD` + `objects/`). To
serve it, gitweb **materializes** the whole bare repo out of upspin into an
in-memory filesystem and opens it with go-git; materialized repos are cached
(LRU, keyed by the upspin subtree `Sequence`, so any change invalidates them).

```mermaid
flowchart LR
    A["GET /u@e.com/proj.git/info/refs"] --> B["Locate: prefix walk<br/>find HEAD + objects/"]
    B --> C{"cached &&<br/>Sequence unchanged?"}
    C -- yes --> E["go-git storer"]
    C -- no --> D["materialize:<br/>copy subtree upspin→memfs"]
    D --> E
    E --> F["upload-pack / browse"]
```

## Public vs private (Access files)

Visibility is derived from the repo's **governing Access file** — the nearest
`Access` file at or above the repo directory (upspin's normal rule):

| Access grants | Anonymous | Authenticated user |
|---|---|---|
| `read: all` (world-readable) | **public**: browse + clone | browse + clone |
| readable only by named users | **private**: 401 challenge | clone if granted `read`; push if granted `write` |
| (no Access file anywhere) | denied | owner only |

Because gitweb reads/writes as `git@cosmocr.at`, a private repo's Access file
must grant that user `read` (and `create,write,delete` for push) in addition to
the human collaborators — otherwise gitweb itself cannot serve the repo.

gitweb evaluates this per request with `upspin.io/access` and caches parsed
Access files briefly. The same rule gates **both** the git protocol and the
browse UI, so a private repo is never visible in the browser to anonymous users.

## The SSO token

A vault-issued token is a compact, signed bearer credential:

```
v1.<base64url(payload)>.<base64url(sigR)>.<base64url(sigS)>
```

- `payload` is JSON: `{"iss","sub","aud","iat","exp"}` — issuer
  (`vault@cosmocr.at`), subject (the authenticated upspin user), audience
  (`gitweb`), issued-at, expiry (~12h).
- The signature is ECDSA over the ASCII string `v1.<base64url(payload)>`, made
  with vault's upspin key; `R` and `S` are separate base64url segments so the
  encoding is curve-agnostic.

Verification in gitweb (`internal/auth/vault.go`): decode claims → check
`iss`/`aud`/`exp` → look up `vault@cosmocr.at`'s public key via the keyserver
(cached) → `factotum.Verify`. On success the token's `sub` is the authenticated
user.

The token travels to gitweb in the **HTTP Basic auth password** field. gitweb
tells a vault token from a PAT by the `v1.` prefix.

## Flows

### 1. Anonymous clone of a public repo

No credentials; the Access file is world-readable.

```mermaid
sequenceDiagram
    participant G as git
    participant W as gitweb
    participant U as upspin
    G->>W: GET /u@e.com/pub.git/info/refs?service=git-upload-pack
    W->>U: read governing Access file
    U-->>W: read: all  (public)
    W-->>G: 200 ref advertisement
    G->>W: POST /u@e.com/pub.git/git-upload-pack (wants)
    W->>U: materialize repo (if not cached)
    W-->>G: 200 packfile
```

### 2. Passkey login + clone of a private repo (loopback)

The credential helper runs a browser passkey login against vault, gets a token,
and hands it to git. This is the primary interactive path.

```mermaid
sequenceDiagram
    autonumber
    participant G as git
    participant H as helper (git-credential-cosmocr)
    participant B as browser
    participant V as vault
    participant W as gitweb
    participant K as upspin keyserver

    G->>H: credential get (host=git.cosmocr.at)
    alt cached token still valid
        H-->>G: username+password (token)
    else needs login
        H->>H: start loopback listener 127.0.0.1:PORT
        H->>B: open /authorize?redirect_uri=127.0.0.1:PORT&state=…&aud=gitweb
        B->>V: GET /authorize
        V-->>B: passkey login page
        B->>V: POST /api/begin, /api/login/finish (WebAuthn)
        V-->>B: session cookie (sub = upspin user)
        B->>V: POST /api/authorize/grant {redirect_uri, state, aud}
        V->>V: mint token, sign with vault@cosmocr.at key
        V-->>B: { redirect: 127.0.0.1:PORT/cb?token=…&state=… }
        B->>H: GET /cb?token=…&state=…  (loopback)
        H->>H: verify state, cache token (0600)
        H-->>G: username(sub)+password(token)
    end
    G->>W: GET …/info/refs  (Authorization: Basic sub:token)
    W->>K: look up vault@cosmocr.at public key (cached)
    K-->>W: public key
    W->>W: verify token signature, aud, exp
    W->>W: Access file grants sub read? yes
    W-->>G: 200 advertisement → clone proceeds
```

If the token is later rejected (e.g. expired), git calls the helper's `erase`,
which drops the cached token so the next operation re-authenticates.

### 3. Passkey login on a headless box (device code)

When no browser can be opened (SSH/CI shell), the helper falls back to RFC
8628-style device flow. The user approves from a *different* device.

```mermaid
sequenceDiagram
    autonumber
    participant H as helper
    participant T as terminal
    participant V as vault
    participant P as phone/laptop browser

    H->>V: POST /api/device/code {aud}
    V-->>H: {device_code, user_code, verification_uri, interval}
    H->>T: "Visit vault.cosmocr.at/authorize and enter CODE-XXXX"
    loop until approved or expiry
        H->>V: POST /api/device/token {device_code}
        V-->>H: {status: pending}
    end
    P->>V: open /authorize, passkey login, enter user_code, approve
    V->>V: mint + sign token, attach to the device grant
    H->>V: POST /api/device/token {device_code}
    V-->>H: {status: approved, token}
    H->>H: cache token; return to git
```

### 4. Push (receive-pack + write-back)

Push requires an authenticated user granted `write`. gitweb runs receive-pack
against a **private** in-memory copy of the repo, then writes the changed files
back to upspin.

```mermaid
sequenceDiagram
    autonumber
    participant G as git
    participant W as gitweb
    participant U as upspin

    G->>W: POST …/git-receive-pack (Basic auth; packfile + ref updates)
    W->>W: verify token/PAT → user; Access grants write? yes
    W->>W: lock repo; seq0 = upspin Sequence
    W->>U: materialize private copy (memfs)
    W->>W: go-git receive-pack: unpack objects, update refs (in memfs)
    W->>W: diff manifest before/after → puts, deletes
    W->>U: re-read Sequence; == seq0?  (else abort, ask client to retry)
    W->>U: Put new objects/packfiles
    W->>U: Put changed refs (refs/**, packed-refs, HEAD)
    W->>U: Delete removed refs
    W->>W: invalidate read cache for this repo
    W-->>G: 200 report-status (per-ref ok/failed)
```

Write ordering is objects-first, refs-next, deletes-last, and each upspin `Put`
is atomic, so a concurrent reader always sees a consistent repo (at worst a few
extra unreachable objects, never a ref pointing at a missing object).

### 5. CI with a personal access token

No browser, no helper — just Basic auth with a pre-created token.

```mermaid
sequenceDiagram
    autonumber
    participant C as CI job (git)
    participant W as gitweb
    participant U as upspin
    Note over C: url = https://u%40e.com:TOKEN@git.cosmocr.at/u@e.com/proj.git
    C->>W: GET …/info/refs  (Basic u@e.com:TOKEN)
    W->>U: read u@e.com/gitweb/tokens
    U-->>W: sha256 hashes (granted read to git@cosmocr.at)
    W->>W: sha256(TOKEN) matches a hash? (constant-time) → user = u@e.com
    W->>W: Access check → serve
    W-->>C: 200
```

Setup once: `openssl rand -hex 32`, store its `sha256sum` in
`u@e.com/gitweb/tokens`, and grant `read: git@cosmocr.at` on that file. Revoke by
deleting the line.

### 6. Browsing (the web UI)

The browse UI is gated by the same rule as clone. An anonymous request for a
private path gets a 401 Basic-auth challenge (so a browser prompts and the same
tokens work); an authenticated-but-unauthorized request gets a 404 so existence
is not revealed.

```mermaid
flowchart TD
    A["GET /u@e.com/… (browser)"] --> B["authenticate: token/PAT or anonymous"]
    B --> C["Locate repo / resolve path"]
    C --> D{"CanRead(user, path)?"}
    D -- yes --> E["render page / listing"]
    D -- "no, anonymous" --> F["401 + WWW-Authenticate: Basic"]
    D -- "no, authenticated" --> G["404 not found"]
```

## Failure modes and limitations

| Situation | Behavior |
|---|---|
| Anonymous access to a private repo | 401 challenge (browser/git prompts for credentials) |
| Authenticated user lacks read | 404 (existence not revealed) |
| Authenticated reader tries to push | 403 forbidden |
| Token expired / invalid signature / wrong audience | rejected; git `erase` clears the cached token, next op re-logs-in |
| Concurrent out-of-band change during push | Sequence mismatch → push aborted before any write; client retries |
| Two pushes to one repo | serialized by a per-repo lock |
| Repo or push over 256 MiB | rejected |
| `git clone --depth N` (shallow) | unsupported by the go-git server; clone fully |
| Incremental `fetch` | server ignores client "have" lines → re-sends full history (correct, wasteful) |
| Push atomicity | non-atomic across refs; each ref updates independently |
| vault has no signer (dev mode) | SSO endpoints disabled; only PATs work |
| rotating `vault@cosmocr.at`'s key | outstanding SSO tokens stop verifying (acceptable given ~12h TTL) |

## Where the code lives

**gitweb**
- `internal/store` — upspin (and dev local) read/write.
- `internal/gitrepo` — materialize, cache, and push write-back.
- `internal/githttp` — git smart-HTTP wire protocol over go-git sessions.
- `internal/auth` — Access-file authorization, PAT and vault-token verification.
- `internal/server` — routing, browse UI, git endpoints, auth gating.
- `cmd/git-credential-cosmocr` — the passkey/device credential helper.

**vault**
- `internal/vault/token.go` — SSO token minting (signed with the upspin key).
- `internal/vault/authorize.go`, `device.go` — the loopback and device flows.
- `web/authorize.html`, `web/authorize.js` — the passkey-login + approve page.