rails_cookie_parser/
lib.rs

1//! # rails-cookie-parser
2//! 
3//! rails-cookie-parser is a decryption library for cookies generated by Rails.
4//! 
5//! ## Example
6//! ```rust
7//! use rails_cookie_parser::RailsCookieParser;
8//! 
9//! let rcp = RailsCookieParser::default();
10//! rcp.decipher_cookie("test--foo--bar");
11//! ```
12//! 
13//! Has been tested to work with session cookies generated in Rails 5 up to 7.
14//! 
15//! I put a few more examples in the README, and in the unit tests.
16//! 
17//! ## Errors
18//! 
19//! The return type is `Result<String, ParseCookieError>`.
20//! 
21//! If an error happens in the process of decrypting a cookie, an error variant
22//! will be returned. See [`ParseCookieError`] for more details.
23#![allow(dead_code)]
24#![warn(missing_docs)]
25
26use base64::prelude::*;
27use pbkdf2::pbkdf2_hmac_array;
28use sha1::Sha1;
29use sha2::Sha256;
30use std::{env, fmt::Display};
31
32use aes_gcm::{
33  aead::{Aead, KeyInit},
34  Aes256Gcm, Key, Nonce,
35};
36
37/// Errors that can happen when decrypting your cookies
38#[derive(Clone, Debug, PartialEq)]
39pub enum ParseCookieError {
40  /// Base 64 parsing error
41  /// 
42  /// Occurs when an invalid character is present in your cookie.
43  CookieDecodingError(base64::DecodeError),
44  /// Cookie format error
45  /// 
46  /// Occurs if you supply a cookie which cannot be split in three properly.
47  CookieFormatError(usize),
48  /// UTF-8 decoding error
49  /// 
50  /// Occurs if your decoded data is not a proper UTF-8 string.
51  ResultEncodingError(std::string::FromUtf8Error),
52  /// Decryption error
53  /// 
54  /// If you reach this, I have no idea what to say.
55  AesError(aes_gcm::Error),
56}
57
58
59// Self reminder in case I need to test error messages again:
60// ```rust
61// println!("ERROR");
62// let x = result.err().unwrap();
63// println!("ERROR: {}", format!("{}", x));
64// panic!("Test")
65// ```
66impl Display for ParseCookieError {
67  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68    match self {
69      ParseCookieError::CookieDecodingError(e) =>
70        write!(f, "error decoding base64: {}", e.to_string()),
71      ParseCookieError::CookieFormatError(e) =>
72        write!(f, "wrong cookie format, expecting `3` parts, found `{}`", e),
73      ParseCookieError::ResultEncodingError(e) =>
74        write!(f, "error parsing string as UTF-8: {}", e.to_string()),
75      ParseCookieError::AesError(e) =>
76        write!(f, "error decrypting aes-gcm: {}", e.to_string())
77    }
78  }
79}
80
81// https://guides.rubyonrails.org/v7.1.3.2/upgrading_ruby_on_rails.html#active-record-encryption-algorithm-changes
82// Active Record Encryption now uses SHA-256 as its hash digest algorithm.
83
84/// Hash digest
85/// 
86/// Since Rails 7, the hash digest is `Sha256`. Before that, it was `Sha1`.
87pub enum HashDigest {
88  /// Sha1 hash digest
89  Sha1,
90  /// Sha256 hash digest
91  Sha256,
92}
93
94/// Base object and structure
95pub struct RailsCookieParser {
96  key: [u8; 32],
97}
98
99
100impl Default for RailsCookieParser {
101  /// Default implementation for RailsCookieParser
102  /// 
103  /// The default implementation is meant to be "plug and play" and uses Rails 7
104  /// defaults, which are:
105  /// * Key base is taken from ENV value for `SECRET_KEY_BASE`.
106  /// * Key salt is Rails default `"authenticated encrypted cookie"`.
107  /// * Iterations: `1000`.
108  /// * Hash digest is `Sha256` since Rails 7.
109  fn default() -> Self {
110    let default_key_base = match env::var("SECRET_KEY_BASE") {
111      Ok(env_key_base) => env_key_base,
112      Err(_) => {
113        eprintln!("rails-cookie-parser: No ENV set for SECRET_KEY_BASE");
114        "".to_owned()
115      }
116    };
117    let default_key_salt = match env::var("SECRET_KEY_SALT") {
118      Ok(env_key_salt) => env_key_salt,
119      Err(_) => RailsCookieParser::DEFAULT_KEY_SALT.to_owned(),
120    };
121    Self::new(
122      &default_key_base,
123      &default_key_salt,
124      1000,
125      HashDigest::Sha256,
126    )
127  }
128}
129
130impl RailsCookieParser {
131  const DEFAULT_KEY_SALT: &'static str = "authenticated encrypted cookie";
132
133  /// Default builder for a Rails 5 cookie
134  /// 
135  /// Calls the same implementation as [`Self::default_rails6()`].
136  pub fn default_rails5() -> Self {
137    Self::default_rails6()
138  }
139
140  /// Default builder for a Rails 6 cookie
141  /// 
142  /// Functions just like [`Self::default()`], but uses the pre-Rails 7 hash of Sha1.
143  pub fn default_rails6() -> Self {
144    let default_key_base = match env::var("SECRET_KEY_BASE") {
145      Ok(env_key_base) => env_key_base,
146      Err(_) => {
147        eprintln!("rails-cookie-parser: No ENV set for SECRET_KEY_BASE");
148        "".to_owned()
149      }
150    };
151    let default_key_salt = match env::var("SECRET_KEY_SALT") {
152      Ok(env_key_salt) => env_key_salt,
153      Err(_) => RailsCookieParser::DEFAULT_KEY_SALT.to_owned(),
154    };
155    Self::new(&default_key_base, &default_key_salt, 1000, HashDigest::Sha1)
156  }
157
158  /// Default builder for a Rails 7 cookie
159  /// 
160  /// Since Rails 7 is the latest stable, the implementation is exactly the same
161  /// as [`Self::default()`].
162  pub fn default_rails7() -> Self {
163    Self::default()
164  }
165
166  /// Creates a new decryption object
167  /// 
168  /// All arguments can be customised in case you have a very specific setup for
169  /// your Rails server.
170  /// 
171  /// Most of the time, you can use the default functions, but here, you can add
172  /// all the customisation you require:
173  /// * `key_base`: Your Rails `SECRET_KEY_BASE` string.
174  /// * `key_salt`: Your Rails salt string. If unsure, just use `"authenticated
175  ///   encrypted cookie"`.
176  /// * `iterations`: Unless you know you changed it, `1000`.
177  /// * `key_hash`: Can be `Sha1` or `Sha256` depending on your Rails version.
178  pub fn new(key_base: &str, key_salt: &str, iterations: u32, key_hash: HashDigest) -> Self {
179    let key_base = key_base.as_bytes().to_vec();
180    let key_salt = key_salt.as_bytes().to_vec();
181    let key = match key_hash {
182      HashDigest::Sha1 => pbkdf2_hmac_array::<Sha1, 32>(&key_base, &key_salt, iterations),
183      HashDigest::Sha256 => pbkdf2_hmac_array::<Sha256, 32>(&key_base, &key_salt, iterations),
184    };
185
186    RailsCookieParser { key }
187  }
188
189  fn decipher_aes(
190    &self,
191    payload: &[u8],
192    iv: &[u8],
193    auth_tag: &[u8],
194  ) -> Result<Vec<u8>, ParseCookieError> {
195    let decipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
196    let nonce = Nonce::from_slice(iv);
197    let new_payload = [payload, auth_tag].concat();
198    match decipher.decrypt(&nonce, new_payload.as_ref()) {
199      Ok(result) => Ok(result),
200      Err(x) => Err(ParseCookieError::AesError(x)),
201    }
202  }
203
204  /// Decipher a cookie into a JSON string
205  /// 
206  /// Only the decryption is done, this library will not tell you how to use the
207  /// resulting JSON.
208  pub fn decipher_cookie(&self, cookie: &str) -> Result<String, ParseCookieError> {
209    let parts: Result<Vec<_>, _> = cookie
210      .split("--")
211      .map(|part| BASE64_STANDARD.decode(part))
212      .collect();
213    // CookieDecodingError
214    let Ok(parts) = parts else {
215      return Err(ParseCookieError::CookieDecodingError(parts.err().unwrap()));
216    };
217    // Error: CookieFormatError
218    if parts.len() != 3 {
219      return Err(ParseCookieError::CookieFormatError(parts.len()));
220    }
221    let deciphered_cookie = match self.decipher_aes(&parts[0], &parts[1], &parts[2]) {
222      Ok(result) => result,
223      Err(err) => return Err(err),
224    };
225
226    match String::from_utf8(deciphered_cookie) {
227      Ok(result) => Ok(result),
228      Err(err) => return Err(ParseCookieError::ResultEncodingError(err.into()))
229    }
230  }
231}
232
233#[cfg(test)]
234mod tests {
235  use super::*;
236
237  #[test]
238  fn it_fails_on_non_base64_cookies() {
239    let rails_key = RailsCookieParser::default();
240    let result = rails_key.decipher_cookie("%test--foo--bar");
241    assert_eq!(result, Err(ParseCookieError::CookieDecodingError(base64::DecodeError::InvalidByte(0, 37))));
242  }
243
244  #[test]
245  fn it_fails_on_invalid_cookies() {
246    let rails_key = RailsCookieParser::default();
247    let result = rails_key.decipher_cookie("test--");
248    assert_eq!(result, Err(ParseCookieError::CookieFormatError(2)));
249  }
250
251  #[test]
252  fn it_works_for_rails_v6() {
253    let rails_key = RailsCookieParser::new(
254      "10b8683351f3a680391ba9b4735285900b6a7745ed5791437f001a938cc4dfac997363195051a28be31ba64d7a0098c2efc41aed4ef206fb18f373339a44bd2f",
255      "authenticated encrypted cookie",
256      1000,
257      HashDigest::Sha1
258    );
259    let result = rails_key.decipher_cookie("FaxbWcVc2y/48LYms/BrNb5r2MUXZcfZLpfzYOR0lVoQvoKz5R3IQwoNVL3VXgcudYp4oWYCuxX4IID70mjmFBcDK5DQTvykD1JAKgcFsbQcxDR5E/PBKTBwS5L5pEruWIB72Lu9o6BXreK6VZeNrAp9xBASiz+a/X33XAMrZFPPC/TGotfkkeLjwTx24wVg5OoET/Y3DkDhlXd9H0sho6lUEJLxxLyhNB+zqCc3i/sB2nyqRXf7J/3FsALeiwrPSCjF8ivBFyeD4pRercRBTNbLP6+Z--7FdUaoy4iwX6C+GX--2bq4Qvv1UDTUHv+O4pRxBg==");
260
261    assert_eq!(result.unwrap(), "{\"_rails\":{\"message\":\"eyJzZXNzaW9uX2lkIjoiMDMzZmY0MTBiYjY4MTcyMGJmZDRkMDM5NjIxMTFiMzkiLCJjdXJyZW50X3VzZXJfdXVpZCI6Ijk1ZDczYmY4LTZlZGYtNGE2Ny1iMWY5LTIyNzM5OGJkZDhhZiJ9\",\"exp\":\"2030-11-27T11:58:33.210Z\",\"pur\":null}}");
262  }
263
264  #[test]
265  fn it_works_for_default_rails() {
266    let rails_key = RailsCookieParser::new(
267      "5ac471dc7dc882a9d8367253dcdebd086be029cad10f681725fad25e8b425d241854a054ea06b08d9ac36e03439948eddd2e93b1310b1c5c9843f6f54a562286",
268      "authenticated encrypted cookie",
269      1000,
270      HashDigest::Sha256
271    );
272    let result = rails_key.decipher_cookie("ClX3OHg9XV03KMYDOUJGB8u1wTq4qnahW1GS9nwbX0Z0eOsuIqWo6l0AVenz1wN61BPg79Bifwr2zGwKwyH9JhFpO75wPlh6llTJ4/dOzmucMsZIRpFDvLoDLjVkeuxSdIRE9JURM9/sD92jOby4qFdR4bkCHMGmnS+T4hbactT88X0uDOpyeifEUVHUi+Mmmui4qzpRbaR86lvqnudVKHYlC53Sb5EQJX0IK1oE/8tl/hXXAd0fQCP+Ho0pqz6LtH4+PPa7H7PXJFOxJ1epDqotmUI9XuYJp7Cq6GZ+NoE2t4WAl+SHqxjjAwE6vzfajA553x4=--5hm+u0xu/mHYUHIh--YiXje6ZO08vnyf/hY41dgQ==");
273
274    assert_eq!(result.unwrap(), "{\"_rails\":{\"message\":\"eyJzZXNzaW9uX2lkIjoiYjJjM2RmNTdhYmZlZGU4M2JiOWUwZGIzNmFjMzBmMGUiLCJmb28iOiJiYXIiLCJfY3NyZl90b2tlbiI6IjFjWWN6a3lvVGpXYnVlMVphR3F2TE9uWmVwOTkycmM5alFiX21XRTBfNzgiLCJjb3VudCI6MTJ9\",\"exp\":null,\"pur\":\"cookie._your_app_session\"}}");
275  }
276}