| 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 | |
| 6 | use anyhow::{Context, Result}; |
| 7 | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; |
| 8 | use argon2::Argon2; |
| 9 | use chrono::{DateTime, Utc}; |
| 10 | use df_db::ids::new_id; |
| 11 | use rand::RngCore; |
| 12 | use sqlx::PgPool; |
| 13 | use uuid::Uuid; |
| 14 | |
| 15 | const PREFIX: &str = "dgf_"; |
| 16 | const RANDOM_BYTES: usize = 32; |
| 17 | /// Length of the identifying prefix stored alongside the hash. Includes `dgf_`. |
| 18 | const ID_PREFIX_LEN: usize = 8; |
| 19 | |
| 20 | const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; |
| 21 | |
| 22 | /// A freshly minted token. The plaintext exists only here and is shown once. |
| 23 | pub 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. |
| 30 | pub 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. |
| 61 | fn 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 | |
| 81 | fn 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. |
| 94 | pub 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 | |
| 136 | pub 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)] |
| 146 | mod 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