diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index 004115a681..fc72d595d8 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -26,7 +26,7 @@ workspace = true default = ["http2", "tokio-macros", "trace"] http2 = ["hyper/http2", "hyper-util/http2"] http3-preview = ["s2n-quic", "s2n-quic-h3", "tls"] -secrets = ["cookie/private", "cookie/key-expansion"] +secrets = ["cookie/private", "cookie/key-expansion", "chacha20poly1305", "hkdf", "sha2", "base64", "hex"] json = ["serde_json"] msgpack = ["rmp-serde"] uuid = ["uuid_", "rocket_http/uuid"] @@ -44,6 +44,13 @@ uuid_ = { package = "uuid", version = "1", optional = true, features = ["serde"] # Optional MTLS dependencies x509-parser = { version = "0.16", optional = true } +# Optional dependencies for "secrets" feature +chacha20poly1305 = { version = "0.10.1", optional = true } +hkdf = { version = "0.12.4", optional = true } +sha2 = { version = "0.10.8", optional = true } +base64 = { version = "0.22.1", optional = true } +hex = { version = "0.4.3", optional = true } + # Hyper dependencies http = "1" bytes = "1.4" diff --git a/core/lib/src/config/mod.rs b/core/lib/src/config/mod.rs index d6969fcb97..acf18ba713 100644 --- a/core/lib/src/config/mod.rs +++ b/core/lib/src/config/mod.rs @@ -138,4 +138,4 @@ mod secret_key; pub use crate::shutdown::Sig; #[cfg(feature = "secrets")] -pub use secret_key::SecretKey; +pub use secret_key::{SecretKey, Cipher}; diff --git a/core/lib/src/config/secret_key.rs b/core/lib/src/config/secret_key.rs index 46818c4f51..d7a9789d11 100644 --- a/core/lib/src/config/secret_key.rs +++ b/core/lib/src/config/secret_key.rs @@ -1,10 +1,28 @@ use std::fmt; +use chacha20poly1305::{ + aead::{generic_array::typenum::Unsigned, Aead, AeadCore, KeyInit, OsRng}, + XChaCha20Poly1305, XNonce +}; +use hkdf::Hkdf; +use sha2::Sha256; use cookie::Key; +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; use serde::{de, ser, Deserialize, Serialize}; use crate::request::{Outcome, Request, FromRequest}; +#[derive(Debug)] +pub enum Error { + KeyLengthError, + NonceFillError, + EncryptionError, + DecryptionError, + EncryptedDataLengthError, + Base64DecodeError, + HexDecodeError, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] enum Kind { Zero, @@ -80,6 +98,38 @@ pub struct SecretKey { provided: bool, } +/// A struct representing encrypted data. +/// +/// The `Cipher` struct encapsulates encrypted data and provides various +/// utility methods for encoding and decoding this data in different formats +/// such as bytes, hexadecimal, and base64. +/// +/// # Examples +/// +/// Creating a `Cipher` from bytes: +/// ``` +/// let data = b"some encrypted data"; +/// let cipher = Cipher::from_bytes(data); +/// ``` +/// +/// Converting a `Cipher` to a hexadecimal string: +/// ``` +/// let hex = cipher.to_hex(); +/// ``` +/// +/// Creating a `Cipher` from a base64 string: +/// ``` +/// let base64_str = "c29tZSBlbmNyeXB0ZWQgZGF0YQ=="; +/// let cipher = Cipher::from_base64(base64_str).unwrap(); +/// ``` +/// +/// Converting a `Cipher` back to bytes: +/// ``` +/// let bytes = cipher.as_bytes(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cipher(Vec); + impl SecretKey { /// Returns a secret key that is all zeroes. pub(crate) fn zero() -> SecretKey { @@ -178,6 +228,70 @@ impl SecretKey { { ser.serialize_bytes(&[0; 32][..]) } + + fn cipher(&self, nonce: &[u8]) -> Result { + let (mut prk, hk) = Hkdf::::extract(Some(nonce), self.key.encryption()); + hk.expand(b"secret_key_data_encryption", &mut prk).map_err(|_| Error::KeyLengthError)?; + + Ok(XChaCha20Poly1305::new(&prk)) + } + + /// Encrypts the given data. + /// Generates a random nonce for each encryption to ensure uniqueness. + /// Returns the Vec of the concatenated nonce and ciphertext. + /// + /// # Example + /// ```rust + /// use rocket::config::SecretKey; + /// + /// let plaintext = "I like turtles".as_bytes(); + /// let secret_key = SecretKey::generate().unwrap(); + /// + /// let cipher = secret_key.encrypt(&plaintext).unwrap(); + /// let decrypted = secret_key.decrypt(&cipher).unwrap(); + /// + /// assert_eq!(plaintext, decrypted); + /// ``` + pub fn encrypt>(&self, value: T) -> Result { + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let cipher = self.cipher(&nonce)?; + + let ciphertext = cipher + .encrypt(&nonce, value.as_ref()) + .map_err(|_| Error::EncryptionError)?; + + // Prepare a vector to hold the nonce and ciphertext + let mut encrypted_data = Vec::with_capacity(nonce.len() + ciphertext.len()); + encrypted_data.extend_from_slice(nonce.as_slice()); + encrypted_data.extend_from_slice(&ciphertext); + + Ok(Cipher(encrypted_data)) + } + + /// Decrypts the given encrypted data, encapsulated in a Cipher wrapper. + /// Extracts the nonce from the data and uses it for decryption. + /// Returns the decrypted Vec. + pub fn decrypt(&self, encrypted: &Cipher) -> Result, Error> { + let encrypted = encrypted.as_bytes(); + + // Check if the length of decoded data is at least the length of the nonce + let nonce_len = ::NonceSize::USIZE; + if encrypted.len() <= nonce_len { + return Err(Error::EncryptedDataLengthError); + } + + // Split the decoded data into nonce and ciphertext + let (nonce, ciphertext) = encrypted.split_at(nonce_len); + let nonce = XNonce::from_slice(nonce); + + let cipher = self.cipher(nonce)?; + + // Decrypt the ciphertext using the nonce + let decrypted = cipher.decrypt(nonce, ciphertext) + .map_err(|_| Error::DecryptionError)?; + + Ok(decrypted) + } } impl PartialEq for SecretKey { @@ -269,3 +383,53 @@ impl fmt::Debug for SecretKey { ::fmt(self, f) } } + +impl Cipher { + /// Create a `Cipher` from its raw bytes representation. + pub fn from_bytes(bytes: &[u8]) -> Self { + Cipher(bytes.to_vec()) + } + + /// Create a `Cipher` from a vector of bytes. + pub fn from_vec(vec: Vec) -> Self { + Cipher(vec) + } + + /// Create a `Cipher` from a hex string. + pub fn from_hex(hex: &str) -> Result { + let decoded = hex::decode(hex).map_err(|_| Error::HexDecodeError)?; + Ok(Cipher(decoded)) + } + + /// Create a `Cipher` from a base64 string. + pub fn from_base64(base64: &str) -> Result { + let decoded = URL_SAFE.decode(base64).map_err(|_| Error::Base64DecodeError)?; + Ok(Cipher(decoded)) + } + + /// Returns the bytes contained in the `Cipher`. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Consumes the `Cipher` and returns the contained bytes as a vector. + pub fn into_vec(self) -> Vec { + self.0 + } + + /// Returns the hex representation of the bytes contained in the `Cipher`. + pub fn to_hex(&self) -> String { + hex::encode(&self.0) + } + + /// Returns the base64 representation of the bytes contained in the `Cipher`. + pub fn to_base64(&self) -> String { + URL_SAFE.encode(&self.0) + } +} + +impl fmt::Display for Cipher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_base64()) + } +} diff --git a/core/lib/tests/private-data.rs b/core/lib/tests/private-data.rs new file mode 100644 index 0000000000..06ed4b704b --- /dev/null +++ b/core/lib/tests/private-data.rs @@ -0,0 +1,49 @@ +#![cfg(feature = "secrets")] +#![deny(warnings)] + +#[cfg(test)] +mod cookies_private_tests { + use rocket::config::{SecretKey, Cipher}; + + #[test] + fn cipher_conversions() { + let secret_key = SecretKey::generate().unwrap(); + + let plaintext = "I like turtles"; + let cipher = secret_key.encrypt(plaintext).unwrap(); + + assert_eq!(cipher, Cipher::from_bytes(&cipher.as_bytes())); + assert_eq!(cipher, Cipher::from_vec(cipher.clone().into_vec())); + assert_eq!(cipher, Cipher::from_hex(&cipher.to_hex()).unwrap()); + assert_eq!(cipher, Cipher::from_base64(&cipher.to_base64()).unwrap()); + } + + #[test] + fn encrypt_decrypt() { + let secret_key = SecretKey::generate().unwrap(); + + // encrypt byte array + let msg = "very-secret-message".as_bytes(); + let encrypted = secret_key.encrypt(&msg).unwrap(); + let decrypted = secret_key.decrypt(&encrypted).unwrap(); + assert_eq!(msg, decrypted); + + // encrypt String + let msg = "very-secret-message".to_string(); + let encrypted = secret_key.encrypt(&msg).unwrap(); + let decrypted = secret_key.decrypt(&encrypted).unwrap(); + assert_eq!(msg.as_bytes(), decrypted); + } + + #[test] + fn encrypt_with_wrong_key() { + let msg = "very-secret-message".as_bytes(); + + let secret_key = SecretKey::generate().unwrap(); + let encrypted = secret_key.encrypt(msg).unwrap(); + + let another_secret_key = SecretKey::generate().unwrap(); + let result = another_secret_key.decrypt(&encrypted); + assert!(result.is_err()); + } +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml index c2ecd5fc59..a833b95998 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -17,8 +17,8 @@ members = [ "testing", "tls", "upgrade", - "pastebin", "todo", "chat", + "private-data", ] diff --git a/examples/cookies/Rocket.toml b/examples/cookies/Rocket.toml index 2c5e69c8cd..219cb3734e 100644 --- a/examples/cookies/Rocket.toml +++ b/examples/cookies/Rocket.toml @@ -1,3 +1,2 @@ [default] -secret_key = "itlYmFR2vYKrOmFhupMIn/hyB6lYCCTXz4yaQX89XVg=" template_dir = "templates" diff --git a/examples/private-data/Cargo.toml b/examples/private-data/Cargo.toml new file mode 100644 index 0000000000..83624ad16d --- /dev/null +++ b/examples/private-data/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "private-data" +version = "0.0.0" +workspace = "../" +edition = "2021" +publish = false + +[dependencies] +rocket = { path = "../../core/lib", features = ["secrets"] } diff --git a/examples/private-data/src/main.rs b/examples/private-data/src/main.rs new file mode 100644 index 0000000000..32b684c233 --- /dev/null +++ b/examples/private-data/src/main.rs @@ -0,0 +1,57 @@ +#[macro_use] +extern crate rocket; + +use rocket::config::Cipher; +use rocket::{Config, State}; +use rocket::fairing::AdHoc; +use rocket::response::status; +use rocket::http::Status; + +#[cfg(test)] mod tests; + +#[get("/encrypt/")] +fn encrypt_endpoint(msg: &str, config: &State) -> Result> { + let secret_key = config.secret_key.clone(); + + let encrypted_msg = secret_key + .encrypt(msg) + .map(|cipher| cipher.to_base64()) + .map_err(|_| { + status::Custom(Status::InternalServerError, "Failed to encrypt message".to_string()) + })?; + + info!("received message for encrypt: '{}'", msg); + info!("encrypted msg: '{}'", encrypted_msg); + + Ok(encrypted_msg) +} + +#[get("/decrypt/")] +fn decrypt_endpoint(msg: &str, config: &State) -> Result> { + let secret_key = config.secret_key.clone(); + + let cipher = Cipher::from_base64(msg).map_err(|_| { + status::Custom(Status::BadRequest, "Failed to decode base64".to_string()) + })?; + + let decrypted = secret_key.decrypt(&cipher).map_err(|_| { + status::Custom(Status::InternalServerError, "Failed to decrypt message".to_string()) + })?; + + let decrypted_msg = String::from_utf8(decrypted).map_err(|_| { + status::Custom(Status::InternalServerError, + "Failed to convert decrypted message to UTF-8".to_string()) + })?; + + info!("received message for decrypt: '{}'", msg); + info!("decrypted msg: '{}'", decrypted_msg); + + Ok(decrypted_msg) +} + +#[launch] +fn rocket() -> _ { + rocket::build() + .mount("/", routes![encrypt_endpoint, decrypt_endpoint]) + .attach(AdHoc::config::()) +} diff --git a/examples/private-data/src/tests.rs b/examples/private-data/src/tests.rs new file mode 100644 index 0000000000..041309022c --- /dev/null +++ b/examples/private-data/src/tests.rs @@ -0,0 +1,23 @@ +use rocket::{config::SecretKey, local::blocking::Client}; + +#[test] +fn encrypt_decrypt() { + let secret_key = SecretKey::generate().unwrap(); + let msg = "very-secret-message".as_bytes(); + + let encrypted = secret_key.encrypt(msg).unwrap(); + let decrypted = secret_key.decrypt(&encrypted).unwrap(); + + assert_eq!(msg, decrypted); +} + +#[test] +fn encrypt_decrypt_api() { + let client = Client::tracked(super::rocket()).unwrap(); + let msg = "some-secret-message"; + + let encrypted = client.get(format!("/encrypt/{}", msg)).dispatch().into_string().unwrap(); + let decrypted = client.get(format!("/decrypt/{}", encrypted)).dispatch().into_string().unwrap(); + + assert_eq!(msg, decrypted); +}