manabrew_server/
identity.rs1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use aws_lc_rs::signature::{UnparsedPublicKey, ED25519};
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use base64::Engine;
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9use tokio::sync::Mutex;
10use tracing::warn;
11
12use crate::protocol::IdentityProof;
13
14const ISSUER: &str = "manabrew-hub";
16const AUDIENCE: &str = "manabrew-relay";
17
18const CLOCK_SKEW_SECS: i64 = 60;
19const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
20const JWKS_TIMEOUT: Duration = Duration::from_secs(5);
21const DEVICE_SECRET_MIN_LEN: usize = 16;
22const DEVICE_SECRET_MAX_LEN: usize = 512;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum SessionIdentity {
26 Account(String),
27 Device(String),
28}
29
30impl SessionIdentity {
31 pub fn kind(&self) -> &'static str {
32 match self {
33 Self::Account(_) => "account",
34 Self::Device(_) => "device",
35 }
36 }
37}
38
39#[derive(Deserialize)]
40struct JwtHeader {
41 alg: String,
42 kid: String,
43}
44
45#[derive(Deserialize)]
46struct IdentityClaims {
47 sub: String,
48 iss: String,
49 aud: String,
50 iat: i64,
51 exp: i64,
52}
53
54#[derive(Deserialize)]
55struct Jwks {
56 keys: Vec<Jwk>,
57}
58
59#[derive(Deserialize)]
60struct Jwk {
61 kid: String,
62 kty: String,
63 crv: String,
64 x: String,
65}
66
67#[derive(Default)]
68struct KeyCache {
69 keys: HashMap<String, Vec<u8>>,
70 last_fetch: Option<Instant>,
71}
72
73pub struct IdentityVerifier {
74 jwks_url: Option<String>,
75 http: reqwest::Client,
76 cache: Mutex<KeyCache>,
77}
78
79impl IdentityVerifier {
80 pub fn new(jwks_url: Option<String>) -> Self {
81 IdentityVerifier {
82 jwks_url,
83 http: reqwest::Client::builder()
84 .timeout(JWKS_TIMEOUT)
85 .build()
86 .unwrap_or_default(),
87 cache: Mutex::new(KeyCache::default()),
88 }
89 }
90
91 pub fn hub_configured(&self) -> bool {
92 self.jwks_url.is_some()
93 }
94
95 pub async fn resolve(&self, proof: &IdentityProof) -> Vec<SessionIdentity> {
96 let mut resolved = Vec::new();
97 if let Some(token) = proof.token.as_deref() {
98 if let Some(identity) = self.account_identity(token).await {
99 resolved.push(identity);
100 }
101 }
102 if let Some(identity) = proof.device.as_deref().and_then(device_identity) {
103 resolved.push(identity);
104 }
105 resolved
106 }
107
108 async fn account_identity(&self, token: &str) -> Option<SessionIdentity> {
109 let mut parts = token.split('.');
110 let encoded_header = parts.next()?;
111 let encoded_claims = parts.next()?;
112 let encoded_signature = parts.next()?;
113 if parts.next().is_some() {
114 return None;
115 }
116
117 let header: JwtHeader = serde_json::from_slice(&decode(encoded_header)?).ok()?;
118 if header.alg != "EdDSA" {
119 return None;
120 }
121
122 let key = self.public_key(&header.kid).await?;
123 let signing_input = format!("{encoded_header}.{encoded_claims}");
124 UnparsedPublicKey::new(&ED25519, &key)
125 .verify(signing_input.as_bytes(), &decode(encoded_signature)?)
126 .ok()?;
127
128 let claims: IdentityClaims = serde_json::from_slice(&decode(encoded_claims)?).ok()?;
129 if claims.iss != ISSUER || claims.aud != AUDIENCE || claims.sub.is_empty() {
130 return None;
131 }
132
133 let now = chrono::Utc::now().timestamp();
134 if claims.exp + CLOCK_SKEW_SECS < now || claims.iat - CLOCK_SKEW_SECS > now {
135 return None;
136 }
137
138 Some(SessionIdentity::Account(claims.sub))
139 }
140
141 async fn public_key(&self, kid: &str) -> Option<Vec<u8>> {
142 let url = self.jwks_url.as_ref()?;
143 let mut cache = self.cache.lock().await;
144 if let Some(key) = cache.keys.get(kid) {
145 return Some(key.clone());
146 }
147 if cache
148 .last_fetch
149 .is_some_and(|at| at.elapsed() < JWKS_REFRESH_COOLDOWN)
150 {
151 return None;
152 }
153
154 cache.last_fetch = Some(Instant::now());
155 match self.fetch_jwks(url).await {
156 Ok(keys) => cache.keys = keys,
157 Err(error) => {
158 warn!(%error, "identity jwks fetch failed");
159 return None;
160 }
161 }
162 cache.keys.get(kid).cloned()
163 }
164
165 async fn fetch_jwks(&self, url: &str) -> Result<HashMap<String, Vec<u8>>, reqwest::Error> {
166 let jwks: Jwks = self
167 .http
168 .get(url)
169 .send()
170 .await?
171 .error_for_status()?
172 .json()
173 .await?;
174 Ok(jwks
175 .keys
176 .into_iter()
177 .filter(|jwk| jwk.kty == "OKP" && jwk.crv == "Ed25519")
178 .filter_map(|jwk| Some((jwk.kid, decode(&jwk.x)?)))
179 .collect())
180 }
181}
182
183pub fn same_owner(session: &[SessionIdentity], presented: &[SessionIdentity]) -> bool {
184 !session.is_empty() && presented.iter().any(|identity| session.contains(identity))
185}
186
187pub fn label(identities: &[SessionIdentity]) -> &'static str {
188 identities
189 .first()
190 .map_or("anonymous", SessionIdentity::kind)
191}
192
193fn device_identity(secret: &str) -> Option<SessionIdentity> {
194 if !(DEVICE_SECRET_MIN_LEN..=DEVICE_SECRET_MAX_LEN).contains(&secret.len()) {
195 return None;
196 }
197 let digest = Sha256::digest(secret.as_bytes());
198 Some(SessionIdentity::Device(
199 digest.iter().map(|byte| format!("{byte:02x}")).collect(),
200 ))
201}
202
203fn decode(value: &str) -> Option<Vec<u8>> {
204 URL_SAFE_NO_PAD.decode(value).ok()
205}
206
207#[cfg(test)]
208mod tests {
209 use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair};
210
211 use super::*;
212
213 struct Signer {
214 key_pair: Ed25519KeyPair,
215 }
216
217 impl Signer {
218 fn new() -> Self {
219 let document =
220 Ed25519KeyPair::generate_pkcs8(&aws_lc_rs::rand::SystemRandom::new()).unwrap();
221 Signer {
222 key_pair: Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(),
223 }
224 }
225
226 fn mint(&self, claims: serde_json::Value) -> String {
227 let header = serde_json::json!({ "alg": "EdDSA", "typ": "JWT", "kid": "test" });
228 let signing_input = format!(
229 "{}.{}",
230 URL_SAFE_NO_PAD.encode(header.to_string()),
231 URL_SAFE_NO_PAD.encode(claims.to_string()),
232 );
233 let signature = self.key_pair.sign(signing_input.as_bytes());
234 format!(
235 "{signing_input}.{}",
236 URL_SAFE_NO_PAD.encode(signature.as_ref())
237 )
238 }
239 }
240
241 async fn verifier_for(signer: &Signer) -> IdentityVerifier {
242 let verifier = IdentityVerifier::new(Some("http://unused".into()));
243 verifier.cache.lock().await.keys.insert(
244 "test".into(),
245 signer.key_pair.public_key().as_ref().to_vec(),
246 );
247 verifier
248 }
249
250 fn token_proof(token: String) -> IdentityProof {
251 IdentityProof {
252 token: Some(token),
253 device: None,
254 }
255 }
256
257 fn claims(now: i64) -> serde_json::Value {
258 serde_json::json!({
259 "sub": "account-1",
260 "handle": "brewer",
261 "iss": ISSUER,
262 "aud": AUDIENCE,
263 "iat": now,
264 "exp": now + 600,
265 })
266 }
267
268 #[tokio::test]
269 async fn accepts_a_token_signed_by_the_hub_key() {
270 let signer = Signer::new();
271 let verifier = verifier_for(&signer).await;
272 let token = signer.mint(claims(chrono::Utc::now().timestamp()));
273
274 let resolved = verifier.resolve(&token_proof(token)).await;
275
276 assert_eq!(resolved, vec![SessionIdentity::Account("account-1".into())]);
277 }
278
279 #[tokio::test]
280 async fn rejects_tampered_expired_and_foreign_tokens() {
281 let signer = Signer::new();
282 let verifier = verifier_for(&signer).await;
283 let now = chrono::Utc::now().timestamp();
284
285 let mut tampered = signer.mint(claims(now));
286 tampered.replace_range(..1, "x");
287 assert!(verifier.resolve(&token_proof(tampered)).await.is_empty());
288
289 let expired = signer.mint(serde_json::json!({
290 "sub": "account-1", "iss": ISSUER, "aud": AUDIENCE,
291 "iat": now - 3600, "exp": now - 1800,
292 }));
293 assert!(verifier.resolve(&token_proof(expired)).await.is_empty());
294
295 let wrong_audience = signer.mint(serde_json::json!({
296 "sub": "account-1", "iss": ISSUER, "aud": "someone-else",
297 "iat": now, "exp": now + 600,
298 }));
299 assert!(verifier
300 .resolve(&token_proof(wrong_audience))
301 .await
302 .is_empty());
303
304 let other = Signer::new();
305 let forged = other.mint(claims(now));
306 assert!(verifier.resolve(&token_proof(forged)).await.is_empty());
307 }
308
309 #[tokio::test]
310 async fn falls_back_to_the_device_secret_without_a_configured_hub() {
311 let signer = Signer::new();
312 let verifier = IdentityVerifier::new(None);
313 let secret = "self-hosted-device-secret";
314 let proof = IdentityProof {
315 token: Some(signer.mint(claims(chrono::Utc::now().timestamp()))),
316 device: Some(secret.into()),
317 };
318
319 assert_eq!(
320 verifier.resolve(&proof).await,
321 device_identity(secret).into_iter().collect::<Vec<_>>()
322 );
323 assert!(verifier
324 .resolve(&token_proof("a.b.c".into()))
325 .await
326 .is_empty());
327 }
328
329 #[test]
330 fn device_secrets_hash_stably_and_bound_length() {
331 let secret = "a".repeat(DEVICE_SECRET_MIN_LEN);
332 assert_eq!(device_identity(&secret), device_identity(&secret));
333 assert_ne!(device_identity(&secret), device_identity(&"b".repeat(32)));
334 assert_eq!(device_identity("short"), None);
335 assert_eq!(
336 device_identity(&"a".repeat(DEVICE_SECRET_MAX_LEN + 1)),
337 None
338 );
339 }
340}