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