rails-cookie-parser 0.1.0

A decryption library for Rails cookies
Documentation
//! # rails-cookie-parser
//! 
//! rails-cookie-parser is a decryption library for cookies generated by Rails.
//! 
//! ## Example
//! ```rust
//! use rails_cookie_parser::RailsCookieParser;
//! 
//! let rcp = RailsCookieParser::default();
//! rcp.decipher_cookie("test--foo--bar");
//! ```
//! 
//! Has been tested to work with session cookies generated in Rails 5 up to 7.
//! 
//! I put a few more examples in the README, and in the unit tests.
//! 
//! ## Errors
//! 
//! The return type is `Result<String, ParseCookieError>`.
//! 
//! If an error happens in the process of decrypting a cookie, an error variant
//! will be returned. See [`ParseCookieError`] for more details.
#![allow(dead_code)]
#![warn(missing_docs)]

use base64::prelude::*;
use pbkdf2::pbkdf2_hmac_array;
use sha1::Sha1;
use sha2::Sha256;
use std::{env, fmt::Display};

use aes_gcm::{
  aead::{Aead, KeyInit},
  Aes256Gcm, Key, Nonce,
};

/// Errors that can happen when decrypting your cookies
#[derive(Clone, Debug, PartialEq)]
pub enum ParseCookieError {
  /// Base 64 parsing error
  /// 
  /// Occurs when an invalid character is present in your cookie.
  CookieDecodingError(base64::DecodeError),
  /// Cookie format error
  /// 
  /// Occurs if you supply a cookie which cannot be split in three properly.
  CookieFormatError(usize),
  /// UTF-8 decoding error
  /// 
  /// Occurs if your decoded data is not a proper UTF-8 string.
  ResultEncodingError(std::string::FromUtf8Error),
  /// Decryption error
  /// 
  /// If you reach this, I have no idea what to say.
  AesError(aes_gcm::Error),
}


// Self reminder in case I need to test error messages again:
// ```rust
// println!("ERROR");
// let x = result.err().unwrap();
// println!("ERROR: {}", format!("{}", x));
// panic!("Test")
// ```
impl Display for ParseCookieError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match self {
      ParseCookieError::CookieDecodingError(e) =>
        write!(f, "error decoding base64: {}", e.to_string()),
      ParseCookieError::CookieFormatError(e) =>
        write!(f, "wrong cookie format, expecting `3` parts, found `{}`", e),
      ParseCookieError::ResultEncodingError(e) =>
        write!(f, "error parsing string as UTF-8: {}", e.to_string()),
      ParseCookieError::AesError(e) =>
        write!(f, "error decrypting aes-gcm: {}", e.to_string())
    }
  }
}

// https://guides.rubyonrails.org/v7.1.3.2/upgrading_ruby_on_rails.html#active-record-encryption-algorithm-changes
// Active Record Encryption now uses SHA-256 as its hash digest algorithm.

/// Hash digest
/// 
/// Since Rails 7, the hash digest is `Sha256`. Before that, it was `Sha1`.
pub enum HashDigest {
  /// Sha1 hash digest
  Sha1,
  /// Sha256 hash digest
  Sha256,
}

/// Base object and structure
pub struct RailsCookieParser {
  key: [u8; 32],
}


impl Default for RailsCookieParser {
  /// Default implementation for RailsCookieParser
  /// 
  /// The default implementation is meant to be "plug and play" and uses Rails 7
  /// defaults, which are:
  /// * Key base is taken from ENV value for `SECRET_KEY_BASE`.
  /// * Key salt is Rails default `"authenticated encrypted cookie"`.
  /// * Iterations: `1000`.
  /// * Hash digest is `Sha256` since Rails 7.
  fn default() -> Self {
    let default_key_base = match env::var("SECRET_KEY_BASE") {
      Ok(env_key_base) => env_key_base,
      Err(_) => {
        eprintln!("rails-cookie-parser: No ENV set for SECRET_KEY_BASE");
        "".to_owned()
      }
    };
    let default_key_salt = match env::var("SECRET_KEY_SALT") {
      Ok(env_key_salt) => env_key_salt,
      Err(_) => RailsCookieParser::DEFAULT_KEY_SALT.to_owned(),
    };
    Self::new(
      &default_key_base,
      &default_key_salt,
      1000,
      HashDigest::Sha256,
    )
  }
}

