rusty_paseto 0.10.0

A type-driven, ergonomic alternative to JWT for secure stateless PASETO tokens.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
use super::{Base64Encodable, Footer, PasetoError};
use std::str;

/// Maximum permitted size of an untrusted token string, in bytes.
///
/// Mirrors [`crate::core::Paseto::MAX_TOKEN_SIZE`]. Bounds the work an
/// attacker can force the parser to do on an input that would have failed
/// MAC/signature verification anyway.
pub(crate) const MAX_TOKEN_SIZE: usize = 64 * 1024;

/// Maximum permitted size of a token footer, in bytes (base64-encoded).
///
/// Mirrors [`crate::core::Paseto::MAX_FOOTER_SIZE`]. PASETO spec recommends
/// footers stay small (≤ 1024 bytes).
pub(crate) const MAX_FOOTER_SIZE: usize = 1024;

/// Represents a PASETO token that has been structurally parsed but **NOT** cryptographically verified.
///
/// This struct provides the ability to extract footer information from PASETO tokens before
/// cryptographic verification. This is essential for key rotation scenarios where the footer
/// contains key identifiers (e.g., `kid` claims) needed to select the correct verification key.
///
/// # Security Warning
///
/// ⚠️ **ALL DATA FROM THIS STRUCT IS UNTRUSTED** ⚠️
///
/// The footer and all other token components have **NOT** been cryptographically verified.
/// - **DO** use footer contents for key selection and lookup
/// - **DO NOT** use footer contents for security decisions
/// - **DO NOT** trust any data until the token has been verified via [`Paseto::try_decrypt`] or [`Paseto::try_verify`]
///
/// The footer is authenticated as part of the PASETO token but only validated during the
/// verification process. An attacker could craft a token with any footer contents, so treat
/// this data as hostile input suitable only for selecting which key to attempt verification with.
///
/// # Usage
///
/// ```
/// # use rusty_paseto::core::*;
/// # fn example() -> Result<(), PasetoError> {
/// // Parse the untrusted token to extract the footer
/// let token = "v4.local.payload.eyJraWQiOiJrZXktMSJ9"; // footer: {"kid":"key-1"}
/// let untrusted = UntrustedToken::try_parse(token)?;
///
/// // Extract the footer (UNTRUSTED - only for key lookup)
/// if let Some(footer_str) = untrusted.footer_str()? {
///     // Parse footer to get key identifier
///     // (In real code, validate/sanitize the footer format)
///
///     // Use the key ID to select the appropriate key
///     // let key = key_store.get(kid)?;
///
///     // Now verify the token with the selected key
///     // let payload = Paseto::<V4, Local>::try_decrypt(&token, &key, Footer::from(footer_str), None)?;
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Copy)]
pub struct UntrustedToken<'a> {
    version: &'a str,
    purpose: &'a str,
    footer: Option<&'a str>,
}

impl<'a> UntrustedToken<'a> {
    /// Parse a PASETO token string into its structural components without any cryptographic verification.
    ///
    /// This method validates only the basic token format (3-4 dot-separated parts) and performs
    /// **NO** cryptographic operations. The returned struct contains references to the token's
    /// components but provides no guarantees about their validity or authenticity.
    ///
    /// # Security
    ///
    /// ⚠️ This method performs **ZERO** cryptographic verification. All returned data is untrusted.
    ///
    /// # Token Format
    ///
    /// Valid PASETO tokens follow the format:
    /// ```text
    /// v{version}.{purpose}.{payload}[.{footer}]
    /// ```
    ///
    /// - `version`: PASETO protocol version (e.g., "v4")
    /// - `purpose`: Either "local" (symmetric) or "public" (asymmetric)
    /// - `payload`: Base64url-encoded encrypted payload or signature
    /// - `footer`: Optional base64url-encoded footer
    ///
    /// # Errors
    ///
    /// Returns [`PasetoError::IncorrectSize`] if the token does not contain exactly 3 or 4 dot-separated parts.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// let token = "v4.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAr68PS4AXe7If_ZgesdkUMvSwscFlAl1pk5HC0e8kApeaqMfGo_7OpBnwJOAbY9V7WU6abu74MmcUE8YWAiaArVI8XJ5hOb_4v9RmDkneN0S92dx0OW4pgy7omxgf3S8c3LlQg";
    ///
    /// let untrusted = UntrustedToken::try_parse(token)?;
    /// assert_eq!(untrusted.version(), "v4");
    /// assert_eq!(untrusted.purpose(), "local");
    /// # Ok(())
    /// # }
    /// ```
    pub fn try_parse(token: &'a str) -> Result<Self, PasetoError> {
        // Reject oversized tokens before splitting/allocating — bounds the
        // work an attacker can force the parser to do on inputs that have
        // not yet been cryptographically verified.
        if token.len() > MAX_TOKEN_SIZE {
            return Err(PasetoError::TokenTooLarge);
        }

        let parts: Vec<&str> = token.split('.').collect();

        // PASETO tokens must have exactly 3 parts (no footer) or 4 parts (with footer)
        let parts_len = parts.len();
        if !(3..=4).contains(&parts_len) {
            return Err(PasetoError::IncorrectSize);
        }

        // Use safe .get() access - these are guaranteed to exist after length validation
        let version = parts.first().ok_or(PasetoError::IncorrectSize)?;
        let purpose = parts.get(1).ok_or(PasetoError::IncorrectSize)?;
        let footer = if parts_len == 4 {
            let f = *parts.get(3).ok_or(PasetoError::IncorrectSize)?;
            if f.len() > MAX_FOOTER_SIZE {
                return Err(PasetoError::FooterTooLarge);
            }
            Some(f)
        } else {
            None
        };

        Ok(Self {
            version,
            purpose,
            footer,
        })
    }

