Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypt data with secret key #2803

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
9 changes: 8 additions & 1 deletion core/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion core/lib/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
164 changes: 164 additions & 0 deletions core/lib/src/config/secret_key.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Comment on lines +111 to +128
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of these doc-tests fail. Use cargo test --doc to run them locally.

/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cipher(Vec<u8>);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make Cipher more useful, it could implement FromForm, FromParam, FromSegment, and FromData. They should either check the format (which may not be possible, since hex could be re-interpreted as base-64), or always use one specific encoding (and provide a method like encode that encodes it using the same encoding).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, we could implement Serialize and Deserialize if the serde feature is enabled.


impl SecretKey {
/// Returns a secret key that is all zeroes.
pub(crate) fn zero() -> SecretKey {
Expand Down Expand Up @@ -178,6 +228,70 @@ impl SecretKey {
{
ser.serialize_bytes(&[0; 32][..])
}

fn cipher(&self, nonce: &[u8]) -> Result<XChaCha20Poly1305, Error> {
let (mut prk, hk) = Hkdf::<Sha256>::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<u8> 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<T: AsRef<[u8]>>(&self, value: T) -> Result<Cipher, Error> {
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<u8>.
pub fn decrypt(&self, encrypted: &Cipher) -> Result<Vec<u8>, Error> {
let encrypted = encrypted.as_bytes();

// Check if the length of decoded data is at least the length of the nonce
let nonce_len = <XChaCha20Poly1305 as AeadCore>::NonceSize::USIZE;
if encrypted.len() <= nonce_len {
return Err(Error::EncryptedDataLengthError);
}
Comment on lines +279 to +281
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not need to be checked here. Rather, it should be checked when Cipher is constructed.


// 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 {
Expand Down Expand Up @@ -269,3 +383,53 @@ impl fmt::Debug for SecretKey {
<Self as fmt::Display>::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<u8>) -> Self {
Cipher(vec)
}

/// Create a `Cipher` from a hex string.
pub fn from_hex(hex: &str) -> Result<Self, Error> {
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<Self, Error> {
let decoded = URL_SAFE.decode(base64).map_err(|_| Error::Base64DecodeError)?;
Ok(Cipher(decoded))
}
Comment on lines +388 to +408
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these methods should validate that the length is longer than the Nonce, and that the length (minus the Nonce) is a multiple of the block size. Ideally, the only error that should be caught when decrypting the value is that the value was not created by encrypting with the same secret key.


/// 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<u8> {
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())
}
}
49 changes: 49 additions & 0 deletions core/lib/tests/private-data.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
2 changes: 1 addition & 1 deletion examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ members = [
"testing",
"tls",
"upgrade",

"pastebin",
"todo",
"chat",
"private-data",
]
1 change: 0 additions & 1 deletion examples/cookies/Rocket.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
[default]
secret_key = "itlYmFR2vYKrOmFhupMIn/hyB6lYCCTXz4yaQX89XVg="
template_dir = "templates"
9 changes: 9 additions & 0 deletions examples/private-data/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "private-data"
version = "0.0.0"
workspace = "../"
edition = "2021"
publish = false

[dependencies]
rocket = { path = "../../core/lib", features = ["secrets"] }
57 changes: 57 additions & 0 deletions examples/private-data/src/main.rs
Original file line number Diff line number Diff line change
@@ -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/<msg>")]
fn encrypt_endpoint(msg: &str, config: &State<Config>) -> Result<String, status::Custom<String>> {
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/<msg>")]
fn decrypt_endpoint(msg: &str, config: &State<Config>) -> Result<String, status::Custom<String>> {
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::<Config>())
}
23 changes: 23 additions & 0 deletions examples/private-data/src/tests.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading