diff options
-rw-r--r-- | Cargo.lock | 103 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | src/api/Cargo.toml | 1 | ||||
-rw-r--r-- | src/api/s3/encryption.rs | 199 | ||||
-rw-r--r-- | src/api/s3/error.rs | 6 | ||||
-rw-r--r-- | src/api/s3/mod.rs | 1 | ||||
-rw-r--r-- | src/model/s3/object_table.rs | 3 |
7 files changed, 313 insertions, 1 deletions
@@ -18,6 +18,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] name = "ahash" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -750,6 +785,16 @@ dependencies = [ ] [[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] name = "clap" version = "2.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -917,10 +962,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] [[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] name = "darling" version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1321,6 +1376,7 @@ dependencies = [ name = "garage_api" version = "0.10.0" dependencies = [ + "aes-gcm", "async-trait", "base64 0.21.7", "bytes", @@ -1602,6 +1658,16 @@ dependencies = [ ] [[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] name = "gimli" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2051,6 +2117,15 @@ dependencies = [ ] [[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] name = "instant" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2631,6 +2706,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2957,6 +3038,18 @@ dependencies = [ ] [[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4376,6 +4469,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] name = "unsafe-libyaml" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -65,6 +65,7 @@ sha2 = "0.10" timeago = { version = "0.4", default-features = false } xxhash-rust = { version = "0.8", default-features = false, features = ["xxh3"] } +aes-gcm = { version = "0.10", features = ["aes", "stream"] } sodiumoxide = { version = "0.2.5-0", package = "kuska-sodiumoxide" } kuska-handshake = { version = "0.2.0", features = ["default", "async_std"] } diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index 3b555b8b..c759d5a4 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -21,6 +21,7 @@ garage_net.workspace = true garage_util.workspace = true garage_rpc.workspace = true +aes-gcm.workspace = true async-trait.workspace = true base64.workspace = true bytes.workspace = true diff --git a/src/api/s3/encryption.rs b/src/api/s3/encryption.rs new file mode 100644 index 00000000..4055a67f --- /dev/null +++ b/src/api/s3/encryption.rs @@ -0,0 +1,199 @@ +use std::borrow::Cow; + +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, +}; + +use http::header::{HeaderName, HeaderValue}; +use hyper::{body::Body, Request}; + +use garage_model::garage::Garage; +use garage_model::s3::object_table::{ObjectVersionEncryption, ObjectVersionHeaders}; + +use crate::s3::error::Error; + +const X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM: HeaderName = + HeaderName::from_static("x-amz-server-side-encryption-customer-algorithm"); +const X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY: HeaderName = + HeaderName::from_static("x-amz-server-side-encryption-customer-key"); +const X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5: HeaderName = + HeaderName::from_static("x-amz-server-side-encryption-customer-key-MD5"); + +const X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM: HeaderName = + HeaderName::from_static("x-amz-copy-source-server-side-encryption-customer-algorithm"); +const X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY: HeaderName = + HeaderName::from_static("x-amz-copy-source-server-side-encryption-customer-key"); +const X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5: HeaderName = + HeaderName::from_static("x-amz-copy-source-server-side-encryption-customer-key-MD5"); + +const CUSTOMER_ALGORITHM_AES256: HeaderValue = HeaderValue::from_static("AES256"); + +pub enum EncryptionParams { + Plaintext, + SseC { + client_key: Key<Aes256Gcm>, + compression_level: Option<i32>, + }, +} + +impl EncryptionParams { + pub fn new_from_req( + garage: &Garage, + req: &Request<impl Body>, + ) -> Result<EncryptionParams, Error> { + let key = parse_request_headers( + req, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, + )?; + match key { + Some(client_key) => Ok(EncryptionParams::SseC { + client_key: parse_and_check_key(req)?, + compression_level: garage.config.compression_level, + }), + None => Ok(EncryptionParams::Plaintext), + } + } + + pub fn check_decrypt_for_get<'a>( + garage: &Garage, + req: &Request<impl Body>, + obj_enc: &'a ObjectVersionEncryption, + ) -> Result<(Self, Cow<'a, ObjectVersionHeaders>), Error> { + let key = parse_request_headers( + req, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, + &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, + )?; + Self::check_decrypt(garage, key, obj_enc) + } + + pub fn check_decrypt_for_copy_source<'a>( + garage: &Garage, + req: &Request<impl Body>, + obj_enc: &'a ObjectVersionEncryption, + ) -> Result<(Self, Cow<'a, ObjectVersionHeaders>), Error> { + let key = parse_request_headers( + req, + &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, + &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, + &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, + )?; + Self::check_decrypt(garage, key, obj_enc) + } + + fn check_decrypt<'a>( + garage: &Garage, + key: Option<Key<Aes256Gcm>>, + obj_enc: &'a ObjectVersionEncryption, + ) -> Result<(Self, Cow<'a, ObjectVersionHeaders>), Error> { + match (key, obj_enc) { + ( + Some(client_key), + ObjectVersionEncryption::SseC { + headers, + compressed, + }, + ) => { + let cipher = Aes256Gcm::new(&client_key); + let nonce: Nonce<Aes256Gcm::NonceSize> = headers + .get(..12) + .ok_or_internal_error("invalid encrypted data")? + .try_into() + .unwrap(); + let plaintext = cipher.decrypt(&nonce, &headers[12..]).ok_or_bad_request( + "Invalid encryption key, could not decrypt object metadata.", + )?; + let headers = ObjectVersionHeaders::decode(&plaintext)?; + let enc = Self::SseC { + client_key, + compression_level: if compressed { + Some(garage.config.compression_level.unwrap_or(1)) + } else { + None + }, + }; + Ok((enc, headers.into())) + } + (None, ObjectVersionEncryption::Plaintext { headers }) => { + Ok((Self::Plaintext, headers.into())) + } + (_, ObjectVersionEncryption::SseC { .. }) => { + Err(Error::bad_request("Object is encrypted")) + } + (Some(_), _) => { + // TODO: should this be an OK scenario? + Err(Error::bad_request("Trying to decrypt a plaintext object")) + } + } + } + + pub fn encrypt_headers( + &self, + h: ObjectVersionHeaders, + ) -> Result<ObjectVersionEncryption, Error> { + match self { + Self::SseC { + client_key, + compression_level, + } => { + let cipher = Aes256Gcm::new(&client_key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let plaintext = h.encode()?; + let ciphertext = cipher + .encrypt(&nonce, &plaintext) + .ok_or_internal_error("Encryption failed")?; + let headers_enc = [nonce.to_vec(), ciphertext].concat(); + Ok(ObjectVersionEncryption::SseC { + headers: headers_enc, + compressed: compression_level.is_some(), + }) + } + Self::Plaintext => Ok(ObjectVersionEncryption::Plaintext { headers: h }), + } + } +} + +fn parse_request_headers( + req: &Request<impl Body>, + alg_header: &HeaderName, + key_header: &HeaderName, + md5_header: &HeaderName, +) -> Result<Option<Key<Aes256Gcm>>, Error> { + match req.headers().get(alg_header) { + Some(CUSTOMER_ALGORITHM_AES256) => { + use md5::{Digest, Md5}; + + let key_b64 = req + .headers() + .get(key_header) + .ok_or_bad_request(format!("Missing {} header", key_header))?; + let key_bytes: [u8; 32] = base64::decode(&key_b64) + .ok_or_bad_request(format!("Invalid {} header", key_header))? + .try_into() + .ok_or_bad_request(format!("Invalid {} header", key_header))?; + + let md5_b64 = req + .headers() + .get(md5_header) + .ok_or_bad_request(format!("Missing {} header", md5_header))?; + let md5_bytes = base64::decode(&md5_b64) + .ok_or_bad_request(format!("Invalid {} header", md5_header))?; + + let mut hasher = Md5::new(); + hasher.update(&key_bytes[..]); + if hasher.finalize() != md5_bytes { + return Err(Error::bad_request( + "Encryption key MD5 checksum does not match", + )); + } + + Ok(Some(key_bytes.into())) + } + Some(alg) => Err(Error::InvalidEncryptionAlgorithm(alg.to_string())), + None => Ok(None), + } +} diff --git a/src/api/s3/error.rs b/src/api/s3/error.rs index f86c19a6..5cb5d04e 100644 --- a/src/api/s3/error.rs +++ b/src/api/s3/error.rs @@ -65,6 +65,10 @@ pub enum Error { #[error(display = "Invalid HTTP range: {:?}", _0)] InvalidRange(#[error(from)] (http_range::HttpRangeParseError, u64)), + /// The client sent a range header with invalid value + #[error(display = "Invalid encryption algorithm: {:?}, should be AES256", _0)] + InvalidEncryptionAlgorithm(String), + /// The client sent a request for an action not supported by garage #[error(display = "Unimplemented action: {}", _0)] NotImplemented(String), @@ -126,6 +130,7 @@ impl Error { Error::InvalidXml(_) => "MalformedXML", Error::InvalidRange(_) => "InvalidRange", Error::InvalidUtf8Str(_) | Error::InvalidUtf8String(_) => "InvalidRequest", + Error::InvalidEncryptionAlgorithm(_) => "InvalidEncryptionAlgorithmError", } } } @@ -143,6 +148,7 @@ impl ApiError for Error { | Error::InvalidPart | Error::InvalidPartOrder | Error::EntityTooSmall + | Error::InvalidEncryptionAlgorithm(_) | Error::InvalidXml(_) | Error::InvalidUtf8Str(_) | Error::InvalidUtf8String(_) => StatusCode::BAD_REQUEST, diff --git a/src/api/s3/mod.rs b/src/api/s3/mod.rs index cbdb94ab..1eb95d40 100644 --- a/src/api/s3/mod.rs +++ b/src/api/s3/mod.rs @@ -13,5 +13,6 @@ mod post_object; mod put; mod website; +mod encryption; mod router; pub mod xml; diff --git a/src/model/s3/object_table.rs b/src/model/s3/object_table.rs index 91f83419..20824e8d 100644 --- a/src/model/s3/object_table.rs +++ b/src/model/s3/object_table.rs @@ -285,7 +285,8 @@ mod v010 { #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Serialize, Deserialize)] pub enum ObjectVersionEncryption { SseC { - /// Encrypted serialized ObjectVersionHeaders struct + /// Encrypted serialized ObjectVersionHeaders struct. + /// This is never compressed, just encrypted using AES256-GCM. #[serde(with = "serde_bytes")] headers: Vec<u8>, /// Whether data blocks are compressed in addition to being encrypted |