    /// Returns the PASETO version string (e.g., "v4").
    ///
    /// ⚠️ **UNTRUSTED**: This value has not been cryptographically verified.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// let token = "v4.local.payload";
    /// let untrusted = UntrustedToken::try_parse(token)?;
    /// assert_eq!(untrusted.version(), "v4");
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub const fn version(&self) -> &str {
        self.version
    }

    /// Returns the PASETO purpose string: either "local" (symmetric) or "public" (asymmetric).
    ///
    /// ⚠️ **UNTRUSTED**: This value has not been cryptographically verified.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// let token = "v4.local.payload";
    /// let untrusted = UntrustedToken::try_parse(token)?;
    /// assert_eq!(untrusted.purpose(), "local");
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub const fn purpose(&self) -> &str {
        self.purpose
    }

    /// Returns the raw base64url-encoded footer string if present.
    ///
    /// Returns `None` if the token does not contain a footer (3-part token).
    ///
    /// ⚠️ **UNTRUSTED**: This value has not been cryptographically verified.
    /// Only use for key selection, never for security decisions.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// // Token with footer
    /// let token_with_footer = "v4.local.payload.Zm9vdGVy";
    /// let untrusted = UntrustedToken::try_parse(token_with_footer)?;
    /// assert!(untrusted.footer_base64().is_some());
    ///
    /// // Token without footer
    /// let token_without_footer = "v4.local.payload";
    /// let untrusted = UntrustedToken::try_parse(token_without_footer)?;
    /// assert!(untrusted.footer_base64().is_none());
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub const fn footer_base64(&self) -> Option<&str> {
        self.footer
    }

    /// Decodes and returns the footer as raw bytes if present.
    ///
    /// Returns `None` if the token does not contain a footer.
    ///
    /// ⚠️ **UNTRUSTED**: This value has not been cryptographically verified.
    /// Only use for key selection, never for security decisions.
    ///
    /// # Errors
    ///
    /// Returns [`PasetoError::PayloadBase64Decode`] if the footer is present but contains invalid base64url encoding.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// let token = "v4.local.payload.Zm9vdGVy"; // footer base64: "footer"
    /// let untrusted = UntrustedToken::try_parse(token)?;
    ///
    /// if let Some(footer_bytes) = untrusted.footer_decoded()? {
    ///     assert_eq!(footer_bytes, b"footer");
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn footer_decoded(&self) -> Result<Option<Vec<u8>>, PasetoError> {
        match self.footer {
            Some(footer_b64) => {
                let decoded = Footer::from(footer_b64).decode()?;
                Ok(Some(decoded))
            }
            None => Ok(None),
        }
    }

    /// Decodes and returns the footer as a UTF-8 string if present.
    ///
    /// Returns `None` if the token does not contain a footer.
    ///
    /// ⚠️ **UNTRUSTED**: This value has not been cryptographically verified.
    /// Only use for key selection, never for security decisions.
    ///
    /// # Errors
    ///
    /// Returns [`PasetoError::PayloadBase64Decode`] if the footer contains invalid base64url encoding.
    ///
    /// Returns [`PasetoError::Utf8Error`] if the decoded footer is not valid UTF-8.
    ///
    /// # Example
    ///
    /// ```
    /// # use rusty_paseto::core::*;
    /// # fn example() -> Result<(), PasetoError> {
    /// let token = "v4.local.payload.eyJraWQiOiJrZXktMSJ9"; // {"kid":"key-1"}
    /// let untrusted = UntrustedToken::try_parse(token)?;
    ///
    /// if let Some(footer_str) = untrusted.footer_str()? {
    ///     // footer_str is now "{\"kid\":\"key-1\"}"
    ///     // Parse as JSON to extract key identifier
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub fn footer_str(&self) -> Result<Option<String>, PasetoError> {
        match self.footer_decoded()? {
            Some(bytes) => {
                let s = str::from_utf8(&bytes)?.to_string();
                Ok(Some(s))
            }
            None => Ok(None),
        }
    }
}

