1use 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::identity_token::{
13 self, DecodedIdentityToken, IdentityTokenClaims, HUB_ISSUER, RELAY_AUDIENCE, SELF_ISSUER,
14 SIGNED_ALG,
15};
16use crate::protocol::IdentityProof;
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(Default)]
40pub struct ResolvedIdentity {
41 pub identities: Vec<SessionIdentity>,
42 pub name: Option<String>,
43 pub name_verified: bool,
44 pub qualification: Option<String>,
45 pub avatar_url: Option<String>,
46 pub stale_token: bool,
47}
48
49#[derive(Deserialize)]
50struct Jwks {
51 keys: Vec<Jwk>,
52}
53
54#[derive(Deserialize)]
55struct Jwk {
56 kid: String,
57 kty: String,
58 crv: String,
59 x: String,
60}
61
62#[derive(Default)]
63struct KeyCache {
64 keys: HashMap<String, Vec<u8>>,
65 last_fetch: Option<Instant>,
66}
67
68pub struct IdentityVerifier {
69 jwks_url: Option<String>,
70 http: reqwest::Client,
71 cache: Mutex<KeyCache>,
72}
73
74impl IdentityVerifier {
75 pub fn new(jwks_url: Option<String>) -> Self {
76 IdentityVerifier {
77 jwks_url,
78 http: reqwest::Client::builder()
79 .timeout(JWKS_TIMEOUT)
80 .build()
81 .unwrap_or_default(),
82 cache: Mutex::new(KeyCache::default()),
83 }
84 }
85
86 pub fn hub_configured(&self) -> bool {
87 self.jwks_url.is_some()
88 }
89
90 pub async fn resolve(&self, proof: &IdentityProof) -> ResolvedIdentity {
91 let mut resolved = ResolvedIdentity::default();
92 if let Some(decoded) = proof.token.as_deref().and_then(identity_token::decode) {
93 if decoded.is_unsigned() {
94 if let Some(name) = unverified_name(&decoded) {
95 resolved.name = Some(name);
96 }
97 } else if let Some((identity, name, qualification, avatar_url)) =
98 self.account_identity(&decoded).await
99 {
100 if !claims_current(&decoded.claims) {
101 resolved.stale_token = true;
102 } else {
103 resolved.identities.push(identity);
104 if !name.trim().is_empty() {
105 resolved.name = Some(name);
106 resolved.name_verified = true;
107 resolved.qualification = qualification;
108 resolved.avatar_url = avatar_url;
109 }
110 }
111 }
112 }
113 if let Some(identity) = proof.device.as_deref().and_then(device_identity) {
114 resolved.identities.push(identity);
115 }
116 resolved
117 }
118
119 async fn account_identity(
120 &self,
121 decoded: &DecodedIdentityToken,
122 ) -> Option<(SessionIdentity, String, Option<String>, Option<String>)> {
123 if decoded.header.alg != SIGNED_ALG {
124 return None;
125 }
126
127 let key = self.public_key(decoded.header.kid.as_deref()?).await?;
128 UnparsedPublicKey::new(&ED25519, &key)
129 .verify(decoded.signing_input.as_bytes(), &decoded.signature)
130 .ok()?;
131
132 let claims = &decoded.claims;
133 if claims.iss != HUB_ISSUER || claims.aud != RELAY_AUDIENCE || claims.sub.is_empty() {
134 return None;
135 }
136
137 Some((
138 SessionIdentity::Account(claims.sub.clone()),
139 claims.handle.clone(),
140 claims.qualification.clone(),
141 claims.avatar_url.clone(),
142 ))
143 }
144
145 async fn public_key(&self, kid: &str) -> Option<Vec<u8>> {
146 let url = self.jwks_url.as_ref()?;
147 let mut cache = self.cache.lock().await;
148 if let Some(key) = cache.keys.get(kid) {
149 return Some(key.clone());
150 }
151 if cache
152 .last_fetch
153 .is_some_and(|at| at.elapsed() < JWKS_REFRESH_COOLDOWN)
154 {
155 return None;
156 }
157
158 cache.last_fetch = Some(Instant::now());
159 match self.fetch_jwks(url).await {
160 Ok(keys) => cache.keys = keys,
161 Err(error) => {
162 warn!(%error, "identity jwks fetch failed");
163 return None;
164 }
165 }
166 cache.keys.get(kid).cloned()
167 }
168
169 async fn fetch_jwks(&self, url: &str) -> Result<HashMap<String, Vec<u8>>, reqwest::Error> {
170 let jwks: Jwks = self
171 .http
172 .get(url)
173 .send()
174 .await?
175 .error_for_status()?
176 .json()
177 .await?;
178 Ok(jwks
179 .keys
180 .into_iter()
181 .filter(|jwk| jwk.kty == "OKP" && jwk.crv == "Ed25519")
182 .filter_map(|jwk| Some((jwk.kid, decode(&jwk.x)?)))
183 .collect())
184 }
185}
186
187pub fn same_owner(session: &[SessionIdentity], presented: &[SessionIdentity]) -> bool {
188 !session.is_empty() && presented.iter().any(|identity| session.contains(identity))
189}
190
191pub fn label(identities: &[SessionIdentity]) -> &'static str {
192 identities
193 .first()
194 .map_or("anonymous", SessionIdentity::kind)
195}
196
197fn unverified_name(decoded: &DecodedIdentityToken) -> Option<String> {
198 let claims = &decoded.claims;
199 if claims.iss != SELF_ISSUER || claims.aud != RELAY_AUDIENCE {
200 return None;
201 }
202 if !claims_current(claims) {
203 return None;
204 }
205 let handle = claims.handle.trim();
206 if handle.is_empty() {
207 return None;
208 }
209 Some(handle.to_string())
210}
211
212fn claims_current(claims: &IdentityTokenClaims) -> bool {
213 let now = chrono::Utc::now().timestamp();
214 claims.exp + CLOCK_SKEW_SECS >= now && claims.iat - CLOCK_SKEW_SECS <= now
215}
216
217fn device_identity(secret: &str) -> Option<SessionIdentity> {
218 if !(DEVICE_SECRET_MIN_LEN..=DEVICE_SECRET_MAX_LEN).contains(&secret.len()) {
219 return None;
220 }
221 let digest = Sha256::digest(secret.as_bytes());
222 Some(SessionIdentity::Device(
223 digest.iter().map(|byte| format!("{byte:02x}")).collect(),
224 ))
225}
226
227fn decode(value: &str) -> Option<Vec<u8>> {
228 URL_SAFE_NO_PAD.decode(value).ok()
229}
230
231#[cfg(test)]
232mod tests {
233 use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair};
234
235 use super::*;
236
237 struct Signer {
238 key_pair: Ed25519KeyPair,
239 }
240
241 impl Signer {
242 fn new() -> Self {
243 let document =
244 Ed25519KeyPair::generate_pkcs8(&aws_lc_rs::rand::SystemRandom::new()).unwrap();
245 Signer {
246 key_pair: Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(),
247 }
248 }
249
250 fn mint(&self, claims: serde_json::Value) -> String {
251 let header = serde_json::json!({ "alg": "EdDSA", "typ": "JWT", "kid": "test" });
252 let signing_input = format!(
253 "{}.{}",
254 URL_SAFE_NO_PAD.encode(header.to_string()),
255 URL_SAFE_NO_PAD.encode(claims.to_string()),
256 );
257 let signature = self.key_pair.sign(signing_input.as_bytes());
258 format!(
259 "{signing_input}.{}",
260 URL_SAFE_NO_PAD.encode(signature.as_ref())
261 )
262 }
263 }
264
265 async fn verifier_for(signer: &Signer) -> IdentityVerifier {
266 let verifier = IdentityVerifier::new(Some("http://unused".into()));
267 verifier.cache.lock().await.keys.insert(
268 "test".into(),
269 signer.key_pair.public_key().as_ref().to_vec(),
270 );
271 verifier
272 }
273
274 fn token_proof(token: String) -> IdentityProof {
275 IdentityProof {
276 token: Some(token),
277 device: None,
278 }
279 }
280
281 fn claims(now: i64) -> serde_json::Value {
282 serde_json::json!({
283 "sub": "account-1",
284 "handle": "brewer",
285 "iss": HUB_ISSUER,
286 "aud": RELAY_AUDIENCE,
287 "iat": now,
288 "exp": now + 600,
289 })
290 }
291
292 #[tokio::test]
293 async fn accepts_a_token_signed_by_the_hub_key() {
294 let signer = Signer::new();
295 let verifier = verifier_for(&signer).await;
296 let token = signer.mint(claims(chrono::Utc::now().timestamp()));
297
298 let resolved = verifier.resolve(&token_proof(token)).await;
299
300 assert_eq!(
301 resolved.identities,
302 vec![SessionIdentity::Account("account-1".into())]
303 );
304 assert_eq!(resolved.name.as_deref(), Some("brewer"));
305 }
306
307 #[tokio::test]
308 async fn accepts_a_self_minted_unsigned_token_as_an_unverified_name() {
309 let verifier = IdentityVerifier::new(None);
310 let now = chrono::Utc::now().timestamp();
311 let token = identity_token::mint_unsigned("node:test", "Free room host", now, 600);
312
313 let resolved = verifier.resolve(&token_proof(token)).await;
314
315 assert_eq!(resolved.name.as_deref(), Some("Free room host"));
316 assert!(!resolved.name_verified);
317 assert!(resolved.identities.is_empty());
318
319 let expired = identity_token::mint_unsigned("node:test", "stale", now - 3600, 600);
320 assert!(verifier.resolve(&token_proof(expired)).await.name.is_none());
321 }
322
323 #[tokio::test]
324 async fn an_unsigned_token_cannot_claim_the_hub_issuer() {
325 let verifier = IdentityVerifier::new(None);
326 let now = chrono::Utc::now().timestamp();
327 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
328 let claims = URL_SAFE_NO_PAD.encode(
329 serde_json::json!({
330 "sub": "account-1", "handle": "impostor",
331 "iss": HUB_ISSUER, "aud": RELAY_AUDIENCE,
332 "iat": now, "exp": now + 600,
333 })
334 .to_string(),
335 );
336
337 let resolved = verifier
338 .resolve(&token_proof(format!("{header}.{claims}.")))
339 .await;
340
341 assert!(resolved.name.is_none());
342 assert!(resolved.identities.is_empty());
343 }
344
345 #[tokio::test]
346 async fn rejects_tampered_expired_and_foreign_tokens() {
347 let signer = Signer::new();
348 let verifier = verifier_for(&signer).await;
349 let now = chrono::Utc::now().timestamp();
350
351 let mut tampered = signer.mint(claims(now));
352 tampered.replace_range(..1, "x");
353 assert!(verifier
354 .resolve(&token_proof(tampered))
355 .await
356 .identities
357 .is_empty());
358
359 let expired = signer.mint(serde_json::json!({
360 "sub": "account-1", "iss": HUB_ISSUER, "aud": RELAY_AUDIENCE,
361 "iat": now - 3600, "exp": now - 1800,
362 }));
363 assert!(verifier
364 .resolve(&token_proof(expired))
365 .await
366 .identities
367 .is_empty());
368
369 let wrong_audience = signer.mint(serde_json::json!({
370 "sub": "account-1", "iss": HUB_ISSUER, "aud": "someone-else",
371 "iat": now, "exp": now + 600,
372 }));
373 assert!(verifier
374 .resolve(&token_proof(wrong_audience))
375 .await
376 .identities
377 .is_empty());
378
379 let other = Signer::new();
380 let forged = other.mint(claims(now));
381 assert!(verifier
382 .resolve(&token_proof(forged))
383 .await
384 .identities
385 .is_empty());
386 }
387
388 #[tokio::test]
389 async fn falls_back_to_the_device_secret_without_a_configured_hub() {
390 let signer = Signer::new();
391 let verifier = IdentityVerifier::new(None);
392 let secret = "self-hosted-device-secret";
393 let proof = IdentityProof {
394 token: Some(signer.mint(claims(chrono::Utc::now().timestamp()))),
395 device: Some(secret.into()),
396 };
397
398 assert_eq!(
399 verifier.resolve(&proof).await.identities,
400 device_identity(secret).into_iter().collect::<Vec<_>>()
401 );
402 assert!(verifier
403 .resolve(&token_proof("a.b.c".into()))
404 .await
405 .identities
406 .is_empty());
407 }
408
409 #[test]
410 fn device_secrets_hash_stably_and_bound_length() {
411 let secret = "a".repeat(DEVICE_SECRET_MIN_LEN);
412 assert_eq!(device_identity(&secret), device_identity(&secret));
413 assert_ne!(device_identity(&secret), device_identity(&"b".repeat(32)));
414 assert_eq!(device_identity("short"), None);
415 assert_eq!(
416 device_identity(&"a".repeat(DEVICE_SECRET_MAX_LEN + 1)),
417 None
418 );
419 }
420}