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