From 4e95e1003c4f2c89a807977b34f287d2f200f5bc Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 29 May 2021 11:39:52 +0300 Subject: feat: add chunk encryption --- src/cipher.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 4 ++ src/lib.rs | 1 + 3 files changed, 205 insertions(+) create mode 100644 src/cipher.rs diff --git a/src/cipher.rs b/src/cipher.rs new file mode 100644 index 0000000..550fafd --- /dev/null +++ b/src/cipher.rs @@ -0,0 +1,200 @@ +use crate::chunk::DataChunk; +use crate::chunkmeta::ChunkMeta; +use crate::passwords::Passwords; + +use aes_gcm::aead::{generic_array::GenericArray, Aead, NewAead, Payload}; +use aes_gcm::Aes256Gcm; // Or `Aes128Gcm` +use rand::Rng; + +use std::str::FromStr; + +const CHUNK_V1: &[u8] = b"0001"; + +pub struct EncryptedChunk { + ciphertext: Vec, + aad: Vec, +} + +impl EncryptedChunk { + fn new(ciphertext: Vec, aad: Vec) -> Self { + Self { ciphertext, aad } + } + + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + + pub fn aad(&self) -> &[u8] { + &self.aad + } +} + +pub struct CipherEngine { + cipher: Aes256Gcm, +} + +impl CipherEngine { + pub fn new(pass: &Passwords) -> Self { + let key = GenericArray::from_slice(pass.encryption_key()); + Self { + cipher: Aes256Gcm::new(key), + } + } + + pub fn encrypt_chunk(&self, chunk: &DataChunk) -> Result { + // Payload with metadata as associated data, to be encrypted. + // + // The metadata will be stored in cleartext after encryption. + let aad = chunk.meta().to_json_vec(); + let payload = Payload { + msg: chunk.data(), + aad: &aad, + }; + + // Unique random key for each encryption. + let nonce = Nonce::new(); + let nonce_arr = GenericArray::from_slice(nonce.as_bytes()); + + // Encrypt the sensitive part. + let ciphertext = self + .cipher + .encrypt(nonce_arr, payload) + .map_err(CipherError::EncryptError)?; + + // Construct the blob to be stored on the server. + let mut vec: Vec = vec![]; + push_bytes(&mut vec, CHUNK_V1); + push_bytes(&mut vec, nonce.as_bytes()); + push_bytes(&mut vec, &ciphertext); + + Ok(EncryptedChunk::new(vec, aad)) + } + + pub fn decrypt_chunk(&self, bytes: &[u8], meta: &[u8]) -> Result { + // Does encrypted chunk start with the right version? + if !bytes.starts_with(CHUNK_V1) { + return Err(CipherError::UnknownChunkVersion); + } + let version_len = CHUNK_V1.len(); + let bytes = &bytes[version_len..]; + + // Get nonce. + let nonce = &bytes[..NONCE_SIZE]; + if nonce.len() != NONCE_SIZE { + return Err(CipherError::NoNonce); + } + let nonce = GenericArray::from_slice(nonce); + let ciphertext = &bytes[NONCE_SIZE..]; + + let payload = Payload { + msg: ciphertext, + aad: meta, + }; + + let payload = self + .cipher + .decrypt(nonce, payload) + .map_err(CipherError::DecryptError)?; + let payload = Payload::from(payload.as_slice()); + + let meta = std::str::from_utf8(meta)?; + let meta = ChunkMeta::from_str(&meta)?; + + let chunk = DataChunk::new(payload.msg.to_vec(), meta); + + Ok(chunk) + } +} + +fn push_bytes(vec: &mut Vec, bytes: &[u8]) { + for byte in bytes.iter() { + vec.push(*byte); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CipherError { + #[error("failed to encrypt with AES-GEM: {0}")] + EncryptError(aes_gcm::Error), + + #[error("encrypted chunk does not start with correct version")] + UnknownChunkVersion, + + #[error("encrypted chunk does not have a complete nonce")] + NoNonce, + + #[error("failed to decrypt with AES-GEM: {0}")] + DecryptError(aes_gcm::Error), + + #[error("failed to parse decrypted data as a DataChunk: {0}")] + Parse(serde_yaml::Error), + + #[error(transparent)] + Utf8Error(#[from] std::str::Utf8Error), + + #[error("failed to parse JSON: {0}")] + JsonParse(#[from] serde_json::Error), +} + +const NONCE_SIZE: usize = 12; + +#[derive(Debug)] +struct Nonce { + nonce: Vec, +} + +impl Nonce { + fn from_bytes(bytes: &[u8]) -> Self { + assert_eq!(bytes.len(), NONCE_SIZE); + Self { + nonce: bytes.to_vec(), + } + } + + fn new() -> Self { + let mut bytes: Vec = vec![0; NONCE_SIZE]; + let mut rng = rand::thread_rng(); + for x in bytes.iter_mut() { + *x = rng.gen(); + } + Self::from_bytes(&bytes) + } + + fn as_bytes(&self) -> &[u8] { + &self.nonce + } +} + +#[cfg(test)] +mod test { + use crate::chunk::DataChunk; + use crate::chunkmeta::ChunkMeta; + use crate::cipher::CipherEngine; + use crate::passwords::Passwords; + + #[test] + fn metadata_as_aad() { + let meta = ChunkMeta::new("dummy-checksum"); + let meta_as_aad = meta.to_json_vec(); + let chunk = DataChunk::new("hello".as_bytes().to_vec(), meta); + let pass = Passwords::new("secret"); + let cipher = CipherEngine::new(&pass); + let enc = cipher.encrypt_chunk(&chunk).unwrap(); + + assert_eq!(meta_as_aad, enc.aad()); + } + + #[test] + fn round_trip() { + let meta = ChunkMeta::new("dummy-checksum"); + let chunk = DataChunk::new("hello".as_bytes().to_vec(), meta); + let pass = Passwords::new("secret"); + + let cipher = CipherEngine::new(&pass); + let enc = cipher.encrypt_chunk(&chunk).unwrap(); + + let bytes: Vec = enc.ciphertext().to_vec(); + let dec = cipher.decrypt_chunk(&bytes, enc.aad()).unwrap(); + assert_eq!(chunk, dec); + } +} diff --git a/src/error.rs b/src/error.rs index 8241d5d..e4d77d3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,5 @@ use crate::backup_run::BackupError; +use crate::cipher::CipherError; use crate::client::ClientError; use crate::cmd::restore::RestoreError; use crate::config::ClientConfigError; @@ -31,6 +32,9 @@ pub enum ObnamError { #[error(transparent)] NascentError(#[from] NascentError), + #[error(transparent)] + CipherError(#[from] CipherError), + #[error(transparent)] LocalGenerationError(#[from] LocalGenerationError), diff --git a/src/lib.rs b/src/lib.rs index 82dab15..7d7afdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod chunk; pub mod chunker; pub mod chunkid; pub mod chunkmeta; +pub mod cipher; pub mod client; pub mod cmd; pub mod config; -- cgit v1.2.1