From cfd259190f2a01eee236de72e599859556097acc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Mar 2025 15:10:55 +0100 Subject: [PATCH 1/2] sse-c: use different object encryption key for each object --- Cargo.lock | 1 + src/api/s3/Cargo.toml | 1 + src/api/s3/copy.rs | 45 ++++++++---- src/api/s3/encryption.rs | 130 ++++++++++++++++++++++++++++------- src/api/s3/get.rs | 18 +++-- src/api/s3/list.rs | 14 +++- src/api/s3/multipart.rs | 35 +++++++--- src/api/s3/post_object.rs | 16 ++++- src/api/s3/put.rs | 19 ++++- src/model/s3/object_table.rs | 5 ++ 10 files changed, 225 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9d48116..f64c50dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1403,6 +1403,7 @@ dependencies = [ "garage_table", "garage_util", "hex", + "hmac", "http 1.2.0", "http-body-util", "http-range", diff --git a/src/api/s3/Cargo.toml b/src/api/s3/Cargo.toml index 7b0cac94..47aaab8c 100644 --- a/src/api/s3/Cargo.toml +++ b/src/api/s3/Cargo.toml @@ -31,6 +31,7 @@ crc32fast.workspace = true crc32c.workspace = true err-derive.workspace = true hex.workspace = true +hmac.workspace = true tracing.workspace = true md-5.workspace = true pin-project.workspace = true diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index a5b2d706..7c67a65d 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -24,7 +24,7 @@ use garage_api_common::helpers::*; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::get::{full_object_byte_stream, PreconditionHeaders}; use crate::multipart; @@ -65,8 +65,18 @@ pub async fn handle_copy( &ctx.garage, req.headers(), &source_version_meta.encryption, + OekDerivationInfo::for_object(&source_object, source_version), )?; - let dest_encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?; + let dest_uuid = gen_uuid(); + let dest_encryption = EncryptionParams::new_from_headers( + &ctx.garage, + req.headers(), + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: dest_uuid, + object_key: dest_key, + }, + )?; // Extract source checksum info before source_object_meta_inner is consumed let source_checksum = source_object_meta_inner.checksum; @@ -115,6 +125,7 @@ pub async fn handle_copy( handle_copy_metaonly( ctx, dest_key, + dest_uuid, dest_object_meta, dest_encryption, source_version, @@ -138,6 +149,7 @@ pub async fn handle_copy( handle_copy_reencrypt( ctx, dest_key, + dest_uuid, dest_object_meta, dest_encryption, source_version, @@ -169,6 +181,7 @@ pub async fn handle_copy( async fn handle_copy_metaonly( ctx: ReqCtx, dest_key: &str, + dest_uuid: Uuid, dest_object_meta: ObjectVersionMetaInner, dest_encryption: EncryptionParams, source_version: &ObjectVersion, @@ -182,7 +195,6 @@ async fn handle_copy_metaonly( } = ctx; // Generate parameters for copied object - let new_uuid = gen_uuid(); let new_timestamp = now_msec(); let new_meta = ObjectVersionMeta { @@ -192,7 +204,7 @@ async fn handle_copy_metaonly( }; let res = SaveStreamResult { - version_uuid: new_uuid, + version_uuid: dest_uuid, version_timestamp: new_timestamp, etag: new_meta.etag.clone(), }; @@ -204,7 +216,7 @@ async fn handle_copy_metaonly( // bytes is either plaintext before&after or encrypted with the // same keys, so it's ok to just copy it as is let dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Complete(ObjectVersionData::Inline( new_meta, @@ -230,7 +242,7 @@ async fn handle_copy_metaonly( // This holds a reference to the object in the Version table // so that it won't be deleted, e.g. by repair_versions. let tmp_dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Uploading { encryption: new_meta.encryption.clone(), @@ -250,7 +262,7 @@ async fn handle_copy_metaonly( // marked as deleted (they are marked as deleted only if the Version // doesn't exist or is marked as deleted). let mut dest_version = Version::new( - new_uuid, + dest_uuid, VersionBacklink::Object { bucket_id: dest_bucket_id, key: dest_key.to_string(), @@ -269,7 +281,7 @@ async fn handle_copy_metaonly( .iter() .map(|b| BlockRef { block: b.1.hash, - version: new_uuid, + version: dest_uuid, deleted: false.into(), }) .collect::>(); @@ -285,7 +297,7 @@ async fn handle_copy_metaonly( // with the stuff before, the block's reference counts could be decremented before // they are incremented again for the new version, leading to data being deleted. let dest_object_version = ObjectVersion { - uuid: new_uuid, + uuid: dest_uuid, timestamp: new_timestamp, state: ObjectVersionState::Complete(ObjectVersionData::FirstBlock( new_meta, @@ -307,6 +319,7 @@ async fn handle_copy_metaonly( async fn handle_copy_reencrypt( ctx: ReqCtx, dest_key: &str, + dest_uuid: Uuid, dest_object_meta: ObjectVersionMetaInner, dest_encryption: EncryptionParams, source_version: &ObjectVersion, @@ -326,6 +339,7 @@ async fn handle_copy_reencrypt( save_stream( &ctx, + dest_uuid, dest_object_meta, dest_encryption, source_stream.map_err(|e| Error::from(GarageError::from(e))), @@ -349,7 +363,7 @@ pub async fn handle_upload_part_copy( let dest_upload_id = multipart::decode_upload_id(upload_id)?; let dest_key = dest_key.to_string(); - let (source_object, (_, dest_version, mut dest_mpu)) = futures::try_join!( + let (source_object, (dest_object, dest_version, mut dest_mpu)) = futures::try_join!( get_copy_source(&ctx, req), multipart::get_upload(&ctx, &dest_key, &dest_upload_id) )?; @@ -367,7 +381,10 @@ pub async fn handle_upload_part_copy( &garage, req.headers(), &source_version_meta.encryption, + OekDerivationInfo::for_object(&source_object, source_object_version), )?; + + let dest_oek_params = OekDerivationInfo::for_object(&dest_object, &dest_version); let (dest_object_encryption, dest_object_checksum_algorithm) = match dest_version.state { ObjectVersionState::Uploading { encryption, @@ -376,8 +393,12 @@ pub async fn handle_upload_part_copy( } => (encryption, checksum_algorithm), _ => unreachable!(), }; - let (dest_encryption, _) = - EncryptionParams::check_decrypt(&garage, req.headers(), &dest_object_encryption)?; + let (dest_encryption, _) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &dest_object_encryption, + dest_oek_params, + )?; let same_encryption = EncryptionParams::is_same(&source_encryption, &dest_encryption); // Check source range is valid diff --git a/src/api/s3/encryption.rs b/src/api/s3/encryption.rs index fa7285ca..c02e126c 100644 --- a/src/api/s3/encryption.rs +++ b/src/api/s3/encryption.rs @@ -11,6 +11,7 @@ use aes_gcm::{ }; use base64::prelude::*; use bytes::Bytes; +use sha2::Sha256; use futures::stream::Stream; use futures::task; @@ -21,12 +22,12 @@ use http::header::{HeaderMap, HeaderName, HeaderValue}; use garage_net::bytes_buf::BytesBuf; use garage_net::stream::{stream_asyncread, ByteStream}; use garage_rpc::rpc_helper::OrderTag; -use garage_util::data::Hash; +use garage_util::data::{Hash, Uuid}; use garage_util::error::Error as GarageError; use garage_util::migrate::Migrate; use garage_model::garage::Garage; -use garage_model::s3::object_table::{ObjectVersionEncryption, ObjectVersionMetaInner}; +use garage_model::s3::object_table::*; use garage_api_common::common_error::*; use garage_api_common::signature::checksum::Md5Checksum; @@ -64,32 +65,45 @@ const STREAM_ENC_CYPER_CHUNK_SIZE: usize = STREAM_ENC_PLAIN_CHUNK_SIZE + 16; pub enum EncryptionParams { Plaintext, SseC { + /// the value of x-amz-server-side-encryption-customer-key client_key: Key, + /// the value of x-amz-server-side-encryption-customer-key-md5 client_key_md5: Md5Output, + /// the object encryption key, for uploads created in garage v2+ + object_key: Option>, + /// the compression level used for compressing data blocks compression_level: Option, }, } +#[derive(Clone, Copy)] +pub struct OekDerivationInfo<'a> { + pub bucket_id: Uuid, + pub version_id: Uuid, + pub object_key: &'a str, +} + impl EncryptionParams { pub fn is_encrypted(&self) -> bool { !matches!(self, Self::Plaintext) } pub fn is_same(a: &Self, b: &Self) -> bool { - let relevant_info = |x: &Self| match x { - Self::Plaintext => None, - Self::SseC { - client_key, - compression_level, - .. - } => Some((*client_key, compression_level.is_some())), - }; - relevant_info(a) == relevant_info(b) + // This function is used in CopyObject and UploadPartCopy to determine + // whether the object must be re-encrypted. If this returns true, + // data blocks are reused as-is. Since Garage v2, we are using + // object-specific encryption keys, so we know that if both source + // and destination are encrypted, it can't be with the same key. + match (a, b) { + (Self::Plaintext, Self::Plaintext) => true, + _ => false, + } } pub fn new_from_headers( garage: &Garage, headers: &HeaderMap, + oek_info: OekDerivationInfo<'_>, ) -> Result { let key = parse_request_headers( headers, @@ -101,6 +115,7 @@ impl EncryptionParams { Some((client_key, client_key_md5)) => Ok(EncryptionParams::SseC { client_key, client_key_md5, + object_key: Some(oek_info.derive_oek(&client_key)), compression_level: garage.config.compression_level, }), None => Ok(EncryptionParams::Plaintext), @@ -126,6 +141,7 @@ impl EncryptionParams { garage: &Garage, headers: &HeaderMap, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { let key = parse_request_headers( headers, @@ -133,13 +149,14 @@ impl EncryptionParams { &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, &X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, )?; - Self::check_decrypt_common(garage, key, obj_enc) + Self::check_decrypt_common(garage, key, obj_enc, oek_info) } pub fn check_decrypt_for_copy_source<'a>( garage: &Garage, headers: &HeaderMap, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { let key = parse_request_headers( headers, @@ -147,22 +164,32 @@ impl EncryptionParams { &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, &X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, )?; - Self::check_decrypt_common(garage, key, obj_enc) + Self::check_decrypt_common(garage, key, obj_enc, oek_info) } fn check_decrypt_common<'a>( garage: &Garage, key: Option<(Key, Md5Output)>, obj_enc: &'a ObjectVersionEncryption, + oek_info: OekDerivationInfo<'_>, ) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> { match (key, &obj_enc) { ( Some((client_key, client_key_md5)), - ObjectVersionEncryption::SseC { inner, compressed }, + ObjectVersionEncryption::SseC { + inner, + compressed, + use_oek, + }, ) => { let enc = Self::SseC { client_key, client_key_md5, + object_key: if *use_oek { + Some(oek_info.derive_oek(&client_key)) + } else { + None + }, compression_level: if *compressed { Some(garage.config.compression_level.unwrap_or(1)) } else { @@ -193,13 +220,16 @@ impl EncryptionParams { ) -> Result { match self { Self::SseC { - compression_level, .. + compression_level, + object_key, + .. } => { let plaintext = meta.encode().map_err(GarageError::from)?; let ciphertext = self.encrypt_blob(&plaintext)?; Ok(ObjectVersionEncryption::SseC { inner: ciphertext.into_owned(), compressed: compression_level.is_some(), + use_oek: object_key.is_some(), }) } Self::Plaintext => Ok(ObjectVersionEncryption::Plaintext { inner: meta }), @@ -228,24 +258,37 @@ impl EncryptionParams { // This is used for encrypting object metadata and inlined data for small objects. // This does not compress anything. - pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { + fn cipher(&self) -> Option { match self { - Self::SseC { client_key, .. } => { - let cipher = Aes256Gcm::new(&client_key); + Self::SseC { + object_key: Some(oek), + .. + } => Some(Aes256Gcm::new(&oek)), + Self::SseC { + client_key, + object_key: None, + .. + } => Some(Aes256Gcm::new(&client_key)), + Self::Plaintext => None, + } + } + + pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { + match self.cipher() { + Some(cipher) => { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, blob) .ok_or_internal_error("Encryption failed")?; Ok(Cow::Owned([nonce.to_vec(), ciphertext].concat())) } - Self::Plaintext => Ok(Cow::Borrowed(blob)), + None => Ok(Cow::Borrowed(blob)), } } pub fn decrypt_blob<'a>(&self, blob: &'a [u8]) -> Result, Error> { - match self { - Self::SseC { client_key, .. } => { - let cipher = Aes256Gcm::new(&client_key); + match self.cipher() { + Some(cipher) => { let nonce_size = ::NonceSize::to_usize(); let nonce = Nonce::from_slice( blob.get(..nonce_size) @@ -258,7 +301,7 @@ impl EncryptionParams { )?; Ok(Cow::Owned(plaintext)) } - Self::Plaintext => Ok(Cow::Borrowed(blob)), + None => Ok(Cow::Borrowed(blob)), } } @@ -284,10 +327,12 @@ impl EncryptionParams { Self::Plaintext => stream, Self::SseC { client_key, + object_key, compression_level, .. } => { - let plaintext = DecryptStream::new(stream, *client_key); + let key = object_key.as_ref().unwrap_or(client_key); + let plaintext = DecryptStream::new(stream, *key); if compression_level.is_some() { let reader = stream_asyncread(Box::pin(plaintext)); let reader = BufReader::new(reader); @@ -307,9 +352,12 @@ impl EncryptionParams { Self::Plaintext => Ok(block), Self::SseC { client_key, + object_key, compression_level, .. } => { + let key = object_key.as_ref().unwrap_or(client_key); + let block = if let Some(level) = compression_level { Cow::Owned( garage_block::zstd_encode(block.as_ref(), *level) @@ -325,7 +373,7 @@ impl EncryptionParams { OsRng.fill_bytes(&mut nonce); ret.extend_from_slice(nonce.as_slice()); - let mut cipher = EncryptorLE31::::new(&client_key, &nonce); + let mut cipher = EncryptorLE31::::new(key, &nonce); let mut iter = block.chunks(STREAM_ENC_PLAIN_CHUNK_SIZE).peekable(); if iter.peek().is_none() { @@ -361,6 +409,13 @@ impl EncryptionParams { } } +pub fn has_encryption_header(headers: &HeaderMap) -> bool { + match headers.get(X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM) { + Some(h) => h.as_bytes() == CUSTOMER_ALGORITHM_AES256, + None => false, + } +} + fn parse_request_headers( headers: &HeaderMap, alg_header: &HeaderName, @@ -420,6 +475,30 @@ fn parse_request_headers( } } +impl<'a> OekDerivationInfo<'a> { + pub fn for_object<'b>(object: &'a Object, version: &'b ObjectVersion) -> Self { + Self { + bucket_id: object.bucket_id, + version_id: version.uuid, + object_key: &object.key, + } + } + + fn derive_oek(&self, client_key: &Key) -> Key { + use hmac::{Hmac, Mac}; + + // info = bucket_id + object_name + version_uuid + "garage-object-encryption-key" + // oek = hmac_sha256(ssec_key, info) + let mut hmac = as Mac>::new_from_slice(client_key.as_slice()) + .expect("create hmac-sha256"); + hmac.update(b"garage-object-encryption-key"); + hmac.update(self.bucket_id.as_slice()); + hmac.update(self.version_id.as_slice()); + hmac.update(self.object_key.as_bytes()); + hmac.finalize().into_bytes() + } +} + // ---- encrypt & decrypt streams ---- #[pin_project::pin_project] @@ -569,6 +648,7 @@ mod tests { let enc = EncryptionParams::SseC { client_key: Aes256Gcm::generate_key(&mut OsRng), client_key_md5: Default::default(), // not needed + object_key: Some(Aes256Gcm::generate_key(&mut OsRng)), compression_level, }; diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index 22076603..723e6775 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -30,7 +30,7 @@ use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AM use crate::api_server::ResBody; use crate::copy::*; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; const X_AMZ_MP_PARTS_COUNT: HeaderName = HeaderName::from_static("x-amz-mp-parts-count"); @@ -181,8 +181,12 @@ pub async fn handle_head_without_ctx( return Ok(res); } - let (encryption, headers) = - EncryptionParams::check_decrypt(&garage, req.headers(), &version_meta.encryption)?; + let (encryption, headers) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &version_meta.encryption, + OekDerivationInfo::for_object(&object, object_version), + )?; let checksum_mode = checksum_mode(&req); @@ -303,8 +307,12 @@ pub async fn handle_get_without_ctx( return Ok(res); } - let (enc, headers) = - EncryptionParams::check_decrypt(&garage, req.headers(), &last_v_meta.encryption)?; + let (enc, headers) = EncryptionParams::check_decrypt( + &garage, + req.headers(), + &last_v_meta.encryption, + OekDerivationInfo::for_object(&object, last_v), + )?; let checksum_mode = checksum_mode(&req); diff --git a/src/api/s3/list.rs b/src/api/s3/list.rs index 94c2c895..ff5ca383 100644 --- a/src/api/s3/list.rs +++ b/src/api/s3/list.rs @@ -17,7 +17,7 @@ use garage_api_common::encoding::*; use garage_api_common::helpers::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::multipart as s3_multipart; use crate::xml as s3_xml; @@ -285,8 +285,16 @@ pub async fn handle_list_parts( ObjectVersionState::Uploading { encryption, .. } => encryption, _ => unreachable!(), }; - let encryption_res = - EncryptionParams::check_decrypt(&ctx.garage, req.headers(), &object_encryption); + let encryption_res = EncryptionParams::check_decrypt( + &ctx.garage, + req.headers(), + &object_encryption, + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: upload_id, + object_key: &query.key, + }, + ); let (info, next) = fetch_part_info(query, &mpu)?; diff --git a/src/api/s3/multipart.rs b/src/api/s3/multipart.rs index d6eb26cb..52ea90e8 100644 --- a/src/api/s3/multipart.rs +++ b/src/api/s3/multipart.rs @@ -26,7 +26,7 @@ use garage_api_common::helpers::*; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{has_encryption_header, EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::put::*; use crate::xml as s3_xml; @@ -56,7 +56,15 @@ pub async fn handle_create_multipart_upload( }; // Determine whether object should be encrypted, and if so the key - let encryption = EncryptionParams::new_from_headers(&garage, req.headers())?; + let encryption = EncryptionParams::new_from_headers( + &garage, + req.headers(), + OekDerivationInfo { + bucket_id: *bucket_id, + version_id: upload_id, + object_key: &key, + }, + )?; let object_encryption = encryption.encrypt_meta(meta)?; let checksum_algorithm = request_checksum_algorithm(req.headers())?; @@ -120,8 +128,7 @@ pub async fn handle_put_part( // Before we stream the body, configure the needed checksums. req_body.add_expected_checksums(expected_checksums.clone()); - // TODO: avoid parsing encryption headers twice... - if !EncryptionParams::new_from_headers(&garage, &req_head.headers)?.is_encrypted() { + if !has_encryption_header(&req_head.headers) { // For non-encrypted objects, we need to compute the md5sum in all cases // (even if content-md5 is not set), because it is used as an etag of the // part, which is in turn used in the etag computation of the whole object @@ -134,10 +141,11 @@ pub async fn handle_put_part( let mut chunker = StreamChunker::new(stream, garage.config.block_size); // Read first chuck, and at the same time try to get object to see if it exists - let ((_, object_version, mut mpu), first_block) = + let ((object, object_version, mut mpu), first_block) = futures::try_join!(get_upload(&ctx, &key, &upload_id), chunker.next(),)?; // Check encryption params + let oek_params = OekDerivationInfo::for_object(&object, &object_version); let (object_encryption, checksum_algorithm) = match object_version.state { ObjectVersionState::Uploading { encryption, @@ -146,8 +154,12 @@ pub async fn handle_put_part( } => (encryption, checksum_algorithm), _ => unreachable!(), }; - let (encryption, _) = - EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?; + let (encryption, _) = EncryptionParams::check_decrypt( + &garage, + &req_head.headers, + &object_encryption, + oek_params, + )?; // Check object is valid and part can be accepted let first_block = first_block.ok_or_bad_request("Empty body")?; @@ -297,6 +309,7 @@ pub async fn handle_complete_multipart_upload( return Err(Error::bad_request("No data was uploaded")); } + let oek_params = OekDerivationInfo::for_object(&object, &object_version); let (object_encryption, checksum_algorithm) = match object_version.state { ObjectVersionState::Uploading { encryption, @@ -417,8 +430,12 @@ pub async fn handle_complete_multipart_upload( let object_encryption = match checksum_algorithm { None => object_encryption, Some(_) => { - let (encryption, meta) = - EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?; + let (encryption, meta) = EncryptionParams::check_decrypt( + &garage, + &req_head.headers, + &object_encryption, + oek_params, + )?; let new_meta = ObjectVersionMetaInner { headers: meta.into_owned().headers, checksum: checksum_extra, diff --git a/src/api/s3/post_object.rs b/src/api/s3/post_object.rs index b9bccae6..01c50ebc 100644 --- a/src/api/s3/post_object.rs +++ b/src/api/s3/post_object.rs @@ -15,6 +15,7 @@ use serde::Deserialize; use garage_model::garage::Garage; use garage_model::s3::object_table::*; +use garage_util::data::gen_uuid; use garage_api_common::cors::*; use garage_api_common::helpers::*; @@ -22,7 +23,7 @@ use garage_api_common::signature::checksum::*; use garage_api_common::signature::payload::{verify_v4, Authorization}; use crate::api_server::ResBody; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::put::{extract_metadata_headers, save_stream, ChecksumMode}; use crate::xml as s3_xml; @@ -231,12 +232,22 @@ pub async fn handle_post_object( .transpose()?, }; + let version_uuid = gen_uuid(); + let meta = ObjectVersionMetaInner { headers, checksum: expected_checksums.extra, }; - let encryption = EncryptionParams::new_from_headers(&garage, ¶ms)?; + let encryption = EncryptionParams::new_from_headers( + &garage, + ¶ms, + OekDerivationInfo { + bucket_id, + version_id: version_uuid, + object_key: &key, + }, + )?; let stream = file_field.map(|r| r.map_err(Into::into)); let ctx = ReqCtx { @@ -249,6 +260,7 @@ pub async fn handle_post_object( let res = save_stream( &ctx, + version_uuid, meta, encryption, StreamLimiter::new(stream, conditions.content_length), diff --git a/src/api/s3/put.rs b/src/api/s3/put.rs index 830a7998..425636b6 100644 --- a/src/api/s3/put.rs +++ b/src/api/s3/put.rs @@ -35,7 +35,7 @@ use garage_api_common::signature::body::StreamingChecksumReceiver; use garage_api_common::signature::checksum::*; use crate::api_server::{ReqBody, ResBody}; -use crate::encryption::EncryptionParams; +use crate::encryption::{EncryptionParams, OekDerivationInfo}; use crate::error::*; use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION; @@ -62,6 +62,10 @@ pub async fn handle_put( req: Request, key: &String, ) -> Result, Error> { + // Generate version uuid now, because it is necessary to compute SSE-C + // encryption parameters + let version_uuid = gen_uuid(); + // Retrieve interesting headers from request let headers = extract_metadata_headers(req.headers())?; debug!("Object headers: {:?}", headers); @@ -82,7 +86,15 @@ pub async fn handle_put( }; // Determine whether object should be encrypted, and if so the key - let encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?; + let encryption = EncryptionParams::new_from_headers( + &ctx.garage, + req.headers(), + OekDerivationInfo { + bucket_id: ctx.bucket_id, + version_id: version_uuid, + object_key: &key, + }, + )?; // The request body is a special ReqBody object (see garage_api_common::signature::body) // which supports calculating checksums while streaming the data. @@ -100,6 +112,7 @@ pub async fn handle_put( let res = save_stream( &ctx, + version_uuid, meta, encryption, stream, @@ -121,6 +134,7 @@ pub async fn handle_put( pub(crate) async fn save_stream> + Unpin>( ctx: &ReqCtx, + version_uuid: Uuid, mut meta: ObjectVersionMetaInner, encryption: EncryptionParams, body: S, @@ -140,7 +154,6 @@ pub(crate) async fn save_stream> + Unpin>( let first_block = first_block_opt.unwrap_or_default(); // Generate identity of new version - let version_uuid = gen_uuid(); let version_timestamp = next_timestamp(existing_object.as_ref()); let mut checksummer = match &checksum_mode { diff --git a/src/model/s3/object_table.rs b/src/model/s3/object_table.rs index 6c33b79b..f6204766 100644 --- a/src/model/s3/object_table.rs +++ b/src/model/s3/object_table.rs @@ -257,6 +257,11 @@ mod v010 { /// (compression happens before encryption, whereas for non-encrypted /// objects, compression is handled at the level of the block manager) compressed: bool, + /// Whether the encryption uses an Object Encryption Key derived + /// from the master SSE-C key, instead of the master SSE-C key itself. + /// This is the case of objects created in Garage v2+ + #[serde(default)] + use_oek: bool, }, Plaintext { /// Plain-text headers From 97e2fa5b8b10873b021dc6e73901b20fb55ea705 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 18 Mar 2025 16:42:03 +0100 Subject: [PATCH 2/2] add upgrade test for sse-c --- script/test-upgrade.sh | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/script/test-upgrade.sh b/script/test-upgrade.sh index 45eb3c43..8f66ab8b 100755 --- a/script/test-upgrade.sh +++ b/script/test-upgrade.sh @@ -30,6 +30,11 @@ elif (echo $OLD_VERSION | grep 'v0\.9\.') || (echo $OLD_VERSION | grep 'v1\.'); export GARAGE_OLDVER=v1 fi +if echo $OLD_VERSION | grep 'v1\.'; then + DO_SSEC_TEST=1 +fi +SSEC_KEY="u8zCfnEyt5Imo/krN+sxA1DQXxLWtPJavU6T6gOVj1Y=" + echo "⏳ Setup cluster using old version" $GARAGE_BIN --version ${SCRIPT_FOLDER}/dev-clean.sh @@ -40,7 +45,23 @@ ${SCRIPT_FOLDER}/dev-bucket.sh echo "🛠️ Inserting data in old cluster" source ${SCRIPT_FOLDER}/dev-env-rclone.sh -rclone copy "${SCRIPT_FOLDER}/../.git/" garage:eprouvette/test_dotgit --stats=1s --stats-log-level=NOTICE --stats-one-line +rclone copy "${SCRIPT_FOLDER}/../.git/" garage:eprouvette/test_dotgit \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + +if [ "$DO_SSEC_TEST" = "1" ]; then + # upload small file (should be single part) + rclone copy "${SCRIPT_FOLDER}/test-upgrade.sh" garage:eprouvette/test-ssec \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + # do a multipart upload + dd if=/dev/urandom of=/tmp/randfile-for-upgrade bs=5M count=5 + rclone copy "/tmp/randfile-for-upgrade" garage:eprouvette/test-ssec \ + --s3-chunk-size 5M \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line +fi echo "🏁 Stopping old cluster" killall -INT old_garage @@ -63,7 +84,8 @@ ${SCRIPT_FOLDER}/dev-cluster.sh >> /tmp/garage.log 2>&1 & sleep 3 echo "🛠️ Retrieving data from old cluster" -rclone copy garage:eprouvette/test_dotgit /tmp/test_dotgit --stats=1s --stats-log-level=NOTICE --stats-one-line --fast-list +rclone copy garage:eprouvette/test_dotgit /tmp/test_dotgit \ + --stats=1s --stats-log-level=NOTICE --stats-one-line --fast-list if ! diff <(find "${SCRIPT_FOLDER}/../.git" -type f | xargs md5sum | cut -d ' ' -f 1 | sort) <(find /tmp/test_dotgit -type f | xargs md5sum | cut -d ' ' -f 1 | sort); then echo "TEST FAILURE: directories are different" @@ -71,6 +93,23 @@ if ! diff <(find "${SCRIPT_FOLDER}/../.git" -type f | xargs md5sum | cut -d ' ' fi rm -r /tmp/test_dotgit +if [ "$DO_SSEC_TEST" = "1" ]; then + rclone copy garage:eprouvette/test-ssec /tmp/test_ssec_out \ + --s3-sse-customer-algorithm AES256 \ + --s3-sse-customer-key-base64 "$SSEC_KEY" \ + --stats=1s --stats-log-level=NOTICE --stats-one-line + if ! diff "/tmp/test_ssec_out/test-upgrade.sh" "${SCRIPT_FOLDER}/test-upgrade.sh"; then + echo "SSEC-FAILURE (small file)" + exit 1 + fi + if ! diff "/tmp/test_ssec_out/randfile-for-upgrade" "/tmp/randfile-for-upgrade"; then + echo "SSEC-FAILURE (big file)" + exit 1 + fi + rm -r /tmp/test_ssec_out + rm /tmp/randfile-for-upgrade +fi + echo "🏁 Teardown" rm -rf /tmp/garage-{data,meta}-* rm -rf /tmp/config.*.toml