Skip to main content

vti_common/auth/
jwt.rs

1use crate::error::AppError;
2use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
3use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5use tracing::debug;
6
7/// JWT claims for VTA/VTC access tokens.
8///
9/// Aligns with the OIDC Core §2 / RFC 8176 vocabulary so the same
10/// claim names other identity stacks emit work here unchanged:
11///
12/// - `amr` — *Authentication Methods References*. Per [RFC 8176]
13///   the canonical short strings are `pwd`, `hwk`, `swk`, `iris`,
14///   `face`, `sms`, etc.; the VTI vocabulary in current use is
15///   `"did"` (challenge-response), `"passkey"` (WebAuthn), `"vta"`
16///   (verifiable-trust-agent attestation). Multi-factor sessions
17///   list every method that contributed.
18/// - `acr` — *Authentication Context Class Reference*. The
19///   recommended set is `"aal1"` (single-factor DID), `"aal2"`
20///   (a second possession-or-biometric factor confirmed), `"aal3"`
21///   (hardware-bound second factor).
22///
23/// `Default` derives so test fixtures and follow-up constructions can
24/// use `Claims { aud: ..., ..Default::default() }` without listing
25/// every field — production minters still set the values that
26/// matter, but the boilerplate at non-load-bearing call sites
27/// (default-empty in tests, mocks, examples) stays out of the way.
28#[derive(Debug, Default, Serialize, Deserialize)]
29pub struct Claims {
30    pub aud: String,
31    pub sub: String,
32    pub session_id: String,
33    pub role: String,
34    #[serde(default)]
35    pub contexts: Vec<String>,
36    pub exp: u64,
37    /// Issued-at, Unix seconds. Mirrors OIDC Core §2 / RFC 7519
38    /// §4.1.6. The extractor doesn't gate on it directly — `exp`
39    /// is the only mandatory freshness check — but `iat` lets
40    /// audit logs and downstream consumers distinguish a re-issued
41    /// token from the original mint, and lets clock-skew analysis
42    /// detect a stuck issuer.
43    ///
44    /// `#[serde(default)]` so tokens minted before `iat` landed
45    /// deserialise as `iat=0` — never confused with a fresh mint
46    /// because `iat==0` is older than any real session.
47    #[serde(default)]
48    pub iat: u64,
49    /// Indicates the service is running inside a Trusted Execution Environment.
50    /// Only present (and `true`) when TEE is active; omitted when false to
51    /// reduce token size.
52    #[serde(default, skip_serializing_if = "is_false")]
53    pub tee_attested: bool,
54    /// Authentication Methods References per [RFC 8176]. Empty when
55    /// the consumer's authentication path did not categorise the
56    /// factor (e.g. legacy tokens minted before AAL plumbing landed).
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub amr: Vec<String>,
59    /// Authentication Context Class Reference per OIDC Core §2.
60    /// Empty when not categorised.
61    #[serde(default, skip_serializing_if = "String::is_empty")]
62    pub acr: String,
63    /// JWT ID (RFC 7519 §4.1.7) — a per-issue nonce that pins this access
64    /// token to the session's current `token_id`. The extractor rejects a
65    /// token whose `jti` does not match `Session.token_id` (when that field is
66    /// set), so minting a fresh token immediately supersedes the prior one for
67    /// the same session. Load-bearing once a session_id stops rotating on
68    /// refresh; harmless before then (each session already has exactly one live
69    /// token). `#[serde(default)]` so pre-`jti` tokens deserialise with an empty
70    /// string and are treated as "no pin" by sessions that predate the field.
71    #[serde(default, skip_serializing_if = "String::is_empty")]
72    pub jti: String,
73}
74
75fn is_false(v: &bool) -> bool {
76    !*v
77}
78
79/// Holds the JWT encoding and decoding keys derived from an Ed25519 seed.
80pub struct JwtKeys {
81    encoding: EncodingKey,
82    decoding: DecodingKey,
83    /// Audience string used for encoding and validation (e.g., "VTA" or "VTC").
84    audience: String,
85}
86
87impl JwtKeys {
88    /// Create JWT keys from raw 32-byte Ed25519 private key bytes.
89    ///
90    /// `audience` is the expected JWT audience claim (e.g., "VTA" or "VTC").
91    ///
92    /// Computes the public key and wraps both in DER format as required
93    /// by `jsonwebtoken`'s `from_ed_der()` methods.
94    pub fn from_ed25519_bytes(private_bytes: &[u8; 32], audience: &str) -> Result<Self, AppError> {
95        // Compute the Ed25519 public key from the private key seed
96        let signing_key = ed25519_dalek::SigningKey::from_bytes(private_bytes);
97        let public_bytes = signing_key.verifying_key().to_bytes();
98
99        // Build PKCS8 v1 DER for the private key (used by EncodingKey)
100        //
101        // SEQUENCE {                                  -- 0x30, 0x2e (46 bytes)
102        //   INTEGER 0                                 -- 0x02, 0x01, 0x00
103        //   SEQUENCE { OID 1.3.101.112 }              -- 0x30, 0x05, ...
104        //   OCTET STRING { OCTET STRING <32 bytes> }  -- 0x04, 0x22, 0x04, 0x20, ...
105        // }
106        let mut pkcs8 = Vec::with_capacity(48);
107        pkcs8.extend_from_slice(&[
108            0x30, 0x2e, // SEQUENCE, 46 bytes
109            0x02, 0x01, 0x00, // INTEGER 0 (version v1)
110            0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // AlgorithmIdentifier (Ed25519)
111            0x04, 0x22, 0x04, 0x20, // OCTET STRING { OCTET STRING, 32 bytes }
112        ]);
113        pkcs8.extend_from_slice(private_bytes);
114
115        let encoding = EncodingKey::from_ed_der(&pkcs8);
116        // rust_crypto backend expects raw 32-byte public key, not SPKI DER
117        let decoding = DecodingKey::from_ed_der(&public_bytes);
118
119        Ok(Self {
120            encoding,
121            decoding,
122            audience: audience.to_string(),
123        })
124    }
125
126    /// Encode claims into a signed JWT access token.
127    pub fn encode(&self, claims: &Claims) -> Result<String, AppError> {
128        let header = Header::new(Algorithm::EdDSA);
129        jsonwebtoken::encode(&header, claims, &self.encoding)
130            .map_err(|e| AppError::Internal(format!("JWT encode failed: {e}")))
131    }
132
133    /// Decode and validate a JWT access token, returning the claims.
134    pub fn decode(&self, token: &str) -> Result<Claims, AppError> {
135        let mut validation = Validation::new(Algorithm::EdDSA);
136        validation.set_audience(&[&self.audience]);
137        validation.set_required_spec_claims(&["exp", "sub", "aud", "session_id", "role"]);
138
139        jsonwebtoken::decode::<Claims>(token, &self.decoding, &validation)
140            .map(|data| data.claims)
141            .map_err(|e| {
142                debug!(error = %e, "JWT decode failed");
143                AppError::Unauthorized(format!("invalid token: {e}"))
144            })
145    }
146
147    /// Create claims for a new access token.
148    pub fn new_claims(
149        &self,
150        sub: String,
151        session_id: String,
152        role: String,
153        contexts: Vec<String>,
154        expiry_secs: u64,
155        tee_attested: bool,
156    ) -> Claims {
157        // Fall back to 0 if the clock is before UNIX_EPOCH — happens on
158        // recovery boots before NTP sync. Token would expire immediately
159        // in that (very unusual) state, which is safer than panicking in
160        // a hot auth path.
161        let now_secs = SystemTime::now()
162            .duration_since(UNIX_EPOCH)
163            .map(|d| d.as_secs())
164            .unwrap_or(0);
165        let exp = now_secs + expiry_secs;
166
167        Claims {
168            aud: self.audience.clone(),
169            sub,
170            session_id,
171            role,
172            contexts,
173            exp,
174            iat: now_secs,
175            tee_attested,
176            amr: Vec::new(),
177            acr: String::new(),
178            jti: String::new(),
179        }
180    }
181}
182
183impl Claims {
184    /// Builder-style setter for the `amr` + `acr` claims. Production
185    /// minters that know how the session was authenticated call this
186    /// after [`JwtKeys::new_claims`] to attach the AAL signal:
187    ///
188    /// ```ignore
189    /// let claims = jwt_keys.new_claims(/* ... */)
190    ///     .with_aal(vec!["did".into()], "aal1");
191    /// ```
192    ///
193    /// Step-up flows append a factor and raise the acr:
194    ///
195    /// ```ignore
196    /// claims = claims.with_aal(vec!["did".into(), "passkey".into()], "aal2");
197    /// ```
198    /// Builder-style setter for the `jti` pin. Minters call this after
199    /// [`JwtKeys::new_claims`] with the same value they persist as the
200    /// session's `token_id`, so the extractor's pin check matches. Defaults to
201    /// empty (no pin) when not called.
202    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
203        self.jti = jti.into();
204        self
205    }
206
207    pub fn with_aal(mut self, amr: Vec<String>, acr: impl Into<String>) -> Self {
208        self.amr = amr;
209        self.acr = acr.into();
210        self
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use base64::Engine;
218
219    /// Pin jsonwebtoken's default `CryptoProvider` to `aws_lc_rs`
220    /// once per process. Required because jsonwebtoken's auto-select
221    /// panics when more than one provider is in the feature graph;
222    /// `cargo test --workspace` unifies features across crates and
223    /// can produce that situation. We compile `jsonwebtoken` with
224    /// only the `aws_lc_rs` backend (the `rust_crypto` bundle pulls
225    /// in `rsa` which is exposed to RUSTSEC-2023-0071), so this is
226    /// also the only backend available at runtime.
227    fn init_jwt_provider() {
228        use std::sync::Once;
229        static INIT: Once = Once::new();
230        INIT.call_once(|| {
231            let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default();
232        });
233    }
234
235    fn test_keys() -> JwtKeys {
236        init_jwt_provider();
237        JwtKeys::from_ed25519_bytes(&[0x42u8; 32], "VTA").unwrap()
238    }
239
240    #[test]
241    fn test_jwt_roundtrip() {
242        let keys = test_keys();
243        let claims = keys.new_claims(
244            "did:key:z6Mk".into(),
245            "sess-1".into(),
246            "admin".into(),
247            vec!["vta".into()],
248            900,
249            false,
250        );
251        let token = keys.encode(&claims).unwrap();
252        let decoded = keys.decode(&token).unwrap();
253        assert_eq!(decoded.sub, "did:key:z6Mk");
254        assert_eq!(decoded.role, "admin");
255        assert!(!decoded.tee_attested);
256    }
257
258    #[test]
259    fn jti_defaults_empty_and_round_trips_when_set() {
260        let keys = test_keys();
261        // Default: no pin.
262        let plain = keys.new_claims(
263            "did:key:z6Mk".into(),
264            "s".into(),
265            "admin".into(),
266            vec![],
267            900,
268            false,
269        );
270        assert_eq!(plain.jti, "", "jti defaults empty (unpinned)");
271        assert!(!keys.encode(&plain).unwrap().is_empty());
272
273        // Pinned via the builder: survives encode → decode.
274        let pinned = keys
275            .new_claims(
276                "did:key:z6Mk".into(),
277                "s".into(),
278                "admin".into(),
279                vec![],
280                900,
281                false,
282            )
283            .with_jti("tok-abc123");
284        assert_eq!(pinned.jti, "tok-abc123");
285        let decoded = keys.decode(&keys.encode(&pinned).unwrap()).unwrap();
286        assert_eq!(
287            decoded.jti, "tok-abc123",
288            "jti must round-trip so the extractor pin can match"
289        );
290    }
291
292    #[test]
293    fn test_jwt_tee_attested_true() {
294        let keys = test_keys();
295        let claims = keys.new_claims(
296            "did:key:z6Mk".into(),
297            "sess-2".into(),
298            "admin".into(),
299            vec![],
300            900,
301            true,
302        );
303        let token = keys.encode(&claims).unwrap();
304
305        // Verify the raw JSON contains tee_attested
306        let parts: Vec<&str> = token.split('.').collect();
307        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
308            .decode(parts[1])
309            .unwrap();
310        let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
311        assert_eq!(json["tee_attested"], true);
312
313        let decoded = keys.decode(&token).unwrap();
314        assert!(decoded.tee_attested);
315    }
316
317    #[test]
318    fn test_jwt_tee_attested_false_omitted() {
319        let keys = test_keys();
320        let claims = keys.new_claims(
321            "did:key:z6Mk".into(),
322            "sess-3".into(),
323            "admin".into(),
324            vec![],
325            900,
326            false,
327        );
328        let token = keys.encode(&claims).unwrap();
329
330        // Verify tee_attested is NOT in the JSON (skip_serializing_if)
331        let parts: Vec<&str> = token.split('.').collect();
332        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
333            .decode(parts[1])
334            .unwrap();
335        let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
336        assert!(json.get("tee_attested").is_none());
337    }
338
339    #[test]
340    fn test_jwt_audience_parameterized() {
341        let vta_keys = JwtKeys::from_ed25519_bytes(&[0x42u8; 32], "VTA").unwrap();
342        let vtc_keys = JwtKeys::from_ed25519_bytes(&[0x42u8; 32], "VTC").unwrap();
343
344        // VTA token should decode with VTA keys
345        let claims = vta_keys.new_claims(
346            "did:key:z6Mk".into(),
347            "sess-1".into(),
348            "admin".into(),
349            vec![],
350            900,
351            false,
352        );
353        let token = vta_keys.encode(&claims).unwrap();
354        assert!(vta_keys.decode(&token).is_ok());
355        // VTA token should NOT decode with VTC audience
356        assert!(vtc_keys.decode(&token).is_err());
357
358        // VTC token should decode with VTC keys
359        let claims = vtc_keys.new_claims(
360            "did:key:z6Mk".into(),
361            "sess-2".into(),
362            "admin".into(),
363            vec![],
364            900,
365            false,
366        );
367        let token = vtc_keys.encode(&claims).unwrap();
368        assert!(vtc_keys.decode(&token).is_ok());
369        assert!(vta_keys.decode(&token).is_err());
370    }
371
372    // ── Rejection tests ─────────────────────────────────────────────
373    //
374    // The textbook JWT bypasses: expired tokens, `alg: none` attacks,
375    // tampered signatures, wrong signer, missing required claims.
376    // These assert the jsonwebtoken crate's defaults are actually on
377    // in our wrapper — a misconfigured Validation would silently
378    // accept any of them.
379
380    use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
381
382    /// Rewrite a JWT's payload with extra mutations applied, re-signing
383    /// with the provided keys so the signature stays valid. Used to
384    /// test that decode rejects claim-level issues (expiry, missing
385    /// fields) rather than accidentally asserting signature failure.
386    fn reencode_with<F: FnOnce(&mut serde_json::Value)>(
387        keys: &JwtKeys,
388        claims: &Claims,
389        mutate: F,
390    ) -> String {
391        let mut payload = serde_json::to_value(claims).unwrap();
392        mutate(&mut payload);
393        let header = Header::new(Algorithm::EdDSA);
394        let header_json = serde_json::to_vec(&header).unwrap();
395        let payload_json = serde_json::to_vec(&payload).unwrap();
396        let signing_input = format!(
397            "{}.{}",
398            B64URL.encode(&header_json),
399            B64URL.encode(&payload_json)
400        );
401        // Re-sign via the wrapper's own encode path by reconstructing
402        // via jsonwebtoken. Simplest: just encode a fresh Claims whose
403        // serde repr matches our mutated payload.
404        let mutated: Claims = serde_json::from_value(payload).unwrap();
405        let _ = signing_input;
406        keys.encode(&mutated).unwrap()
407    }
408
409    #[test]
410    fn decode_rejects_expired_token() {
411        let keys = test_keys();
412        let expired = keys.new_claims(
413            "did:key:z6Mk".into(),
414            "sess-expired".into(),
415            "admin".into(),
416            vec![],
417            900,
418            false,
419        );
420        // Drop exp into the past before signing.
421        let past_token = reencode_with(&keys, &expired, |payload| {
422            payload["exp"] = serde_json::json!(1);
423        });
424
425        let err = keys
426            .decode(&past_token)
427            .expect_err("expired token must be rejected");
428        assert!(matches!(err, AppError::Unauthorized(_)), "got {err:?}");
429    }
430
431    #[test]
432    fn decode_rejects_tampered_signature() {
433        let keys = test_keys();
434        let claims = keys.new_claims(
435            "did:key:z6Mk".into(),
436            "sess-tamper".into(),
437            "admin".into(),
438            vec![],
439            900,
440            false,
441        );
442        let token = keys.encode(&claims).unwrap();
443
444        // Flip one byte in the signature segment.
445        let mut parts: Vec<&str> = token.split('.').collect();
446        assert_eq!(parts.len(), 3);
447        let mut sig_bytes = B64URL.decode(parts[2]).unwrap();
448        sig_bytes[0] ^= 0x01;
449        let tampered_sig = B64URL.encode(&sig_bytes);
450        parts[2] = &tampered_sig;
451        let tampered = parts.join(".");
452
453        let err = keys
454            .decode(&tampered)
455            .expect_err("tampered signature must be rejected");
456        assert!(matches!(err, AppError::Unauthorized(_)), "got {err:?}");
457    }
458
459    #[test]
460    fn decode_rejects_alg_none_header() {
461        // Classic JWT bypass: forge a header claiming alg=none and
462        // omit the signature. The decoder must reject it — only EdDSA
463        // is accepted. A naive `Validation::default()` would allow
464        // alg=none in some jsonwebtoken versions; our wrapper pins
465        // Algorithm::EdDSA which prevents that.
466        let keys = test_keys();
467        let claims = keys.new_claims(
468            "did:key:z6Mk".into(),
469            "sess-none".into(),
470            "admin".into(),
471            vec![],
472            900,
473            false,
474        );
475        let payload = serde_json::to_vec(&claims).unwrap();
476        let none_header = r#"{"typ":"JWT","alg":"none"}"#;
477        let header_b64 = B64URL.encode(none_header.as_bytes());
478        let payload_b64 = B64URL.encode(&payload);
479        // No signature — some none-accepting parsers still want the
480        // trailing dot. Try both shapes.
481        for forged in [
482            format!("{header_b64}.{payload_b64}."),
483            format!("{header_b64}.{payload_b64}"),
484        ] {
485            let err = keys.decode(&forged).expect_err("alg=none must be rejected");
486            assert!(
487                matches!(err, AppError::Unauthorized(_)),
488                "got {err:?} for shape {forged:?}"
489            );
490        }
491    }
492
493    #[test]
494    fn decode_rejects_foreign_signer() {
495        // A token signed by a different JWT key must not decode,
496        // regardless of audience match.
497        let genuine = test_keys();
498        let attacker = JwtKeys::from_ed25519_bytes(&[0xAAu8; 32], "VTA").unwrap();
499
500        let claims = attacker.new_claims(
501            "did:key:zForged".into(),
502            "sess-forged".into(),
503            "admin".into(),
504            vec![],
505            900,
506            false,
507        );
508        let forged = attacker.encode(&claims).unwrap();
509
510        let err = genuine
511            .decode(&forged)
512            .expect_err("token signed by foreign key must be rejected");
513        assert!(matches!(err, AppError::Unauthorized(_)), "got {err:?}");
514    }
515
516    #[test]
517    fn decode_rejects_missing_required_claims() {
518        // set_required_spec_claims(["exp","sub","aud","session_id","role"])
519        // is load-bearing — a caller that drops any of these shouldn't
520        // slip through. Build a JWT manually with no `exp` and confirm
521        // decode rejects it.
522        let keys = test_keys();
523        let claims = keys.new_claims(
524            "did:key:z6Mk".into(),
525            "sess-missing".into(),
526            "admin".into(),
527            vec![],
528            900,
529            false,
530        );
531        // Encode normally, then rewrite the payload without `exp`.
532        let mut payload = serde_json::to_value(&claims).unwrap();
533        payload.as_object_mut().unwrap().remove("exp");
534        let payload_bytes = serde_json::to_vec(&payload).unwrap();
535        let header = Header::new(Algorithm::EdDSA);
536        let header_bytes = serde_json::to_vec(&header).unwrap();
537
538        // Naively build + sign with the SAME keypair so only the
539        // missing-claim check can reject this.
540        let signing_input = format!(
541            "{}.{}",
542            B64URL.encode(&header_bytes),
543            B64URL.encode(&payload_bytes)
544        );
545        // Compute the raw Ed25519 signature over the signing input.
546        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]);
547        use ed25519_dalek::Signer;
548        let sig = signing_key.sign(signing_input.as_bytes());
549        let forged = format!("{signing_input}.{}", B64URL.encode(sig.to_bytes()));
550
551        let err = keys
552            .decode(&forged)
553            .expect_err("token missing `exp` must be rejected");
554        assert!(matches!(err, AppError::Unauthorized(_)), "got {err:?}");
555    }
556
557    #[test]
558    fn decode_rejects_empty_token() {
559        let keys = test_keys();
560        assert!(matches!(keys.decode(""), Err(AppError::Unauthorized(_))));
561    }
562
563    #[test]
564    fn decode_rejects_malformed_structure() {
565        let keys = test_keys();
566        for bad in ["not-a-jwt", "only.two", "four.dot.separated.parts"] {
567            let err = keys
568                .decode(bad)
569                .expect_err(&format!("{bad:?} must be rejected"));
570            assert!(matches!(err, AppError::Unauthorized(_)), "got {err:?}");
571        }
572    }
573}