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

[PM-15096] Add xchacha20-poly1305-blake3-ctx cipher #41

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
51 changes: 51 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions crates/bitwarden-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ wasm = ["dep:tsify-next", "dep:wasm-bindgen"] # WASM support
uniffi = ["dep:uniffi"] # Uniffi bindings
no-memory-hardening = [] # Disable memory hardening features

aead-crypto = []

[dependencies]
aes = { version = ">=0.8.2, <0.9", features = ["zeroize"] }
argon2 = { version = ">=0.5.0, <0.6", features = [
"std",
"zeroize",
], default-features = false }
base64 = ">=0.22.1, <0.23"
blake3 = { version = "1.5.5", features = ["zeroize"], optional = true }
cbc = { version = ">=0.1.2, <0.2", features = ["alloc", "zeroize"] }
chacha20poly1305 = { version = "0.10.1", optional = true }
generic-array = { version = ">=0.14.7, <1.0", features = ["zeroize"] }
hkdf = ">=0.12.3, <0.13"
hmac = ">=0.12.1, <0.13"
Expand Down Expand Up @@ -65,5 +69,10 @@ name = "zeroizing_allocator"
harness = false
required-features = ["no-memory-hardening"]

[[bench]]
name = "ciphers"
required-features = ["aead-crypto"]
harness = false

[lints]
workspace = true
33 changes: 33 additions & 0 deletions crates/bitwarden-crypto/benches/ciphers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use bitwarden_crypto::chacha20::{
decrypt_xchacha20_poly1305_blake3_ctx, encrypt_xchacha20_poly1305_blake3_ctx,
};
use criterion::{black_box, criterion_group, criterion_main, Criterion};

