# 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 (`/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
find HEAD + objects/"] B --> C{"cached &&
Sequence unchanged?"} C -- yes --> E["go-git storer"] C -- no --> D["materialize:
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... ``` - `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.`, 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.