@jpgilldev / steid

steid/src/domain/token.rs
9.7 KBRaw
1//! Personal access tokens — the credential a git client presents over HTTP Basic.
2
3use std::{fmt, time::SystemTime};
4
5use sha2::{Digest, Sha256};
6use subtle::ConstantTimeEq;
7
8use 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.
14const 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.
20const 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)]
28pub struct TokenSecret(String);
29
30impl 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).
76impl 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)]
89pub struct TokenHash(String);
90
91impl 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.
120impl 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)]
132pub 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
143impl 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)]
202mod 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}