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