use rainier_support::setting_enum;
setting_enum! {
pub enum CryptScheme: "encryption scheme" {
#[default]
Native = "native",
Laravel = "laravel",
}
}
use aes::cipher::block_padding::Pkcs7;
use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use rainier_support::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::encrypter::Encrypter;
use crate::key::{Key, KeyRing};
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
type HmacSha256 = Hmac<Sha256>;
const IV_LEN: usize = 16;
#[derive(Debug, Serialize, Deserialize)]
struct Payload {
iv: String,
value: String,
mac: String,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}
pub struct LaravelEncrypter {
keys: KeyRing,
}
impl LaravelEncrypter {
pub fn new(keys: KeyRing) -> Self {
Self { keys }
}
pub fn keys(&self) -> &KeyRing {
&self.keys
}
fn encrypt_with(key: &Key, plain: &[u8]) -> Result<String> {
let mut iv = [0u8; IV_LEN];
rand::Rng::fill(&mut rand::thread_rng(), &mut iv[..]);
let ciphertext = Aes256CbcEnc::new_from_slices(key.bytes(), &iv)
.map_err(|_| Error::internal("the key or IV is the wrong length for AES-256-CBC"))?
.encrypt_padded_vec_mut::<Pkcs7>(plain);
let iv = B64.encode(iv);
let value = B64.encode(&ciphertext);
let mac = Self::mac(key, &iv, &value)?;
let payload = serde_json::to_vec(&Payload { iv, value, mac, tag: None })?;
Ok(B64.encode(payload))
}
fn decrypt_with(key: &Key, payload: &Payload) -> Result<Vec<u8>> {
let expected = Self::mac(key, &payload.iv, &payload.value)?;
let presented = hex_decode(&payload.mac).ok_or_else(invalid)?;
let expected = hex_decode(&expected).ok_or_else(invalid)?;
if !bool::from(presented.ct_eq(&expected)) {
return Err(invalid());
}
let iv = B64.decode(&payload.iv).map_err(|_| invalid())?;
let ciphertext = B64.decode(&payload.value).map_err(|_| invalid())?;
Aes256CbcDec::new_from_slices(key.bytes(), &iv)
.map_err(|_| invalid())?
.decrypt_padded_vec_mut::<Pkcs7>(&ciphertext)
.map_err(|_| invalid())
}
fn mac(key: &Key, iv: &str, value: &str) -> Result<String> {
let mut mac = HmacSha256::new_from_slice(key.bytes())
.map_err(|_| Error::internal("the key is the wrong length for HMAC-SHA256"))?;
mac.update(iv.as_bytes());
mac.update(value.as_bytes());
Ok(hex_encode(&mac.finalize().into_bytes()))
}
}
impl Encrypter for LaravelEncrypter {
fn encrypt_bytes(&self, plain: &[u8]) -> Result<String> {
Self::encrypt_with(self.keys.current(), plain)
}
fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> {
let decoded = B64.decode(payload).map_err(|_| invalid())?;
let payload: Payload = serde_json::from_slice(&decoded).map_err(|_| invalid())?;
for key in self.keys.all() {
if let Ok(plain) = Self::decrypt_with(key, &payload) {
return Ok(plain);
}
}
Err(invalid())
}
}
impl std::fmt::Debug for LaravelEncrypter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaravelEncrypter").field("keys", &self.keys.ids()).finish()
}
}
fn invalid() -> Error {
Error::internal("could not decrypt the payload")
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return None;
}
(0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encrypter::EncrypterExt;
fn encrypter() -> LaravelEncrypter {
LaravelEncrypter::new(KeyRing::new(Key::generate()))
}
#[test]
fn a_value_round_trips() {
let encrypter = encrypter();
let payload = encrypter.encrypt("a card number").unwrap();
assert_eq!(encrypter.decrypt(&payload).unwrap(), "a card number");
}
#[test]
fn the_payload_is_the_shape_php_writes() {
let payload = encrypter().encrypt("hello").unwrap();
let decoded = B64.decode(&payload).expect("base64 around the JSON");
let json: serde_json::Value = serde_json::from_slice(&decoded).expect("JSON inside");
assert!(json["iv"].is_string());
assert!(json["value"].is_string());
assert!(json["mac"].is_string());
assert_eq!(B64.decode(json["iv"].as_str().unwrap()).unwrap().len(), IV_LEN);
assert_eq!(json["mac"].as_str().unwrap().len(), 64);
}
#[test]
fn the_mac_covers_the_base64_forms() {
let key = Key::generate();
let iv = B64.encode([7u8; IV_LEN]);
let value = B64.encode(b"ciphertext");
let mut expected = HmacSha256::new_from_slice(key.bytes()).unwrap();
expected.update(format!("{iv}{value}").as_bytes());
assert_eq!(
LaravelEncrypter::mac(&key, &iv, &value).unwrap(),
hex_encode(&expected.finalize().into_bytes())
);
}
#[test]
fn a_tampered_ciphertext_is_refused_rather_than_decrypted() {
let encrypter = encrypter();
let payload = encrypter.encrypt("a card number").unwrap();
let decoded = B64.decode(&payload).unwrap();
let mut json: Payload = serde_json::from_slice(&decoded).unwrap();
let mut ciphertext = B64.decode(&json.value).unwrap();
ciphertext[0] ^= 0xff;
json.value = B64.encode(&ciphertext);
let tampered = B64.encode(serde_json::to_vec(&json).unwrap());
assert!(encrypter.decrypt(&tampered).is_err());
}
#[test]
fn a_tampered_iv_is_refused() {
let encrypter = encrypter();
let payload = encrypter.encrypt("a card number").unwrap();
let decoded = B64.decode(&payload).unwrap();
let mut json: Payload = serde_json::from_slice(&decoded).unwrap();
let mut iv = B64.decode(&json.iv).unwrap();
iv[0] ^= 0xff;
json.iv = B64.encode(&iv);
let tampered = B64.encode(serde_json::to_vec(&json).unwrap());
assert!(encrypter.decrypt(&tampered).is_err());
}
#[test]
fn another_applications_payload_does_not_decrypt() {
let payload = encrypter().encrypt("a card number").unwrap();
assert!(encrypter().decrypt(&payload).is_err());
}
#[test]
fn a_retired_key_still_reads_what_it_wrote() {
let old = Key::generate();
let payload = LaravelEncrypter::new(KeyRing::new(old.clone())).encrypt("secret").unwrap();
let rotated = LaravelEncrypter::new(KeyRing::new(Key::generate()).with_previous(old));
assert_eq!(rotated.decrypt(&payload).unwrap(), "secret");
}
#[test]
fn nonsense_is_an_error_rather_than_a_panic() {
let encrypter = encrypter();
for payload in ["", "not base64 at all !!", &B64.encode("not json"), &B64.encode("{}")] {
assert!(encrypter.decrypt(payload).is_err(), "{payload:?}");
}
}
#[test]
fn every_failure_looks_the_same() {
let encrypter = encrypter();
let payload = encrypter.encrypt("secret").unwrap();
let corrupt = B64.encode("{}");
let wrong_key =
LaravelEncrypter::new(KeyRing::new(Key::generate())).encrypt("secret").unwrap();
let _ = payload;
assert_eq!(
encrypter.decrypt(&corrupt).unwrap_err().message(),
encrypter.decrypt(&wrong_key).unwrap_err().message()
);
}
#[test]
fn empty_and_long_values_round_trip() {
let encrypter = encrypter();
for plain in ["", "a", &"x".repeat(10_000), "héllo — ünicode"] {
assert_eq!(encrypter.decrypt(&encrypter.encrypt(plain).unwrap()).unwrap(), plain);
}
}
#[test]
fn hex_round_trips() {
assert_eq!(hex_decode(&hex_encode(&[0, 15, 16, 255])), Some(vec![0, 15, 16, 255]));
assert_eq!(hex_decode("abc"), None, "an odd length is not hex");
assert_eq!(hex_decode("zz"), None);
}
fn fixed_key() -> Key {
Key::from_base64("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=").unwrap()
}
#[test]
fn the_ciphertext_is_pinned_to_a_known_vector() {
let key = fixed_key();
let iv = B64.encode([0u8; IV_LEN]);
let ciphertext = Aes256CbcEnc::new_from_slices(key.bytes(), &[0u8; IV_LEN])
.unwrap()
.encrypt_padded_vec_mut::<Pkcs7>(b"a card number");
assert_eq!(B64.encode(&ciphertext), "nH4xXUTQpgBTKXvgzml9eg==");
assert_eq!(
LaravelEncrypter::mac(&key, &iv, &B64.encode(&ciphertext)).unwrap(),
"f648b5eac55d1dd21fe92175da4660271c245c3c0d23a9839e932eb583716aad"
);
}
}