git@cosmocr.at/gitweb.git @ main · summary refs log
main / docs / ARCHITECTURE.md /
docs/ARCHITECTURE.md · 15.7 KiB · raw
view: SourceMarkdown ☑ force

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

        ┌────────────────────┐        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)  │
                    └─────────┘

Trust model

The design avoids any shared secret between gitweb and vault:

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)>

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

vault