#[cfg(test)]
mod unit_tests {
    use super::*;

    #[test]
    fn test_parse_token_with_footer() {
        let token = "v4.local.payload.Zm9vdGVy"; // footer = "footer" in base64url
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        assert_eq!(untrusted.version(), "v4");
        assert_eq!(untrusted.purpose(), "local");
        assert_eq!(untrusted.footer_base64(), Some("Zm9vdGVy"));
    }

    #[test]
    fn test_parse_token_without_footer() {
        let token = "v4.local.payload";
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        assert_eq!(untrusted.version(), "v4");
        assert_eq!(untrusted.purpose(), "local");
        assert!(untrusted.footer_base64().is_none());
    }

    #[test]
    fn test_parse_token_too_few_parts() {
        let token = "v4.local";
        let result = UntrustedToken::try_parse(token);

        assert!(matches!(result, Err(PasetoError::IncorrectSize)));
    }

    #[test]
    fn test_parse_token_too_many_parts() {
        let token = "v4.local.payload.footer.extra";
        let result = UntrustedToken::try_parse(token);

        assert!(matches!(result, Err(PasetoError::IncorrectSize)));
    }

    #[test]
    fn test_footer_decoded() {
        let token = "v4.local.payload.Zm9vdGVy"; // footer = "footer" in base64url
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        let footer_bytes = untrusted
            .footer_decoded()
            .expect("failed to decode footer")
            .expect("footer should be present");

        assert_eq!(footer_bytes, b"footer");
    }

    #[test]
    fn test_footer_decoded_returns_none_when_no_footer() {
        let token = "v4.local.payload";
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        let footer_bytes = untrusted.footer_decoded().expect("should not error");

        assert!(footer_bytes.is_none());
    }

    #[test]
    fn test_footer_str() {
        let token = "v4.local.payload.Zm9vdGVy"; // footer = "footer" in base64url
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        let footer_str = untrusted
            .footer_str()
            .expect("failed to decode footer")
            .expect("footer should be present");

        assert_eq!(&footer_str, "footer");
    }

    #[test]
    fn test_footer_str_json() {
        // {"kid":"key-1"} in base64url
        let token = "v4.local.payload.eyJraWQiOiJrZXktMSJ9";
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        let footer_str = untrusted
            .footer_str()
            .expect("failed to decode footer")
            .expect("footer should be present");

        assert_eq!(&footer_str, r#"{"kid":"key-1"}"#);
    }

    #[test]
    fn test_invalid_base64_in_footer() {
        let token = "v4.local.payload.!!!invalid!!!";
        let untrusted = UntrustedToken::try_parse(token).expect("failed to parse token");

        let result = untrusted.footer_decoded();
        assert!(result.is_err());
    }

    #[test]
    fn test_all_versions() {
        for version in &["v1", "v2", "v3", "v4"] {
            let token = format!("{}.local.payload", version);
            let untrusted = UntrustedToken::try_parse(&token).expect("failed to parse token");
            assert_eq!(untrusted.version(), *version);
        }
    }

    #[test]
    fn test_both_purposes() {
        for purpose in &["local", "public"] {
            let token = format!("v4.{}.payload", purpose);
            let untrusted = UntrustedToken::try_parse(&token).expect("failed to parse token");
            assert_eq!(untrusted.purpose(), *purpose);
        }
    }

    #[test]
    fn test_oversized_token_rejected() {
        // Build a token that just exceeds MAX_TOKEN_SIZE
        let oversized_payload = "A".repeat(MAX_TOKEN_SIZE);
        let token = format!("v4.local.{oversized_payload}");
        assert!(token.len() > MAX_TOKEN_SIZE);

        let result = UntrustedToken::try_parse(&token);
        assert!(matches!(result, Err(PasetoError::TokenTooLarge)));
    }

    #[test]
    fn test_oversized_footer_rejected() {
        let oversized_footer = "A".repeat(MAX_FOOTER_SIZE + 1);
        let token = format!("v4.local.payload.{oversized_footer}");
        // Token itself stays under MAX_TOKEN_SIZE; the footer is what trips
        assert!(token.len() < MAX_TOKEN_SIZE);

        let result = UntrustedToken::try_parse(&token);
        assert!(matches!(result, Err(PasetoError::FooterTooLarge)));
    }

    #[test]
    fn test_footer_at_max_size_accepted() {
        let max_footer = "A".repeat(MAX_FOOTER_SIZE);
        let token = format!("v4.local.payload.{max_footer}");
        let untrusted = UntrustedToken::try_parse(&token).expect("should accept footer at limit");
        assert!(untrusted.footer_base64().is_some());
    }
}