@jpgilldev / steid

steid/plans/decisions/0007-tokens-over-http-basic.md
4.2 KBRaw
1# 0007 — Personal access tokens over HTTP Basic, with a uniform 401
2
3**Status:** accepted · **Date:** 2026-08-28
4
5## Context
6
7Milestone 4a serves `git clone` for public repositories to anonymous callers.
8Everything else the git transport should eventually do — pushing, and cloning a private
9repository — needs to know who is asking.
10[0001](0001-git-over-http-not-ssh.md) already chose the credential: personal access
11tokens over HTTP Basic. What it did not settle is how they are stored, what they are
12allowed to do, and — the one with a real cost either way — what an unauthenticated
13request is told.
14
15That last question is forced by the client. **Git only sends credentials after a 401.**
16Milestone 4a answers 404 for a private repository, matching `view_repo`'s rule that an
17invisible repository is absent rather than forbidden. Keep that, and an authenticated
18private clone is impossible: the client is told the repository does not exist and never
19offers a credential.
20
21## Decision
22
23**Tokens are SHA-256 hashed, carry no scope, and any git request that is not
24anonymously readable answers 401 — whether or not the repository exists.**
25
26- **Hashing: SHA-256, with a display prefix stored alongside.** Mirrors
27 `SessionTokenHash`. A token is 256 bits from the OS random source, so there is no
28 dictionary for a slow hash to defend against, and this credential is verified on every
29 request of a clone — several per `git clone`. Argon2 is the right answer for a
30 human-chosen password and the wrong one here. The first eight characters are kept in
31 the clear so a management UI can name a token it can no longer show.
32- **No scopes.** A token acts as the user who issued it. Personal-first means one
33 person's own credential; narrowing it is a change to make when something needs it,
34 behind an unchanged port.
35- **A uniform 401.** Any git path the caller may not read anonymously answers `401` with
36 `WWW-Authenticate: Basic`, including repositories that do not exist. Nothing in the
37 response distinguishes "private" from "absent".
38
39## Alternatives considered
40
41- **401 only for repositories that exist**, 404 otherwise. No spurious credential prompt
42 on a typo. Rejected because the pair of responses is itself the leak: probing tells an
43 attacker which private repository names exist, which is the thing private visibility is
44 protecting. The prompt-on-typo cost is a nuisance; the leak is a defect.
45- **Keep 404 and require credentials up front.** Leaks nothing, and git never prompts —
46 a private clone works only after configuring a credential helper. Rejected as hostile
47 to the ordinary case, and it makes the documented instructions longer than the feature.
48- **Argon2, reusing the existing `PasswordHasher` port.** One credential mechanism
49 instead of two. Rejected on cost: deliberately slow hashing on every request of every
50 clone, defending against a dictionary attack that cannot exist against 256 random bits.
51- **Read-only versus read-write scopes now.** Genuinely useful — a CI token that can
52 clone but not push. Rejected as a second authorization axis crossing the one that
53 already exists, designed against no requirement. Cheap to add later.
54
55## Consequences
56
57- **A typo'd clone URL prompts for a password before reporting `not found`.** The
58 accepted cost of the uniform 401, and the same behaviour GitHub has.
59- **The 404 rule now has an exception, and it is transport-shaped.** `view_repo` still
60 answers "absent" for a page; the git routes answer 401. Two rules for one question, so
61 the reason lives here rather than being rediscovered as an inconsistency.
62- **A lost token cannot be recovered**, only replaced. That is the point of storing a
63 hash, and the UI has to show the token exactly once and say so.
64- **Revocation is a delete.** A flag would mean every read has to remember to check it.
65- **Tokens authenticate; they do not authorize.** `serve_git` remains the one place that
66 decides what an actor may do, so a token widens who the actor is and changes nothing
67 about the rules.
68- **Reversible where it matters:** scopes can be added behind `TokenRepository`
69 unchanged. The 401 rule is the part that is expensive to revisit, because it is
70 observable behaviour that clients and instructions come to depend on.