pub fn criterion_benchmark(c: &mut Criterion) {
let key = [0u8; 32];
let plaintext_secret_data = vec![0u8; 1024];
let plaintext_secret_data = plaintext_secret_data.as_slice();
let authenticated_data = vec![0u8; 256];
let authenticated_data = authenticated_data.as_slice();

c.bench_function("encrypt_xchacha20_poly1305_blake3_ctx", |b| {
b.iter(|| {
encrypt_xchacha20_poly1305_blake3_ctx(
black_box(&key),
black_box(plaintext_secret_data),
black_box(authenticated_data),
)
})
});

let encrypted =
encrypt_xchacha20_poly1305_blake3_ctx(&key, plaintext_secret_data, authenticated_data)
.unwrap();

c.bench_function("encrypt_xchacha20_poly1305_blake3_ctx", |b| {
b.iter(|| decrypt_xchacha20_poly1305_blake3_ctx(black_box(&key), black_box(&encrypted)))
});
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
215 changes: 215 additions & 0 deletions crates/bitwarden-crypto/src/chacha20.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
use chacha20::{
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
cipher::{KeyIvInit, StreamCipher},
XChaCha20,
};
use chacha20poly1305::{AeadCore, AeadInPlace, KeyInit, XChaCha20Poly1305};
use generic_array::GenericArray;
use poly1305::{universal_hash::UniversalHash, Poly1305};
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
use subtle::ConstantTimeEq;

/**
* Note:
* XChaCha20Poly1305 encrypts data, and authenticates associated data using
* XChaCha20Poly1305 Specifically, this uses the CTX construction, proposed here: https://par.nsf.gov/servlets/purl/10391723
* using blake3 as the cryptographic hash function. The entire construction is called
* XChaCha20Poly1305Blake3CTX. This provides not only key-commitment, but full-commitment.
* In total, this scheme prevents attacks such as invisible salamanders.
*/
use crate::CryptoError;

pub struct XChaCha20Poly1305Blake3CTXCiphertext {
nonce: [u8; 24],
tag: [u8; 32],
encrypted_data: Vec<u8>,
authenticated_data: Vec<u8>,
}

pub fn encrypt_xchacha20_poly1305_blake3_ctx(
key: &[u8; 32],
plaintext_secret_data: &[u8],
authenticated_data: &[u8],
) -> Result<XChaCha20Poly1305Blake3CTXCiphertext, CryptoError> {
encrypt_xchacha20_poly1305_blake3_ctx_internal(
rand::thread_rng(),
key,
plaintext_secret_data,
authenticated_data,
)
}

fn encrypt_xchacha20_poly1305_blake3_ctx_internal(
rng: impl rand::CryptoRng + rand::RngCore,
key: &[u8; 32],
plaintext_secret_data: &[u8],
associated_data: &[u8],
) -> Result<XChaCha20Poly1305Blake3CTXCiphertext, CryptoError> {
let nonce = XChaCha20Poly1305::generate_nonce(rng);
// This should never fail because XChaCha20Poly1305::generate_nonce always returns 24 bytes
let nonce_slice: [u8; 24] = nonce.as_slice().try_into().expect("Nonce is 24 bytes");

// This buffer contains the plaintext, that will be encrypted in-place
let mut buffer = Vec::from(plaintext_secret_data);
let cipher = XChaCha20Poly1305::new(&GenericArray::from_slice(key));
Fixed Show fixed Hide fixed

let poly1305_tag = cipher
.encrypt_in_place_detached(&nonce, associated_data, &mut buffer)
.map_err(|_| CryptoError::InvalidKey)?;
// This should never fail because poly1305_tag is always 16 bytes
let poly1305_tag: [u8; 16] = poly1305_tag
.as_slice()
.try_into()
.expect("A poly1305 tag is 16 bytes");

Ok(XChaCha20Poly1305Blake3CTXCiphertext {
nonce: nonce_slice,
encrypted_data: buffer,
authenticated_data: associated_data.to_vec(),
tag: ctx_hash(key, &nonce_slice, associated_data, &poly1305_tag),
})
}

pub fn decrypt_xchacha20_poly1305_blake3_ctx(
key: &[u8; 32],
ciphertext: &XChaCha20Poly1305Blake3CTXCiphertext,
) -> Result<Vec<u8>, CryptoError> {
let buffer = ciphertext.encrypted_data.clone();
let associated_data = ciphertext.authenticated_data.as_slice();

// First, get the original polynomial tag, since this is required to calculate the ctx_tag
let poly1305_tag = get_tag_expected_for_xchacha20_poly1305_ctx(
key,
&ciphertext.nonce,
associated_data,
&buffer,
);
// This should never fail because poly1305_tag is always 32 bytes
let poly1305_tag_slice: [u8; 16] = poly1305_tag
.as_slice()
.try_into()
.expect("A poly1305 tag is 16 bytes");
let ctx_tag = ctx_hash(key, &ciphertext.nonce, associated_data, &poly1305_tag_slice);

if ctx_tag.ct_eq(&ciphertext.tag).into() {
// At this point the commitment is verified, so we can decrypt the data using regular
// XChaCha20Poly1305
let cipher = XChaCha20Poly1305::new(&GenericArray::from_slice(key));
Fixed Show fixed Hide fixed
let mut buffer = ciphertext.encrypted_data.clone();
let nonce_array = GenericArray::from_slice(&ciphertext.nonce);
cipher
.decrypt_in_place_detached(nonce_array, associated_data, &mut buffer, &poly1305_tag)
.map_err(|_| CryptoError::InvalidKey)?;
return Ok(buffer);
}

Err(CryptoError::InvalidKey)
}

/// The ctx hash function creates a cryptographic hash that binds to:
/// - The key
/// - The nonce
/// - The associated data
/// - The Poly1305 tag
/// And by injectivity, also to the ciphertext.
Fixed Show fixed Hide fixed
fn ctx_hash(
key: &[u8; 32],
nonce: &[u8; 24],
associated_data: &[u8],
poly1305_tag: &[u8; 16],
) -> [u8; 32] {
// A cryptographic hash of the AD makes it fixed length
let ad_hash = blake3::hash(associated_data);

// It is safe to do a hash of the concatenations of the input values because all input values
// have a fixed size and thus there can be no ambiguity about a pre-image having different
// meanings, and leading to the same hash.
//
// T* = H(K, N, A, T)
let ctx: &mut [u8; 32 * 2 + 24 + 16] = &mut [0u8; 32 * 2 + 24 + 16];
ctx[..32].copy_from_slice(key);
ctx[32..56].copy_from_slice(nonce);
ctx[56..88].copy_from_slice(ad_hash.as_bytes());
ctx[88..].copy_from_slice(poly1305_tag);
let hash = blake3::hash(ctx.as_slice());
*hash.as_bytes()
}

// This function copies internal behavior from the AEAD implementation in RustCrypto
fn get_tag_expected_for_xchacha20_poly1305_ctx(
key: &[u8; 32],
nonce: &[u8; 24],
associated_data: &[u8],
buffer: &[u8],
) -> chacha20poly1305::Tag {
let mut xchacha20 = XChaCha20::new(
GenericArray::from_slice(key),
GenericArray::from_slice(nonce),
);
// https://github.com/RustCrypto/AEADs/blob/8403768230657812016e1b3d17b1638e5fdf5f73/chacha20poly1305/src/cipher.rs#L35-L37
let mut mac_key = poly1305::Key::default();
Fixed Show fixed Hide fixed
xchacha20.apply_keystream(&mut *mac_key);
Fixed Show fixed Hide fixed

// https://github.com/RustCrypto/AEADs/blob/8403768230657812016e1b3d17b1638e5fdf5f73/chacha20poly1305/src/cipher.rs#L85-L87
let mut mac = Poly1305::new(GenericArray::from_slice(&*mac_key));
Fixed Show fixed Hide fixed
mac.update_padded(&associated_data);
Fixed Show fixed Hide fixed
mac.update_padded(&buffer);
Fixed Show fixed Hide fixed
authenticate_lengths(&associated_data, &buffer, &mut mac);
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
mac.finalize()
}

/// This function copies internal behavior from the AEAD implementation in RustCrypto
/// https://github.com/RustCrypto/AEADs/blob/8403768230657812016e1b3d17b1638e5fdf5f73/chacha20poly1305/src/cipher.rs#L100-L111
fn authenticate_lengths(associated_data: &[u8], buffer: &[u8], mac: &mut Poly1305) -> () {
Fixed Show fixed Hide fixed
let associated_data_len: u64 = associated_data.len() as u64;
let buffer_len: u64 = buffer.len() as u64;

let mut block = GenericArray::default();
block[..8].copy_from_slice(&associated_data_len.to_le_bytes());
block[8..].copy_from_slice(&buffer_len.to_le_bytes());
mac.update(&[block]);
}

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

#[test]
fn test_encrypt_decrypt_xchacha20_poly1305_ctx() {
let key = [0u8; 32];
let plaintext_secret_data = b"My secret data";
let authenticated_data = b"My authenticated data";

let encrypted =
encrypt_xchacha20_poly1305_blake3_ctx(&key, plaintext_secret_data, authenticated_data)
.unwrap();
let decrypted = decrypt_xchacha20_poly1305_blake3_ctx(&key, &encrypted).unwrap();
assert_eq!(plaintext_secret_data, decrypted.as_slice());
}

#[test]
fn test_fails_when_ciphertext_changed() {
let key = [0u8; 32];
let plaintext_secret_data = b"My secret data";
let authenticated_data = b"My authenticated data";

let mut encrypted =
encrypt_xchacha20_poly1305_blake3_ctx(&key, plaintext_secret_data, authenticated_data)
.unwrap();
encrypted.encrypted_data[0] = encrypted.encrypted_data[0].wrapping_add(1);
let result = decrypt_xchacha20_poly1305_blake3_ctx(&key, &encrypted);
assert!(result.is_err());
}

#[test]
fn test_fails_when_associated_data_changed() {
let key = [0u8; 32];
let plaintext_secret_data = b"My secret data";
let authenticated_data = b"My authenticated data";

let mut encrypted =
encrypt_xchacha20_poly1305_blake3_ctx(&key, plaintext_secret_data, authenticated_data)
.unwrap();
encrypted.authenticated_data[0] = encrypted.authenticated_data[0].wrapping_add(1);
let result = decrypt_xchacha20_poly1305_blake3_ctx(&key, &encrypted);
assert!(result.is_err());
}
}
4 changes: 3 additions & 1 deletion crates/bitwarden-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
#[cfg(not(feature = "no-memory-hardening"))]
#[global_allocator]
static ALLOC: ZeroizingAllocator<std::alloc::System> = ZeroizingAllocator(std::alloc::System);

mod aes;
mod enc_string;
pub use enc_string::{AsymmetricEncString, EncString};
Expand All @@ -82,6 +81,9 @@ mod wordlist;
pub use wordlist::EFF_LONG_WORD_LIST;
pub use zeroizing_alloc::ZeroAlloc as ZeroizingAllocator;

#[cfg(feature = "aead-crypto")]
pub mod chacha20;

#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!();

Expand Down
Loading