impl RailsCookieParser {
  const DEFAULT_KEY_SALT: &'static str = "authenticated encrypted cookie";

  /// Default builder for a Rails 5 cookie
  /// 
  /// Calls the same implementation as [`Self::default_rails6()`].
  pub fn default_rails5() -> Self {
    Self::default_rails6()
  }

  /// Default builder for a Rails 6 cookie
  /// 
  /// Functions just like [`Self::default()`], but uses the pre-Rails 7 hash of Sha1.
  pub fn default_rails6() -> Self {
    let default_key_base = match env::var("SECRET_KEY_BASE") {
      Ok(env_key_base) => env_key_base,
      Err(_) => {
        eprintln!("rails-cookie-parser: No ENV set for SECRET_KEY_BASE");
        "".to_owned()
      }
    };
    let default_key_salt = match env::var("SECRET_KEY_SALT") {
      Ok(env_key_salt) => env_key_salt,
      Err(_) => RailsCookieParser::DEFAULT_KEY_SALT.to_owned(),
    };
    Self::new(&default_key_base, &default_key_salt, 1000, HashDigest::Sha1)
  }

  /// Default builder for a Rails 7 cookie
  /// 
  /// Since Rails 7 is the latest stable, the implementation is exactly the same
  /// as [`Self::default()`].
  pub fn default_rails7() -> Self {
    Self::default()
  }

  /// Creates a new decryption object
  /// 
  /// All arguments can be customised in case you have a very specific setup for
  /// your Rails server.
  /// 
  /// Most of the time, you can use the default functions, but here, you can add
  /// all the customisation you require:
  /// * `key_base`: Your Rails `SECRET_KEY_BASE` string.
  /// * `key_salt`: Your Rails salt string. If unsure, just use `"authenticated
  ///   encrypted cookie"`.
  /// * `iterations`: Unless you know you changed it, `1000`.
  /// * `key_hash`: Can be `Sha1` or `Sha256` depending on your Rails version.
  pub fn new(key_base: &str, key_salt: &str, iterations: u32, key_hash: HashDigest) -> Self {
    let key_base = key_base.as_bytes().to_vec();
    let key_salt = key_salt.as_bytes().to_vec();
    let key = match key_hash {
      HashDigest::Sha1 => pbkdf2_hmac_array::<Sha1, 32>(&key_base, &key_salt, iterations),
      HashDigest::Sha256 => pbkdf2_hmac_array::<Sha256, 32>(&key_base, &key_salt, iterations),
    };

    RailsCookieParser { key }
  }

  fn decipher_aes(
    &self,
    payload: &[u8],
    iv: &[u8],
    auth_tag: &[u8],
  ) -> Result<Vec<u8>, ParseCookieError> {
    let decipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
    let nonce = Nonce::from_slice(iv);
    let new_payload = [payload, auth_tag].concat();
    match decipher.decrypt(&nonce, new_payload.as_ref()) {
      Ok(result) => Ok(result),
      Err(x) => Err(ParseCookieError::AesError(x)),
    }
  }

