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