gitweb architecture: clone, push, and passkey auth
This document describes how gitweb serves git repositories out of
upspin 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
- Trust model
- How a repo is addressed and served
- Public vs private (Access files)
- The SSO token
- Flows
- 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 usergit@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 asvault@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
Accessfiles; and every user (includingvault@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).
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)>
payloadis 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;RandSare 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.
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.
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.
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.
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.
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.
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.