biscotti/
processor.rs

1use crate::ProcessorConfig;
2use crate::crypto::Key;
3use crate::crypto::encryption::EncryptionKey;
4use crate::crypto::signing::SigningKey;
5use crate::encoding::encode;
6use crate::{RequestCookie, ResponseCookie, config};
7use percent_encoding::percent_decode;
8use std::collections::HashMap;
9use std::str::Utf8Error;
10
11/// Transforms cookies before they are sent to the client, or after they have been parsed from an incoming request.
12///
13/// # Creating a `Processor`
14///
15/// A processor is created from a [`ProcessorConfig`] using the [`From`] trait.
16///
17/// ```rust
18/// use biscotti::{Processor, ProcessorConfig, Key};
19/// use biscotti::config::{CryptoRule, CryptoAlgorithm};
20///
21/// let mut config = ProcessorConfig::default();
22/// config.crypto_rules.push(CryptoRule {
23///     cookie_names: vec!["session".to_string()],
24///     algorithm: CryptoAlgorithm::Encryption,
25///     // You'll use a key loaded from *somewhere* in production—e.g.
26///     // from a file, environment variable, or a secret management service.
27///     key: Key::generate(),
28///     fallbacks: vec![],
29/// });
30/// let processor: Processor = config.into();
31/// ```
32///
33/// # Using a `Processor`
34///
35/// You need a `Processor`
36/// to invoke [`ResponseCookies::header_values`] and [`RequestCookies::parse_header`].
37/// You can also use it to transform individual cookies using
38/// [`Processor::process_outgoing`] and [`Processor::process_incoming`].
39///
40/// [`ResponseCookies::header_values`]: crate::ResponseCookies::header_values
41/// [`RequestCookies::parse_header`]: crate::RequestCookies::parse_header
42#[derive(Debug, Clone)]
43pub struct Processor {
44    percent_encode: bool,
45    rules: HashMap<String, Rule>,
46}
47
48impl From<ProcessorConfig> for Processor {
49    fn from(value: ProcessorConfig) -> Self {
50        let mut processor = Processor {
51            percent_encode: value.percent_encode,
52            rules: HashMap::new(),
53        };
54
55        for rule in value.crypto_rules.into_iter() {
56            let primary = CryptoConfig::new(&rule.key, rule.algorithm.into());
57            let fallbacks: Vec<_> = rule
58                .fallbacks
59                .into_iter()
60                .map(|config| CryptoConfig::new(&config.key, config.algorithm.into()))
61                .collect();
62            for name in rule.cookie_names {
63                processor.rules.insert(
64                    name,
65                    Rule {
66                        primary,
67                        fallbacks: fallbacks.clone(),
68                    },
69                );
70            }
71        }
72        processor
73    }
74}
75
76impl Processor {
77    /// Transform a [`ResponseCookie`] before it is sent to the client.
78    pub fn process_outgoing<'c>(&self, mut cookie: ResponseCookie<'c>) -> ResponseCookie<'c> {
79        if self.percent_encode {
80            let name = encode(&cookie.name).to_string();
81            cookie.name = name.into();
82        }
83        if let Some(rule) = self.rules.get(cookie.name.as_ref()) {
84            let value = rule.primary.process_outgoing(&cookie.name, &cookie.value);
85            cookie.value = value.into();
86        } else {
87            // We don't need to percent-encode the value if we're encrypting or signing it.
88            // The signing/encryption process is guaranteed to return a value that is safe to use
89            // in a cookie.
90            if self.percent_encode {
91                let value = encode(&cookie.value).to_string();
92                cookie.value = value.into();
93            }
94        }
95
96        cookie
97    }
98
99    /// Returns `true` if a cookie with the given name will be encrypted before
100    /// being sent back to the client in the response.
101    ///
102    /// Returns `false` if the cookie will be signed or returned as is.
103    pub fn will_encrypt(&self, name: &str) -> bool {
104        self.rules
105            .get(name)
106            .is_some_and(|rule| matches!(rule.primary, CryptoConfig::Encryption { .. }))
107    }
108
109    /// Returns `true` if a cookie with the given name will be signed before
110    /// being sent back to the client in the response.
111    ///
112    /// Returns `false` if the cookie will be encrypted or returned as is.
113    pub fn will_sign(&self, name: &str) -> bool {
114        self.rules
115            .get(name)
116            .is_some_and(|rule| matches!(rule.primary, CryptoConfig::Signing { .. }))
117    }
118
119    /// Transform a [`RequestCookie`] before it is added to [`ResponseCookies`].
120    ///
121    /// [`ResponseCookies`]: crate::ResponseCookies
122    pub fn process_incoming<'c>(
123        &self,
124        name: &'c str,
125        value: &'c str,
126    ) -> Result<RequestCookie<'c>, ProcessIncomingError> {
127        let mut cookie = RequestCookie {
128            name: name.into(),
129            value: value.into(),
130        };
131
132        let mut decode_value = false;
133
134        if let Some(rule) = self.rules.get(name) {
135            let configs = std::iter::once(rule.primary).chain(rule.fallbacks.iter().copied());
136            let value = 'outer: {
137                let mut error = None;
138                for config in configs {
139                    let outcome = config.process_incoming(name, value);
140                    match outcome {
141                        Ok(value) => {
142                            break 'outer value;
143                        }
144                        Err(e) => {
145                            if error.is_none() {
146                                // We only want to keep the first error.
147                                error = Some(e);
148                            }
149                        }
150                    }
151                }
152                // If we reach this point, we've tried all the keys and none of them worked.
153                return Err(error.unwrap().into());
154            };
155            cookie.value = value.into();
156        } else {
157            decode_value = true;
158        }
159        if self.percent_encode {
160            cookie.name =
161                percent_decode(name.as_bytes())
162                    .decode_utf8()
163                    .map_err(|e| DecodingError {
164                        invalid_part: InvalidCookiePart::Name {
165                            raw_value: name.to_string(),
166                        },
167                        source: e,
168                    })?;
169        }
170
171        if self.percent_encode && decode_value {
172            cookie.value = percent_decode(value.as_bytes())
173                .decode_utf8()
174                .map_err(|e| DecodingError {
175                    invalid_part: InvalidCookiePart::Value {
176                        cookie_name: cookie.name.clone().into_owned(),
177                        raw_value: value.to_string(),
178                    },
179                    source: e,
180                })?;
181        }
182
183        Ok(cookie)
184    }
185}
186
187#[derive(Debug)]
188#[non_exhaustive]
189/// The error returned by [`Processor::process_incoming`].
190pub enum ProcessIncomingError {
191    Crypto(CryptoError),
192    Decoding(DecodingError),
193}
194
195impl std::fmt::Display for ProcessIncomingError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            ProcessIncomingError::Crypto(e) => e.fmt(f),
199            ProcessIncomingError::Decoding(e) => e.fmt(f),
200        }
201    }
202}
203
204impl std::error::Error for ProcessIncomingError {
205    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
206        match self {
207            ProcessIncomingError::Crypto(e) => Some(e),
208            ProcessIncomingError::Decoding(e) => Some(e),
209        }
210    }
211}
212
213impl From<CryptoError> for ProcessIncomingError {
214    fn from(value: CryptoError) -> Self {
215        ProcessIncomingError::Crypto(value)
216    }
217}
218
219impl From<DecodingError> for ProcessIncomingError {
220    fn from(value: DecodingError) -> Self {
221        ProcessIncomingError::Decoding(value)
222    }
223}
224
225#[derive(Debug)]
226/// An error that occurred while decrypting or verifying an incoming request cookie.
227///
228/// This error is returned by [`Processor::process_incoming`].
229pub struct CryptoError {
230    name: String,
231    r#type: CryptoAlgorithm,
232    source: anyhow::Error,
233}
234
235impl std::fmt::Display for CryptoError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        let t = match self.r#type {
238            CryptoAlgorithm::Encryption => "an encrypted",
239            CryptoAlgorithm::Signing => "a signed",
240        };
241        write!(f, "Failed to process `{}` as {t} request cookie", self.name)
242    }
243}
244
245impl std::error::Error for CryptoError {
246    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
247        Some(self.source.as_ref())
248    }
249}
250
251#[derive(Debug)]
252/// An error that occurred while decoding a percent-encoded cookie name or value.
253///
254/// This error is returned by [`Processor::process_incoming`].
255pub struct DecodingError {
256    invalid_part: InvalidCookiePart,
257    source: Utf8Error,
258}
259
260impl std::fmt::Display for DecodingError {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        match &self.invalid_part {
263            InvalidCookiePart::Name { raw_value } => {
264                write!(
265                    f,
266                    "Failed to percent-decode the name of a cookie: `{raw_value}`",
267                )
268            }
269            InvalidCookiePart::Value {
270                cookie_name,
271                raw_value,
272            } => {
273                write!(
274                    f,
275                    "Failed to percent-decode the value of the `{cookie_name}` cookie: `{raw_value}`",
276                )
277            }
278        }
279    }
280}
281
282impl std::error::Error for DecodingError {
283    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
284        Some(&self.source)
285    }
286}
287
288#[derive(Debug, Eq, PartialEq)]
289pub(crate) enum InvalidCookiePart {
290    Name {
291        raw_value: String,
292    },
293    Value {
294        cookie_name: String,
295        raw_value: String,
296    },
297}
298
299#[derive(Debug, Clone)]
300struct Rule {
301    primary: CryptoConfig,
302    fallbacks: Vec<CryptoConfig>,
303}
304
305#[derive(Debug, Clone, Copy)]
306enum CryptoConfig {
307    Encryption { key: EncryptionKey },
308    Signing { key: SigningKey },
309}
310
311impl CryptoConfig {
312    pub fn new(master_key: &Key, crypto_algorithm: CryptoAlgorithm) -> Self {
313        match crypto_algorithm {
314            CryptoAlgorithm::Encryption => {
315                let key = EncryptionKey::derive(master_key);
316                CryptoConfig::Encryption { key }
317            }
318            CryptoAlgorithm::Signing => {
319                let key = SigningKey::derive(master_key);
320                CryptoConfig::Signing { key }
321            }
322        }
323    }
324
325    /// Process a cookie value received from the client, either by verifying it or decrypting it.
326    fn process_incoming(&self, name: &str, value: &str) -> Result<String, CryptoError> {
327        match self {
328            Self::Encryption { key } => {
329                key.decrypt(name.as_bytes(), value.as_bytes())
330                    .map_err(|e| CryptoError {
331                        name: name.to_owned(),
332                        r#type: CryptoAlgorithm::Encryption,
333                        source: e,
334                    })
335            }
336            Self::Signing { key } => key.verify(name, value).map_err(|e| CryptoError {
337                name: name.to_owned(),
338                r#type: CryptoAlgorithm::Signing,
339                source: e,
340            }),
341        }
342    }
343
344    /// Process a cookie to be sent to the client, either by signing it or encrypting it.
345    fn process_outgoing(&self, name: &str, value: &str) -> String {
346        match self {
347            Self::Encryption { key } => key.encrypt(name.as_bytes(), value.as_bytes()),
348            Self::Signing { key } => key.sign(name, value),
349        }
350    }
351}
352
353#[derive(Debug, Clone, Copy)]
354enum CryptoAlgorithm {
355    Encryption,
356    Signing,
357}
358
359impl std::fmt::Display for CryptoAlgorithm {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        match self {
362            CryptoAlgorithm::Encryption => write!(f, "encryption"),
363            CryptoAlgorithm::Signing => write!(f, "signing"),
364        }
365    }
366}
367
368impl From<config::CryptoAlgorithm> for CryptoAlgorithm {
369    fn from(value: config::CryptoAlgorithm) -> Self {
370        match value {
371            config::CryptoAlgorithm::Encryption => CryptoAlgorithm::Encryption,
372            config::CryptoAlgorithm::Signing => CryptoAlgorithm::Signing,
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use crate::config::{CryptoAlgorithm, CryptoRule, FallbackConfig};
380    use crate::encoding::encode;
381    use crate::{Key, Processor, ProcessorConfig, RequestCookies, ResponseCookie};
382    use std::error::Error;
383
384    #[test]
385    fn roundtrip_encryption() {
386        let name = "encrypted";
387        let unencrypted_value = "tamper-proof";
388        let processor: Processor = ProcessorConfig {
389            crypto_rules: vec![CryptoRule {
390                cookie_names: vec![name.to_string()],
391                algorithm: CryptoAlgorithm::Encryption,
392                key: Key::generate(),
393                fallbacks: vec![],
394            }],
395            ..Default::default()
396        }
397        .into();
398
399        assert!(processor.will_encrypt(name));
400        assert!(!processor.will_sign(name));
401
402        let cookie = ResponseCookie::new(name, unencrypted_value);
403        let encrypted_cookie = processor.process_outgoing(cookie);
404        assert_ne!(encrypted_cookie.value(), unencrypted_value);
405        // The encrypted value should be safe to use in a cookie.
406        assert_eq!(
407            encode(encrypted_cookie.value()).to_string(),
408            encrypted_cookie.value()
409        );
410
411        let header = format!("{}={}", encrypted_cookie.name(), encrypted_cookie.value());
412        let request_cookies = RequestCookies::parse_header(&header, &processor)
413            .expect("Failed to parse the encrypted cookie");
414        let decrypted_cookie = request_cookies
415            .get(name)
416            .expect("Failed to get the decrypted cookie");
417
418        assert_eq!(decrypted_cookie.name(), name);
419        assert_eq!(decrypted_cookie.value(), unencrypted_value);
420    }
421
422    #[test]
423    fn roundtrip_signing() {
424        let name = "signed";
425        let value = "tamper-proof";
426        let processor: Processor = ProcessorConfig {
427            crypto_rules: vec![CryptoRule {
428                cookie_names: vec![name.to_string()],
429                algorithm: CryptoAlgorithm::Signing,
430                key: Key::generate(),
431                fallbacks: vec![],
432            }],
433            ..Default::default()
434        }
435        .into();
436
437        assert!(!processor.will_encrypt(name));
438        assert!(processor.will_sign(name));
439
440        let cookie = ResponseCookie::new(name, value);
441        let signed_cookie = processor.process_outgoing(cookie);
442        assert_ne!(signed_cookie.value(), value);
443
444        let header = format!("{}={}", signed_cookie.name(), signed_cookie.value());
445        let request_cookies = RequestCookies::parse_header(&header, &processor)
446            .expect("Failed to parse the signed cookie");
447        let verified_cookie = request_cookies
448            .get(name)
449            .expect("Failed to get the signed cookie");
450
451        assert_eq!(verified_cookie.name(), name);
452        assert_eq!(verified_cookie.value(), value);
453    }
454
455    #[test]
456    fn roundtrip_encoded() {
457        let name = "to be encoded";
458        let value = "a bunch of % very special ! # characters ;";
459        let processor: Processor = ProcessorConfig::default().into();
460
461        let cookie = ResponseCookie::new(name, value);
462        let encoded_cookie = processor.process_outgoing(cookie);
463        assert_ne!(encoded_cookie.name(), name);
464        assert_ne!(encoded_cookie.value(), value);
465
466        let header = format!("{}={}", encoded_cookie.name(), encoded_cookie.value());
467        let request_cookies = RequestCookies::parse_header(&header, &processor)
468            .expect("Failed to parse the decoded cookie");
469        let decoded_cookie = request_cookies
470            .get(name)
471            .expect("Failed to get the decoded cookie");
472
473        assert_eq!(decoded_cookie.name(), name);
474        assert_eq!(decoded_cookie.value(), value);
475    }
476
477    #[test]
478    fn unsigned_is_rejected() {
479        let name = "session";
480        let value = "a-value-that-should-be-signed-but-is-not";
481        let header = format!("{name}={value}");
482
483        let processor: Processor = ProcessorConfig {
484            crypto_rules: vec![CryptoRule {
485                cookie_names: vec![name.to_string()],
486                algorithm: CryptoAlgorithm::Signing,
487                key: Key::generate(),
488                fallbacks: vec![],
489            }],
490            ..Default::default()
491        }
492        .into();
493        let err = RequestCookies::parse_header(&header, &processor)
494            .expect_err("A non-signed cookie passed verification, bad!");
495        assert_eq!(
496            err.to_string(),
497            "Failed to parse cookies out of a header value"
498        );
499        assert_eq!(
500            err.source().unwrap().to_string(),
501            "Failed to process `session` as a signed request cookie"
502        );
503    }
504
505    #[test]
506    fn unencrypted_is_rejected() {
507        let name = "session";
508        let value = "a-value-that-should-be-encrypted-but-is-not";
509        let header = format!("{name}={value}");
510
511        let processor: Processor = ProcessorConfig {
512            crypto_rules: vec![CryptoRule {
513                cookie_names: vec![name.to_string()],
514                algorithm: CryptoAlgorithm::Encryption,
515                key: Key::generate(),
516                fallbacks: vec![],
517            }],
518            ..Default::default()
519        }
520        .into();
521        let err = RequestCookies::parse_header(&header, &processor)
522            .expect_err("A non-encrypted cookie passed, bad!");
523        assert_eq!(
524            err.to_string(),
525            "Failed to parse cookies out of a header value"
526        );
527        assert_eq!(
528            err.source().unwrap().to_string(),
529            "Failed to process `session` as an encrypted request cookie"
530        );
531    }
532
533    #[test]
534    fn signed_with_secondary_is_fine() {
535        let name = "signed";
536        let value = "tamper-proof";
537        let primary_key = Key::generate();
538        let fallbacks = vec![
539            FallbackConfig {
540                key: Key::generate(),
541                algorithm: CryptoAlgorithm::Signing,
542            },
543            FallbackConfig {
544                key: Key::generate(),
545                algorithm: CryptoAlgorithm::Signing,
546            },
547            FallbackConfig {
548                key: Key::generate(),
549                algorithm: CryptoAlgorithm::Signing,
550            },
551        ];
552        let fallback = fallbacks[1].clone();
553
554        let processor: Processor = ProcessorConfig {
555            crypto_rules: vec![CryptoRule {
556                cookie_names: vec![name.to_string()],
557                algorithm: fallback.algorithm,
558                key: fallback.key.clone(),
559                fallbacks: vec![],
560            }],
561            ..Default::default()
562        }
563        .into();
564        let cookie = ResponseCookie::new(name, value);
565        // Signed with the secondary key.
566        let secured_cookie = processor.process_outgoing(cookie);
567        assert_ne!(secured_cookie.value(), value);
568
569        let header = format!("{}={}", secured_cookie.name(), secured_cookie.value());
570        let processor: Processor = ProcessorConfig {
571            crypto_rules: vec![CryptoRule {
572                cookie_names: vec![name.to_string()],
573                algorithm: CryptoAlgorithm::Signing,
574                // Primary key has changed!
575                key: primary_key.clone(),
576                fallbacks,
577            }],
578            ..Default::default()
579        }
580        .into();
581        let request_cookies = RequestCookies::parse_header(&header, &processor)
582            .expect("Failed to parse the signed cookie");
583        let verified_cookie = request_cookies
584            .get(name)
585            .expect("Failed to get the signed cookie");
586
587        assert_eq!(verified_cookie.name(), name);
588        assert_eq!(verified_cookie.value(), value);
589    }
590
591    #[test]
592    fn encrypted_with_secondary_is_fine() {
593        let name = "encrypted";
594        let value = "tamper-proof";
595        let primary_key = Key::generate();
596        let fallbacks = vec![
597            FallbackConfig {
598                key: Key::generate(),
599                algorithm: CryptoAlgorithm::Signing,
600            },
601            FallbackConfig {
602                key: Key::generate(),
603                algorithm: CryptoAlgorithm::Signing,
604            },
605            FallbackConfig {
606                key: Key::generate(),
607                algorithm: CryptoAlgorithm::Signing,
608            },
609        ];
610        let fallback = fallbacks[1].clone();
611
612        let processor: Processor = ProcessorConfig {
613            crypto_rules: vec![CryptoRule {
614                cookie_names: vec![name.to_string()],
615                algorithm: fallback.algorithm,
616                key: fallback.key.clone(),
617                fallbacks: vec![],
618            }],
619            ..Default::default()
620        }
621        .into();
622        let cookie = ResponseCookie::new(name, value);
623        // Signed with the secondary key.
624        let secured_cookie = processor.process_outgoing(cookie);
625        assert_ne!(secured_cookie.value(), value);
626
627        let header = format!("{}={}", secured_cookie.name(), secured_cookie.value());
628        let processor: Processor = ProcessorConfig {
629            crypto_rules: vec![CryptoRule {
630                cookie_names: vec![name.to_string()],
631                algorithm: CryptoAlgorithm::Encryption,
632                // Primary key has changed!
633                key: primary_key.clone(),
634                fallbacks,
635            }],
636            ..Default::default()
637        }
638        .into();
639        let request_cookies = RequestCookies::parse_header(&header, &processor)
640            .expect("Failed to parse the encrypted cookie");
641        let decrypted_cookie = request_cookies
642            .get(name)
643            .expect("Failed to get the encrypted cookie");
644
645        assert_eq!(decrypted_cookie.name(), name);
646        assert_eq!(decrypted_cookie.value(), value);
647    }
648}