| 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