  /// Decipher a cookie into a JSON string
  /// 
  /// Only the decryption is done, this library will not tell you how to use the
  /// resulting JSON.
  pub fn decipher_cookie(&self, cookie: &str) -> Result<String, ParseCookieError> {
    let parts: Result<Vec<_>, _> = cookie
      .split("--")
      .map(|part| BASE64_STANDARD.decode(part))
      .collect();
    // CookieDecodingError
    let Ok(parts) = parts else {
      return Err(ParseCookieError::CookieDecodingError(parts.err().unwrap()));
    };
    // Error: CookieFormatError
    if parts.len() != 3 {
      return Err(ParseCookieError::CookieFormatError(parts.len()));
    }
    let deciphered_cookie = match self.decipher_aes(&parts[0], &parts[1], &parts[2]) {
      Ok(result) => result,
      Err(err) => return Err(err),
    };

    match String::from_utf8(deciphered_cookie) {
      Ok(result) => Ok(result),
      Err(err) => return Err(ParseCookieError::ResultEncodingError(err.into()))
    }
  }
}

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

  #[test]
  fn it_fails_on_non_base64_cookies() {
    let rails_key = RailsCookieParser::default();
    let result = rails_key.decipher_cookie("%test--foo--bar");
    assert_eq!(result, Err(ParseCookieError::CookieDecodingError(base64::DecodeError::InvalidByte(0, 37))));
  }

  #[test]
  fn it_fails_on_invalid_cookies() {
    let rails_key = RailsCookieParser::default();
    let result = rails_key.decipher_cookie("test--");
    assert_eq!(result, Err(ParseCookieError::CookieFormatError(2)));
  }

  #[test]
  fn it_works_for_rails_v6() {
    let rails_key = RailsCookieParser::new(
      "10b8683351f3a680391ba9b4735285900b6a7745ed5791437f001a938cc4dfac997363195051a28be31ba64d7a0098c2efc41aed4ef206fb18f373339a44bd2f",
      "authenticated encrypted cookie",
      1000,
      HashDigest::Sha1
    );
    let result = rails_key.decipher_cookie("FaxbWcVc2y/48LYms/BrNb5r2MUXZcfZLpfzYOR0lVoQvoKz5R3IQwoNVL3VXgcudYp4oWYCuxX4IID70mjmFBcDK5DQTvykD1JAKgcFsbQcxDR5E/PBKTBwS5L5pEruWIB72Lu9o6BXreK6VZeNrAp9xBASiz+a/X33XAMrZFPPC/TGotfkkeLjwTx24wVg5OoET/Y3DkDhlXd9H0sho6lUEJLxxLyhNB+zqCc3i/sB2nyqRXf7J/3FsALeiwrPSCjF8ivBFyeD4pRercRBTNbLP6+Z--7FdUaoy4iwX6C+GX--2bq4Qvv1UDTUHv+O4pRxBg==");

    assert_eq!(result.unwrap(), "{\"_rails\":{\"message\":\"eyJzZXNzaW9uX2lkIjoiMDMzZmY0MTBiYjY4MTcyMGJmZDRkMDM5NjIxMTFiMzkiLCJjdXJyZW50X3VzZXJfdXVpZCI6Ijk1ZDczYmY4LTZlZGYtNGE2Ny1iMWY5LTIyNzM5OGJkZDhhZiJ9\",\"exp\":\"2030-11-27T11:58:33.210Z\",\"pur\":null}}");
  }

  #[test]
  fn it_works_for_default_rails() {
    let rails_key = RailsCookieParser::new(
      "5ac471dc7dc882a9d8367253dcdebd086be029cad10f681725fad25e8b425d241854a054ea06b08d9ac36e03439948eddd2e93b1310b1c5c9843f6f54a562286",
      "authenticated encrypted cookie",
      1000,
      HashDigest::Sha256
    );
    let result = rails_key.decipher_cookie("ClX3OHg9XV03KMYDOUJGB8u1wTq4qnahW1GS9nwbX0Z0eOsuIqWo6l0AVenz1wN61BPg79Bifwr2zGwKwyH9JhFpO75wPlh6llTJ4/dOzmucMsZIRpFDvLoDLjVkeuxSdIRE9JURM9/sD92jOby4qFdR4bkCHMGmnS+T4hbactT88X0uDOpyeifEUVHUi+Mmmui4qzpRbaR86lvqnudVKHYlC53Sb5EQJX0IK1oE/8tl/hXXAd0fQCP+Ho0pqz6LtH4+PPa7H7PXJFOxJ1epDqotmUI9XuYJp7Cq6GZ+NoE2t4WAl+SHqxjjAwE6vzfajA553x4=--5hm+u0xu/mHYUHIh--YiXje6ZO08vnyf/hY41dgQ==");

    assert_eq!(result.unwrap(), "{\"_rails\":{\"message\":\"eyJzZXNzaW9uX2lkIjoiYjJjM2RmNTdhYmZlZGU4M2JiOWUwZGIzNmFjMzBmMGUiLCJmb28iOiJiYXIiLCJfY3NyZl90b2tlbiI6IjFjWWN6a3lvVGpXYnVlMVphR3F2TE9uWmVwOTkycmM5alFiX21XRTBfNzgiLCJjb3VudCI6MTJ9\",\"exp\":null,\"pur\":\"cookie._your_app_session\"}}");
  }
}