Skip to main content

manabrew_server/
identity.rs

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}