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    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}