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