steid

@jamesgill /

feat: personal access tokens, as a domain type

The credential itself, plus ADR 0007 recording the three decisions behind it.

SHA-256 rather than Argon2: a token is 256 bits from the OS random source, so
there is no dictionary for a slow hash to defend against, and this credential is
verified on every request of a clone. Argon2 is the right answer for a
human-chosen password and the wrong one here.

Eight characters are kept in the clear so a management UI can name a token it
can no longer show. TokenSecret exists only long enough to be shown once, and
both it and the hash have hand-written Debug impls that redact — a hash in a log
is a hash an attacker can compare against.

No scopes. A token acts as the user who issued it, which is what personal-first
means, and a second authorization axis designed against no requirement is worth
less than the room to add one later.

The decision with a real cost is in the ADR rather than here: a uniform 401 on
anything not anonymously readable, including repositories that do not exist,
because git only sends credentials after a 401 and answering 404 for a private
repo makes authenticated clone impossible. Answering 401 only for repos that
exist would make the response pair itself the leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparente0856ebBrowse files86085749f57331eddaebe044fdcdbc87ac3da1cd

9 files changed+454 −21

Cargo.lock+1 −0View file
@@ -1896,6 +1896,7 @@ dependencies = [
18961896 "http-body",
18971897 "rand 0.10.2",
18981898 "serde",
1899+ "sha2 0.10.9",
18991900 "sqlx",
19001901 "subtle",
19011902 "tempfile",
Cargo.toml+1 −0View file
@@ -12,6 +12,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
1212 http-body = "1"
1313 rand = "0.10.2"
1414 serde = { version = "1.0.229", features = ["derive"] }
15+sha2 = "0.10"
1516 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1617 subtle = "2.6.1"
1718 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs", "io-util"] }
plans/current.md+16 −20View file
@@ -15,9 +15,7 @@ and anything to do with browsing a tree (Milestone 5).
1515
1616 ### Steps
1717
18Provisional below the first two — the rest depend on the Open decisions.
19
20- [ ] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
18+- [x] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
2119 - [ ] Infrastructure: in-memory + SQLite implementations, migration
2220 - [ ] Application: `issue_token`, `list_tokens`, `revoke_token`
2321 - [ ] Application: `authenticate_token` — resolves a Basic credential into an `Actor`
@@ -35,25 +33,23 @@ write, and lets `git clone` succeed against a private repository its owner may r
3533 Revoking the token stops both. An anonymous clone of a public repository still works
3634 exactly as it does today.
3735
36+### Settled
37+
38+All three of this milestone's open decisions are answered in
39+[0007](decisions/0007-tokens-over-http-basic.md): SHA-256 with a stored display prefix,
40+no scopes, and a **uniform 401** on any git path not anonymously readable — including
41+repositories that do not exist, so nothing distinguishes "private" from "absent".
42+
43+- **Revocation is a delete, not a flag.** A revoked row that lingers is a credential
44+ that stops working only as long as every read remembers to check the flag.
45+- **Tokens authenticate; they do not authorize.** A token widens who the actor is;
46+ `serve_git` still decides what that actor may do.
47+- **`TokenRepository` looks up by hash**, because that is the lookup authentication
48+ actually performs — a client presents a token, never an id.
49+
3850 ### Open
3951
40These are decisions, not unknowns — each needs an answer before the step that depends on
41it.
42
43- **How tokens are hashed.** Sessions already hash their token with SHA-256
44 (`SessionTokenHash`), which suits a high-entropy random value; Argon2 would be the
45 password answer and is far too slow for something presented on every git request, of
46 which a single clone makes several. Recommendation: copy the session approach, store a
47 display prefix alongside so the UI can name a token without holding it.
48- **Whether tokens carry scopes.** Personal-first says no: a token acts as its user.
49 Scopes are the kind of thing that is cheap to add later behind an unchanged port and
50 expensive to design against no requirement.
51- **401 versus 404 for a private repository.** Carried from 4a and now decidable. A git
52 client only sends credentials *after* a 401, so answering 404 to an anonymous request
53 for a private repository — which is what 4a does, and what `view_repo` does — makes
54 authenticated private clone impossible. Answering 401 leaks that the repository
55 exists. Gitea and GitHub both accept that leak. This is the one with a real cost
56 either way.
52+Nothing open.
5753
5854 ### Carried over — small, unblocked
5955
plans/decisions/0007-tokens-over-http-basic.md+70 −0View file
@@ -0,0 +1,70 @@
1+# 0007 — Personal access tokens over HTTP Basic, with a uniform 401
2+
3+**Status:** accepted · **Date:** 2026-08-28
4+
5+## Context
6+
7+Milestone 4a serves `git clone` for public repositories to anonymous callers.
8+Everything else the git transport should eventually do — pushing, and cloning a private
9+repository — needs to know who is asking.
10+[0001](0001-git-over-http-not-ssh.md) already chose the credential: personal access
11+tokens over HTTP Basic. What it did not settle is how they are stored, what they are
12+allowed to do, and — the one with a real cost either way — what an unauthenticated
13+request is told.
14+
15+That last question is forced by the client. **Git only sends credentials after a 401.**
16+Milestone 4a answers 404 for a private repository, matching `view_repo`'s rule that an
17+invisible repository is absent rather than forbidden. Keep that, and an authenticated
18+private clone is impossible: the client is told the repository does not exist and never
19+offers a credential.
20+
21+## Decision
22+
23+**Tokens are SHA-256 hashed, carry no scope, and any git request that is not
24+anonymously 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.
src/domain/id.rs+5 −0View file
@@ -76,3 +76,8 @@ mod tests {
7676 assert_eq!(id.to_string(), "user-1");
7777 }
7878 }
79+
80+typed_id!(
81+ /// Identifies a [`PersonalAccessToken`](crate::domain::PersonalAccessToken).
82+ TokenId
83+);
src/domain/mod.rs+3 −1View file
@@ -14,16 +14,18 @@ pub mod repo;
1414 pub mod repository;
1515 pub mod session;
1616 pub mod setup_token;
17+pub mod token;
1718 pub mod user;
1819
1920 pub use actor::Actor;
2021 pub use email::Email;
2122 pub use error::DomainError;
22pub use id::{MembershipId, OrgId, RepoId, UserId};
23+pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2324 pub use membership::{Membership, Role};
2425 pub use org::{OrgName, Organization};
2526 pub use password::PasswordHash;
2627 pub use repo::{RepoName, Repository, Visibility};
2728 pub use session::{Session, SessionTokenHash};
2829 pub use setup_token::SetupToken;
30+pub use token::{PersonalAccessToken, TokenHash, TokenSecret};
2931 pub use user::User;
src/domain/repository/mod.rs+2 −0View file
@@ -8,12 +8,14 @@ pub mod membership_repo;
88 pub mod org_repo;
99 pub mod repo_repo;
1010 pub mod session_repo;
11+pub mod token_repo;
1112 pub mod user_repo;
1213
1314 pub use membership_repo::MembershipRepository;
1415 pub use org_repo::OrgRepository;
1516 pub use repo_repo::RepoRepository;
1617 pub use session_repo::SessionRepository;
18+pub use token_repo::TokenRepository;
1719 pub use user_repo::UserRepository;
1820
1921 /// What a repository can fail with.
src/domain/repository/token_repo.rs+34 −0View file
@@ -0,0 +1,34 @@
1+use super::RepositoryResult;
2+use crate::domain::{PersonalAccessToken, TokenHash, TokenId, UserId};
3+
4+/// Persistence for [`PersonalAccessToken`].
5+pub trait TokenRepository: Send + Sync {
6+ /// Looks a token up by the hash of what a client presented.
7+ ///
8+ /// By hash rather than by id, because a client presents the token itself and never
9+ /// its id — this is the lookup that authentication actually performs, so it is the
10+ /// one the storage layer has to make fast.
11+ fn find_by_hash(
12+ &self,
13+ hash: &TokenHash,
14+ ) -> impl Future<Output = RepositoryResult<Option<PersonalAccessToken>>> + Send;
15+
16+ /// Every token a user holds, newest first.
17+ fn list_by_user(
18+ &self,
19+ user_id: &UserId,
20+ ) -> impl Future<Output = RepositoryResult<Vec<PersonalAccessToken>>> + Send;
21+
22+ /// Inserts or replaces a token.
23+ fn save(
24+ &self,
25+ token: &PersonalAccessToken,
26+ ) -> impl Future<Output = RepositoryResult<()>> + Send;
27+
28+ /// Deletes a token, succeeding if there was nothing to delete.
29+ ///
30+ /// Revocation is a delete rather than a flag: a revoked token that lingers in the
31+ /// table is a credential that stops working only as long as every read remembers to
32+ /// check the flag. Nothing needs the history.
33+ fn delete(&self, id: &TokenId) -> impl Future<Output = RepositoryResult<()>> + Send;
34+}
src/domain/token.rs+322 −0View file
@@ -0,0 +1,322 @@
1+//! Personal access tokens — the credential a git client presents over HTTP Basic.
2+
3+use std::{fmt, time::SystemTime};
4+
5+use sha2::{Digest, Sha256};
6+use subtle::ConstantTimeEq;
7+
8+use super::{DomainError, TokenId, UserId};
9+
10+/// The prefix every Steid token carries.
11+///
12+/// Present so a leaked token is recognisable as one — secret scanners key off exactly
13+/// this — and so a value pasted into the wrong field is obviously a Steid credential.
14+const TOKEN_PREFIX: &str = "steid_pat_";
15+
16+/// How much of a token is kept in the clear to identify it afterwards.
17+///
18+/// Enough for a person to tell two tokens apart in a list, far short of enough to
19+/// guess the rest: the remaining 56 hex characters are still 224 bits.
20+const DISPLAY_CHARS: usize = 8;
21+
22+/// A token in the clear.
23+///
24+/// Exists for exactly as long as it takes to show it to whoever asked for it. Nothing
25+/// stores this — the database holds only a [`TokenHash`] — so a token that is lost is
26+/// gone, which is the property that makes the hash worth keeping.
27+#[derive(Clone)]
28+pub struct TokenSecret(String);
29+
30+impl TokenSecret {
31+ /// Bytes of entropy, matching [`SetupToken`](super::SetupToken).
32+ const BYTES: usize = 32;
33+
34+ /// Mints a fresh token from the OS random source.
35+ pub fn generate() -> Self {
36+ use rand::Rng;
37+
38+ let mut bytes = [0u8; Self::BYTES];
39+ rand::rng().fill_bytes(&mut bytes);
40+
41+ let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
42+
43+ Self(format!("{TOKEN_PREFIX}{random}"))
44+ }
45+
46+ /// Wraps a value presented by a client, which may be anything at all.
47+ pub fn from_presented(value: impl Into<String>) -> Self {
48+ Self(value.into())
49+ }
50+
51+ /// The token's hash, as stored.
52+ pub fn hash(&self) -> TokenHash {
53+ TokenHash::of(&self.0)
54+ }
55+
56+ /// The leading characters kept in the clear, for naming the token in a list.
57+ ///
58+ /// Empty for a value that is not a Steid token at all, which only ever happens for
59+ /// something a client presented — and nothing displays those.
60+ pub fn display_prefix(&self) -> String {
61+ self.0
62+ .strip_prefix(TOKEN_PREFIX)
63+ .unwrap_or_default()
64+ .chars()
65+ .take(DISPLAY_CHARS)
66+ .collect()
67+ }
68+
69+ /// The token itself, to be shown exactly once.
70+ pub fn reveal(&self) -> &str {
71+ &self.0
72+ }
73+}
74+
75+/// Redacted, so a token cannot reach a log line except through [`reveal`](Self::reveal).
76+impl fmt::Debug for TokenSecret {
77+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78+ f.write_str("TokenSecret(redacted)")
79+ }
80+}
81+
82+/// The SHA-256 of a token, hex-encoded.
83+///
84+/// SHA-256 rather than Argon2 deliberately: a token is 256 bits from the OS random
85+/// source, so there is no dictionary to slow an attacker down through, and this
86+/// credential is verified on every request of every clone. See
87+/// [0007](../../plans/decisions/0007-tokens-over-http-basic.md).
88+#[derive(Clone, PartialEq, Eq, Hash)]
89+pub struct TokenHash(String);
90+
91+impl TokenHash {
92+ /// Hashes a token.
93+ pub fn of(plaintext: &str) -> Self {
94+ let digest = Sha256::digest(plaintext.as_bytes());
95+
96+ Self(digest.iter().map(|byte| format!("{byte:02x}")).collect())
97+ }
98+
99+ /// Wraps an already-computed hash, typically one loaded from the database.
100+ pub fn from_trusted(value: impl Into<String>) -> Self {
101+ Self(value.into())
102+ }
103+
104+ pub fn as_str(&self) -> &str {
105+ &self.0
106+ }
107+
108+ /// Whether two hashes are the same, compared in constant time.
109+ ///
110+ /// Belt and braces: recovering a token from a timing signal on its *hash* would
111+ /// need a preimage attack. It costs nothing, and the day this is compared against
112+ /// something that is secret, the habit is already in place.
113+ pub fn matches(&self, other: &Self) -> bool {
114+ self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
115+ }
116+}
117+
118+/// Redacted for the same reason as [`TokenSecret`]: a hash in a log is a hash an
119+/// attacker can compare against.
120+impl fmt::Debug for TokenHash {
121+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122+ f.write_str("TokenHash(redacted)")
123+ }
124+}
125+
126+/// A personal access token, as recorded.
127+///
128+/// Carries no scope. A token acts as the user who issued it, which is what
129+/// personal-first means; narrowing that is a change to make when something actually
130+/// needs it, behind an unchanged port.
131+#[derive(Debug, Clone, PartialEq, Eq)]
132+pub struct PersonalAccessToken {
133+ pub id: TokenId,
134+ pub user_id: UserId,
135+ /// What the person called it, so they can tell which one to revoke.
136+ pub name: String,
137+ /// The leading characters of the token, kept so a list can name one it cannot show.
138+ pub prefix: String,
139+ pub token_hash: TokenHash,
140+ pub created_at: SystemTime,
141+}
142+
143+impl PersonalAccessToken {
144+ /// Longer than any label a person will type, short enough to bound a row.
145+ const MAX_NAME: usize = 100;
146+
147+ /// Validates a new token record.
148+ pub fn new(
149+ id: TokenId,
150+ user_id: UserId,
151+ name: impl AsRef<str>,
152+ secret: &TokenSecret,
153+ created_at: SystemTime,
154+ ) -> Result<Self, DomainError> {
155+ let name = name.as_ref().trim();
156+
157+ if name.is_empty() {
158+ return Err(DomainError::validation(
159+ "name",
160+ "give the token a name so you can tell it apart later",
161+ ));
162+ }
163+
164+ if name.chars().count() > Self::MAX_NAME {
165+ return Err(DomainError::validation(
166+ "name",
167+ format!("a token name is at most {} characters", Self::MAX_NAME),
168+ ));
169+ }
170+
171+ Ok(Self {
172+ id,
173+ user_id,
174+ name: name.to_owned(),
175+ prefix: secret.display_prefix(),
176+ token_hash: secret.hash(),
177+ created_at,
178+ })
179+ }
180+
181+ /// Rebuilds a record from storage without revalidating it.
182+ pub fn from_trusted(
183+ id: TokenId,
184+ user_id: UserId,
185+ name: impl Into<String>,
186+ prefix: impl Into<String>,
187+ token_hash: TokenHash,
188+ created_at: SystemTime,
189+ ) -> Self {
190+ Self {
191+ id,
192+ user_id,
193+ name: name.into(),
194+ prefix: prefix.into(),
195+ token_hash,
196+ created_at,
197+ }
198+ }
199+}
200+
201+#[cfg(test)]
202+mod tests {
203+ use super::*;
204+
205+ fn token(name: &str) -> Result<PersonalAccessToken, DomainError> {
206+ PersonalAccessToken::new(
207+ TokenId::generate(),
208+ UserId::generate(),
209+ name,
210+ &TokenSecret::generate(),
211+ SystemTime::UNIX_EPOCH,
212+ )
213+ }
214+
215+ #[test]
216+ fn a_generated_token_is_recognisable_and_full_width() {
217+ let secret = TokenSecret::generate();
218+
219+ assert!(secret.reveal().starts_with("steid_pat_"));
220+ assert_eq!(
221+ secret.reveal().len(),
222+ "steid_pat_".len() + TokenSecret::BYTES * 2
223+ );
224+ }
225+
226+ #[test]
227+ fn tokens_are_unique_per_generation() {
228+ assert_ne!(
229+ TokenSecret::generate().reveal(),
230+ TokenSecret::generate().reveal()
231+ );
232+ }
233+
234+ #[test]
235+ fn a_token_hashes_to_the_same_value_every_time() {
236+ let secret = TokenSecret::generate();
237+
238+ assert!(secret.hash().matches(&secret.hash()));
239+ }
240+
241+ #[test]
242+ fn different_tokens_hash_differently() {
243+ let first = TokenSecret::generate();
244+ let second = TokenSecret::generate();
245+
246+ assert!(!first.hash().matches(&second.hash()));
247+ }
248+
249+ #[test]
250+ fn a_presented_value_hashes_the_same_as_the_token_it_copies() {
251+ // This is the whole authentication path: what arrives over Basic is hashed and
252+ // compared against what was stored.
253+ let secret = TokenSecret::generate();
254+ let presented = TokenSecret::from_presented(secret.reveal());
255+
256+ assert!(presented.hash().matches(&secret.hash()));
257+ }
258+
259+ #[test]
260+ fn the_hash_is_not_the_token() {
261+ let secret = TokenSecret::generate();
262+
263+ assert_ne!(secret.hash().as_str(), secret.reveal());
264+ assert_eq!(secret.hash().as_str().len(), 64);
265+ }
266+
267+ #[test]
268+ fn the_display_prefix_identifies_without_revealing() {
269+ let secret = TokenSecret::generate();
270+ let prefix = secret.display_prefix();
271+
272+ assert_eq!(prefix.len(), 8);
273+ assert!(secret.reveal().contains(&prefix));
274+ assert!(
275+ !prefix.starts_with("steid_pat_"),
276+ "the prefix should identify the token, not the product"
277+ );
278+ }
279+
280+ #[test]
281+ fn a_value_that_is_not_a_steid_token_has_no_prefix() {
282+ assert_eq!(TokenSecret::from_presented("hunter2").display_prefix(), "");
283+ }
284+
285+ #[test]
286+ fn debug_output_redacts_both_the_token_and_its_hash() {
287+ let secret = TokenSecret::generate();
288+
289+ assert_eq!(format!("{secret:?}"), "TokenSecret(redacted)");
290+ assert_eq!(format!("{:?}", secret.hash()), "TokenHash(redacted)");
291+ assert!(!format!("{:?}", token("laptop").expect("valid")).contains(secret.reveal()));
292+ }
293+
294+ #[test]
295+ fn a_token_records_its_prefix_and_hash() {
296+ let secret = TokenSecret::generate();
297+ let record = PersonalAccessToken::new(
298+ TokenId::generate(),
299+ UserId::generate(),
300+ " laptop ",
301+ &secret,
302+ SystemTime::UNIX_EPOCH,
303+ )
304+ .expect("valid");
305+
306+ assert_eq!(record.name, "laptop", "the name should be trimmed");
307+ assert_eq!(record.prefix, secret.display_prefix());
308+ assert!(record.token_hash.matches(&secret.hash()));
309+ }
310+
311+ #[test]
312+ fn a_token_needs_a_name() {
313+ assert!(token("").is_err());
314+ assert!(token(" ").is_err());
315+ }
316+
317+ #[test]
318+ fn a_name_has_a_limit() {
319+ assert!(token(&"a".repeat(PersonalAccessToken::MAX_NAME)).is_ok());
320+ assert!(token(&"a".repeat(PersonalAccessToken::MAX_NAME + 1)).is_err());
321+ }
322+}