Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Personal access tokens for Git over HTTPS (spec §6, §9).
Matt W2//!
Matt W3//! "Tokens are `dgf_` + 32 random bytes base62; store only an Argon2id hash and
Matt W4//! an 8-character prefix for identification. Show plaintext exactly once."
Matt W5
Matt W6use anyhow::{Context, Result};
Matt W7use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
Matt W8use argon2::Argon2;
Matt W9use chrono::{DateTime, Utc};
Matt W10use df_db::ids::new_id;
Matt W11use rand::RngCore;
Matt W12use sqlx::PgPool;
Matt W13use uuid::Uuid;
Matt W14
Matt W15const PREFIX: &str = "dgf_";
Matt W16const RANDOM_BYTES: usize = 32;
Matt W17/// Length of the identifying prefix stored alongside the hash. Includes `dgf_`.
Matt W18const ID_PREFIX_LEN: usize = 8;
Matt W19
Matt W20const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
Matt W21
Matt W22/// A freshly minted token. The plaintext exists only here and is shown once.
Matt W23pub struct NewToken {
Matt W24 pub id: Uuid,
Matt W25 pub plaintext: String,
Matt W26 pub prefix: String,
Matt W27}
Matt W28
Matt W29/// Generate a token, hash it, and store it.
Matt W30pub async fn create(
Matt W31 db: &PgPool,
Matt W32 user_id: Uuid,
Matt W33 name: &str,
Matt W34 scopes: &[String],
Matt W35 expires_at: Option<DateTime<Utc>>,
Matt W36) -> Result<NewToken> {
Matt W37 let plaintext = generate();
Matt W38 let prefix: String = plaintext.chars().take(ID_PREFIX_LEN).collect();
Matt W39 let hash = hash_token(&plaintext)?;
Matt W40 let id = new_id();
Matt W41
Matt W42 sqlx::query(
Matt W43 "INSERT INTO access_tokens (id, user_id, name, token_hash, prefix, scopes, expires_at)
Matt W44 VALUES ($1, $2, $3, $4, $5, $6, $7)",
Matt W45 )
Matt W46 .bind(id)
Matt W47 .bind(user_id)
Matt W48 .bind(name)
Matt W49 .bind(&hash)
Matt W50 .bind(&prefix)
Matt W51 .bind(scopes)
Matt W52 .bind(expires_at)
Matt W53 .execute(db)
Matt W54 .await
Matt W55 .context("storing access token")?;
Matt W56
Matt W57 Ok(NewToken { id, plaintext, prefix })
Matt W58}
Matt W59
Matt W60/// Generate a token string: `dgf_` followed by 32 random bytes in base62.
Matt W61fn generate() -> String {
Matt W62 let mut bytes = [0u8; RANDOM_BYTES];
Matt W63 rand::thread_rng().fill_bytes(&mut bytes);
Matt W64
Matt W65 let mut s = String::with_capacity(PREFIX.len() + RANDOM_BYTES);
Matt W66 s.push_str(PREFIX);
Matt W67 // Rejection-free mapping: 256 is not a multiple of 62, so a plain modulo
Matt W68 // biases the alphabet slightly. Each byte contributes well under a full
Matt W69 // character of entropy anyway (32 bytes -> ~190 bits), so the bias is not
Matt W70 // security-relevant here, but we draw fresh randomness per character rather
Matt W71 // than reusing the byte array to keep that reasoning simple.
Matt W72 let mut extra = [0u8; RANDOM_BYTES];
Matt W73 rand::thread_rng().fill_bytes(&mut extra);
Matt W74 for i in 0..RANDOM_BYTES {
Matt W75 let v = ((bytes[i] as u16) << 8 | extra[i] as u16) % BASE62.len() as u16;
Matt W76 s.push(BASE62[v as usize] as char);
Matt W77 }
Matt W78 s
Matt W79}
Matt W80
Matt W81fn hash_token(plaintext: &str) -> Result<String> {
Matt W82 let salt = SaltString::generate(&mut rand::thread_rng());
Matt W83 let hash = Argon2::default()
Matt W84 .hash_password(plaintext.as_bytes(), &salt)
Matt W85 .map_err(|e| anyhow::anyhow!("hashing token: {e}"))?;
Matt W86 Ok(hash.to_string())
Matt W87}
Matt W88
Matt W89/// Verify a presented token and return the owning user id.
Matt W90///
Matt W91/// Looks up candidates by prefix so Argon2 runs against at most a handful of
Matt W92/// rows rather than the whole table. Returns `None` for unknown, expired, or
Matt W93/// mismatched tokens without distinguishing between them.
Matt W94pub async fn verify(db: &PgPool, presented: &str) -> Result<Option<Uuid>> {
Matt W95 if !presented.starts_with(PREFIX) || presented.len() != PREFIX.len() + RANDOM_BYTES {
Matt W96 return Ok(None);
Matt W97 }
Matt W98 let prefix: String = presented.chars().take(ID_PREFIX_LEN).collect();
Matt W99
Matt W100 let candidates: Vec<(Uuid, Uuid, String)> = sqlx::query_as(
Matt W101 "SELECT id, user_id, token_hash FROM access_tokens
Matt W102 WHERE prefix = $1 AND (expires_at IS NULL OR expires_at > now())",
Matt W103 )
Matt W104 .bind(&prefix)
Matt W105 .fetch_all(db)
Matt W106 .await?;
Matt W107
Matt W108 for (token_id, user_id, stored) in candidates {
Matt W109 let parsed = match PasswordHash::new(&stored) {
Matt W110 Ok(p) => p,
Matt W111 Err(e) => {
Matt W112 tracing::error!(%token_id, "stored token hash is unparseable: {e}");
Matt W113 continue;
Matt W114 }
Matt W115 };
Matt W116 // Argon2's verify is constant-time with respect to the hash comparison.
Matt W117 if Argon2::default()
Matt W118 .verify_password(presented.as_bytes(), &parsed)
Matt W119 .is_ok()
Matt W120 {
Matt W121 // Best-effort: a failure to record usage must not fail the request.
Matt W122 if let Err(e) = sqlx::query("UPDATE access_tokens SET last_used_at = now() WHERE id = $1")
Matt W123 .bind(token_id)
Matt W124 .execute(db)
Matt W125 .await
Matt W126 {
Matt W127 tracing::warn!(%token_id, "recording token use failed: {e}");
Matt W128 }
Matt W129 return Ok(Some(user_id));
Matt W130 }
Matt W131 }
Matt W132
Matt W133 Ok(None)
Matt W134}
Matt W135
Matt W136pub async fn revoke(db: &PgPool, user_id: Uuid, token_id: Uuid) -> Result<bool> {
Matt W137 let r = sqlx::query("DELETE FROM access_tokens WHERE id = $1 AND user_id = $2")
Matt W138 .bind(token_id)
Matt W139 .bind(user_id)
Matt W140 .execute(db)
Matt W141 .await?;
Matt W142 Ok(r.rows_affected() > 0)
Matt W143}
Matt W144
Matt W145#[cfg(test)]
Matt W146mod tests {
Matt W147 use super::*;
Matt W148
Matt W149 #[test]
Matt W150 fn generated_tokens_have_the_documented_shape() {
Matt W151 let t = generate();
Matt W152 assert!(t.starts_with("dgf_"), "token must carry the dgf_ prefix: {t}");
Matt W153 assert_eq!(t.len(), 4 + 32);
Matt W154 assert!(
Matt W155 t[4..].bytes().all(|b| BASE62.contains(&b)),
Matt W156 "token body must be base62: {t}"
Matt W157 );
Matt W158 }
Matt W159
Matt W160 #[test]
Matt W161 fn generated_tokens_are_unique() {
Matt W162 use std::collections::HashSet;
Matt W163 let set: HashSet<String> = (0..2000).map(|_| generate()).collect();
Matt W164 assert_eq!(set.len(), 2000);
Matt W165 }
Matt W166
Matt W167 #[test]
Matt W168 fn hashing_is_salted_so_equal_tokens_differ_on_disk() {
Matt W169 let t = generate();
Matt W170 assert_ne!(
Matt W171 hash_token(&t).unwrap(),
Matt W172 hash_token(&t).unwrap(),
Matt W173 "identical tokens must not produce identical hashes"
Matt W174 );
Matt W175 }
Matt W176
Matt W177 #[test]
Matt W178 fn round_trips_through_argon2() {
Matt W179 let t = generate();
Matt W180 let stored = hash_token(&t).unwrap();
Matt W181 let parsed = PasswordHash::new(&stored).unwrap();
Matt W182 assert!(Argon2::default().verify_password(t.as_bytes(), &parsed).is_ok());
Matt W183 assert!(Argon2::default()
Matt W184 .verify_password(b"dgf_wrongwrongwrongwrongwrongwrongwr", &parsed)
Matt W185 .is_err());
Matt W186 }
Matt W187
Matt W188 #[test]
Matt W189 fn the_stored_prefix_identifies_without_revealing() {
Matt W190 let t = generate();
Matt W191 let prefix: String = t.chars().take(ID_PREFIX_LEN).collect();
Matt W192 assert_eq!(prefix.len(), 8);
Matt W193 assert!(t.starts_with(&prefix));
Matt W194 // 4 of the 8 characters are the constant `dgf_`, so only 4 random
Matt W195 // characters are exposed — enough to tell tokens apart in a list,
Matt W196 // far too few to guess the remaining 28.
Matt W197 assert_eq!(&prefix[..4], "dgf_");
Matt W198 }
Matt W199}

199 lines · Rust