fabrica

hanna/fabrica

8629 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! HS256 JSON Web Tokens for the API.
6//!
7//! We hand-roll the (small, well-specified) HS256 construction rather than pull
8//! in a JWT library, because the mainstream crates depend on `ring`, whose
9//! license set falls outside this project's allow-list. The surface we need is
10//! tiny: base64url of a fixed header and a JSON payload, an HMAC-SHA256 over the
11//! two, and constant-time verification of the same.
12//!
13//! Signature and expiry are all this module proves. **Revocation is not checked
14//! here** — a valid signature only means the token is authentic and unexpired.
15//! Callers MUST additionally confirm the `jti` row in `api_tokens` has
16//! `revoked_at IS NULL`; that database check is what makes revocation real.
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use hmac::{Hmac, Mac};
21use rand_core::{OsRng, RngCore};
22use serde::{Deserialize, Serialize};
23use sha2::Sha256;
24
25use crate::AuthError;
26
27type HmacSha256 = Hmac<Sha256>;
28
29/// Generate a fresh instance signing secret: 32 bytes of OS randomness, base64url
30/// encoded. Written to `auth.secret_file` on first run and reused thereafter; it
31/// signs both API tokens and session-cookie material.
32#[must_use]
33pub fn new_secret() -> String {
34 let mut bytes = [0u8; 32];
35 OsRng.fill_bytes(&mut bytes);
36 URL_SAFE_NO_PAD.encode(bytes)
37}
38
39/// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`.
40/// Pre-encoded so minting never re-serializes it, and matched exactly on verify
41/// so a token advertising any other algorithm is rejected out of hand (closing
42/// the classic "alg" confusion attack).
43const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
44
45/// The claims carried by an API token. Field names are the JWT wire names.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Claims {
48 /// Subject: the owning user's id (`users.id`).
49 pub sub: String,
50 /// JWT id: the `api_tokens` row id, checked against `revoked_at` on every
51 /// request to enforce revocation.
52 pub jti: String,
53 /// Comma-separated scope list (see [`crate::scope`]).
54 pub scopes: String,
55 /// Issued-at, Unix seconds.
56 pub iat: i64,
57 /// Expiry, Unix seconds. A token is rejected once `exp <= now`.
58 pub exp: i64,
59}
60
61/// Mint a signed HS256 token for `claims`, signed with the instance `secret`.
62///
63/// # Errors
64///
65/// Returns [`AuthError::Hash`] if the claims cannot be serialized (in practice
66/// only an allocation failure), reusing that variant to avoid widening the error
67/// enum for an all-but-impossible case.
68pub fn mint_jwt(secret: &str, claims: &Claims) -> Result<String, AuthError> {
69 let payload_json =
70 serde_json::to_vec(claims).map_err(|e| AuthError::Hash(format!("claims: {e}")))?;
71 let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
72 let signing_input = format!("{HEADER_B64}.{payload_b64}");
73 let signature = sign(secret, signing_input.as_bytes());
74 Ok(format!("{signing_input}.{signature}"))
75}
76
77/// Verify a token's signature and expiry against `secret` and the current time
78/// `now` (Unix seconds), returning its claims on success.
79///
80/// Every failure — wrong shape, unexpected algorithm, bad signature, malformed
81/// payload, or expiry — collapses to [`AuthError::InvalidToken`] so nothing about
82/// *why* a token was rejected reaches the caller.
83///
84/// # Errors
85///
86/// Returns [`AuthError::InvalidToken`] on any verification failure.
87pub fn verify_jwt(secret: &str, token: &str, now: i64) -> Result<Claims, AuthError> {
88 let mut parts = token.split('.');
89 let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) =
90 (parts.next(), parts.next(), parts.next(), parts.next())
91 else {
92 return Err(AuthError::InvalidToken);
93 };
94
95 // Reject anything not signed with our fixed HS256 header before spending an
96 // HMAC on it.
97 if header_b64 != HEADER_B64 {
98 return Err(AuthError::InvalidToken);
99 }
100
101 // Constant-time signature check via the MAC's own verifier.
102 let provided = URL_SAFE_NO_PAD
103 .decode(signature_b64)
104 .map_err(|_| AuthError::InvalidToken)?;
105 let signing_input = format!("{header_b64}.{payload_b64}");
106 let mut mac =
107 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| AuthError::InvalidToken)?;
108 mac.update(signing_input.as_bytes());
109 mac.verify_slice(&provided)
110 .map_err(|_| AuthError::InvalidToken)?;
111
112 // Signature is good; now decode and check the claims.
113 let payload = URL_SAFE_NO_PAD
114 .decode(payload_b64)
115 .map_err(|_| AuthError::InvalidToken)?;
116 let claims: Claims = serde_json::from_slice(&payload).map_err(|_| AuthError::InvalidToken)?;
117 if claims.exp <= now {
118 return Err(AuthError::InvalidToken);
119 }
120 Ok(claims)
121}
122
123/// Compute the base64url HMAC-SHA256 signature of `message` under `secret`.
124fn sign(secret: &str, message: &[u8]) -> String {
125 // `new_from_slice` only fails for key sizes HMAC cannot accept, which is
126 // none — every byte length is valid — so this cannot error in practice.
127 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
128 return String::new();
129 };
130 mac.update(message);
131 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
132}
133
134#[cfg(test)]
135mod tests {
136 #![allow(clippy::unwrap_used)]
137
138 use super::*;
139
140 fn claims(exp: i64) -> Claims {
141 Claims {
142 sub: "01useruseruseruseruseruser".to_string(),
143 jti: "01tokentokentokentokentoken".to_string(),
144 scopes: "repo:read,repo:write".to_string(),
145 iat: 1_000,
146 exp,
147 }
148 }
149
150 #[test]
151 fn round_trips_a_valid_token() {
152 let token = mint_jwt("s3cr3t", &claims(2_000)).unwrap();
153 // Three base64url segments.
154 assert_eq!(token.split('.').count(), 3);
155 let decoded = verify_jwt("s3cr3t", &token, 1_500).unwrap();
156 assert_eq!(decoded, claims(2_000));
157 }
158
159 #[test]
160 fn header_is_the_canonical_hs256_header() {
161 let token = mint_jwt("s", &claims(2_000)).unwrap();
162 let header = token.split('.').next().unwrap();
163 let json = URL_SAFE_NO_PAD.decode(header).unwrap();
164 assert_eq!(&json, br#"{"alg":"HS256","typ":"JWT"}"#);
165 }
166
167 #[test]
168 fn wrong_secret_is_rejected() {
169 let token = mint_jwt("right", &claims(2_000)).unwrap();
170 assert!(matches!(
171 verify_jwt("wrong", &token, 1_500),
172 Err(AuthError::InvalidToken)
173 ));
174 }
175
176 #[test]
177 fn expired_token_is_rejected() {
178 let token = mint_jwt("s", &claims(2_000)).unwrap();
179 // At exactly exp the token is already expired (exp <= now).
180 assert!(matches!(
181 verify_jwt("s", &token, 2_000),
182 Err(AuthError::InvalidToken)
183 ));
184 assert!(verify_jwt("s", &token, 1_999).is_ok());
185 }
186
187 #[test]
188 fn tampered_payload_is_rejected() {
189 let token = mint_jwt("s", &claims(2_000)).unwrap();
190 let mut parts: Vec<&str> = token.split('.').collect();
191 // Re-encode a payload that escalates scopes; the signature no longer fits.
192 let forged = Claims {
193 scopes: "admin".to_string(),
194 ..claims(2_000)
195 };
196 let forged_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged).unwrap());
197 parts[1] = &forged_b64;
198 let tampered = parts.join(".");
199 assert!(matches!(
200 verify_jwt("s", &tampered, 1_500),
201 Err(AuthError::InvalidToken)
202 ));
203 }
204
205 #[test]
206 fn malformed_shapes_are_rejected() {
207 for bad in ["", "onlyonepart", "two.parts", "a.b.c.d", "..", "x.y.z"] {
208 assert!(
209 matches!(verify_jwt("s", bad, 0), Err(AuthError::InvalidToken)),
210 "should reject {bad:?}"
211 );
212 }
213 }
214
215 #[test]
216 fn algorithm_confusion_is_rejected() {
217 // A token whose header claims "alg":"none" but is otherwise well-formed
218 // must not verify, even with an empty signature.
219 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims(2_000)).unwrap());
220 let none_header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
221 let forged = format!("{none_header}.{payload}.");
222 assert!(matches!(
223 verify_jwt("s", &forged, 1_500),
224 Err(AuthError::InvalidToken)
225 ));
226 }
227}