Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Personal access tokens for Git over HTTPS (spec §6, §9).
2//!
3//! "Tokens are `dgf_` + 32 random bytes base62; store only an Argon2id hash and
4//! an 8-character prefix for identification. Show plaintext exactly once."
5
6use anyhow::{Context, Result};
7use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
8use argon2::Argon2;
9use chrono::{DateTime, Utc};
10use df_db::ids::new_id;
11use rand::RngCore;
12use sqlx::PgPool;
13use uuid::Uuid;
14
15const PREFIX: &str = "dgf_";
16const RANDOM_BYTES: usize = 32;
17/// Length of the identifying prefix stored alongside the hash. Includes `dgf_`.
18const ID_PREFIX_LEN: usize = 8;
19
20const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
21
22/// A freshly minted token. The plaintext exists only here and is shown once.
23pub struct NewToken {
24 pub id: Uuid,
25 pub plaintext: String,
26 pub prefix: String,
27}
28
29/// Generate a token, hash it, and store it.
30pub async fn create(
31 db: &PgPool,
32 user_id: Uuid,
33 name: &str,
34 scopes: &[String],
35 expires_at: Option<DateTime<Utc>>,
36) -> Result<NewToken> {
37 let plaintext = generate();
38 let prefix: String = plaintext.chars().take(ID_PREFIX_LEN).collect();
39 let hash = hash_token(&plaintext)?;
40 let id = new_id();
41
42 sqlx::query(
43 "INSERT INTO access_tokens (id, user_id, name, token_hash, prefix, scopes, expires_at)
44 VALUES ($1, $2, $3, $4, $5, $6, $7)",
45 )
46 .bind(id)
47 .bind(user_id)
48 .bind(name)
49 .bind(&hash)
50 .bind(&prefix)
51 .bind(scopes)
52 .bind(expires_at)
53 .execute(db)
54 .await
55 .context("storing access token")?;
56
57 Ok(NewToken { id, plaintext, prefix })
58}
59
60/// Generate a token string: `dgf_` followed by 32 random bytes in base62.
61fn generate() -> String {
62 let mut bytes = [0u8; RANDOM_BYTES];
63 rand::thread_rng().fill_bytes(&mut bytes);
64
65 let mut s = String::with_capacity(PREFIX.len() + RANDOM_BYTES);
66 s.push_str(PREFIX);
67 // Rejection-free mapping: 256 is not a multiple of 62, so a plain modulo
68 // biases the alphabet slightly. Each byte contributes well under a full
69 // character of entropy anyway (32 bytes -> ~190 bits), so the bias is not
70 // security-relevant here, but we draw fresh randomness per character rather
71 // than reusing the byte array to keep that reasoning simple.
72 let mut extra = [0u8; RANDOM_BYTES];
73 rand::thread_rng().fill_bytes(&mut extra);
74 for i in 0..RANDOM_BYTES {
75 let v = ((bytes[i] as u16) << 8 | extra[i] as u16) % BASE62.len() as u16;
76 s.push(BASE62[v as usize] as char);
77 }
78 s
79}
80
81fn hash_token(plaintext: &str) -> Result<String> {
82 let salt = SaltString::generate(&mut rand::thread_rng());
83 let hash = Argon2::default()
84 .hash_password(plaintext.as_bytes(), &salt)
85 .map_err(|e| anyhow::anyhow!("hashing token: {e}"))?;
86 Ok(hash.to_string())
87}
88
89/// Verify a presented token and return the owning user id.
90///
91/// Looks up candidates by prefix so Argon2 runs against at most a handful of
92/// rows rather than the whole table. Returns `None` for unknown, expired, or
93/// mismatched tokens without distinguishing between them.
94pub async fn verify(db: &PgPool, presented: &str) -> Result<Option<Uuid>> {
95 if !presented.starts_with(PREFIX) || presented.len() != PREFIX.len() + RANDOM_BYTES {
96 return Ok(None);
97 }
98 let prefix: String = presented.chars().take(ID_PREFIX_LEN).collect();
99
100 let candidates: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
101 "SELECT id, user_id, token_hash FROM access_tokens
102 WHERE prefix = $1 AND (expires_at IS NULL OR expires_at > now())",
103 )
104 .bind(&prefix)
105 .fetch_all(db)
106 .await?;
107
108 for (token_id, user_id, stored) in candidates {
109 let parsed = match PasswordHash::new(&stored) {
110 Ok(p) => p,
111 Err(e) => {
112 tracing::error!(%token_id, "stored token hash is unparseable: {e}");
113 continue;
114 }
115 };
116 // Argon2's verify is constant-time with respect to the hash comparison.
117 if Argon2::default()
118 .verify_password(presented.as_bytes(), &parsed)
119 .is_ok()
120 {
121 // Best-effort: a failure to record usage must not fail the request.
122 if let Err(e) = sqlx::query("UPDATE access_tokens SET last_used_at = now() WHERE id = $1")
123 .bind(token_id)
124 .execute(db)
125 .await
126 {
127 tracing::warn!(%token_id, "recording token use failed: {e}");
128 }
129 return Ok(Some(user_id));
130 }
131 }
132
133 Ok(None)
134}
135
136pub async fn revoke(db: &PgPool, user_id: Uuid, token_id: Uuid) -> Result<bool> {
137 let r = sqlx::query("DELETE FROM access_tokens WHERE id = $1 AND user_id = $2")
138 .bind(token_id)
139 .bind(user_id)
140 .execute(db)
141 .await?;
142 Ok(r.rows_affected() > 0)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn generated_tokens_have_the_documented_shape() {
151 let t = generate();
152 assert!(t.starts_with("dgf_"), "token must carry the dgf_ prefix: {t}");
153 assert_eq!(t.len(), 4 + 32);
154 assert!(
155 t[4..].bytes().all(|b| BASE62.contains(&b)),
156 "token body must be base62: {t}"
157 );
158 }
159
160 #[test]
161 fn generated_tokens_are_unique() {
162 use std::collections::HashSet;
163 let set: HashSet<String> = (0..2000).map(|_| generate()).collect();
164 assert_eq!(set.len(), 2000);
165 }
166
167 #[test]
168 fn hashing_is_salted_so_equal_tokens_differ_on_disk() {
169 let t = generate();
170 assert_ne!(
171 hash_token(&t).unwrap(),
172 hash_token(&t).unwrap(),
173 "identical tokens must not produce identical hashes"
174 );
175 }
176
177 #[test]
178 fn round_trips_through_argon2() {
179 let t = generate();
180 let stored = hash_token(&t).unwrap();
181 let parsed = PasswordHash::new(&stored).unwrap();
182 assert!(Argon2::default().verify_password(t.as_bytes(), &parsed).is_ok());
183 assert!(Argon2::default()
184 .verify_password(b"dgf_wrongwrongwrongwrongwrongwrongwr", &parsed)
185 .is_err());
186 }
187
188 #[test]
189 fn the_stored_prefix_identifies_without_revealing() {
190 let t = generate();
191 let prefix: String = t.chars().take(ID_PREFIX_LEN).collect();
192 assert_eq!(prefix.len(), 8);
193 assert!(t.starts_with(&prefix));
194 // 4 of the 8 characters are the constant `dgf_`, so only 4 random
195 // characters are exposed — enough to tell tokens apart in a list,
196 // far too few to guess the remaining 28.
197 assert_eq!(&prefix[..4], "dgf_");
198 }
199}

199 lines · Rust