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