Merge pull request #67725 from mheler/wip-rgw-transition-recompress

rgw: compress/decompress objects during lifecycle transitions

Reviewed-by: Casey Bodley <cbodley@redhat.com>
This commit is contained in:
Adam Emerson 2026-07-16 16:52:07 -04:00 committed by GitHub
commit 423acf868d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1430 additions and 195 deletions

View File

@ -14,8 +14,8 @@ overrides:
rgw lc debug interval: 10
rgw:
storage classes:
LUKEWARM:
FROZEN:
LUKEWARM: [--compression, zstd]
FROZEN: [--compression, zlib]
s3tests:
accounts:
iam root: RGW88888888888888888

View File

@ -0,0 +1 @@
../.qa/

View File

@ -0,0 +1,8 @@
overrides:
rgw:
enable features: [compress-encrypted]
tasks:
- workunit:
clients:
client.0:
- rgw/run-lc-encrypt-recompress.sh

View File

@ -0,0 +1,5 @@
tasks:
- workunit:
clients:
client.0:
- rgw/run-lc-recompress.sh

View File

@ -0,0 +1,12 @@
overrides:
ceph:
conf:
client:
rgw crypt sse algorithm: aes-256-gcm
rgw:
enable features: [compress-encrypted]
tasks:
- workunit:
clients:
client.0:
- rgw/run-lc-encrypt-recompress.sh

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -ex
mydir=$(dirname $0)
python3 -m venv $mydir
source $mydir/bin/activate
pip install pip --upgrade
pip install boto3
$mydir/bin/python3 $mydir/test_rgw_lc_recompress.py --encrypt
deactivate
echo OK.

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -ex
mydir=$(dirname $0)
python3 -m venv $mydir
source $mydir/bin/activate
pip install pip --upgrade
pip install boto3
$mydir/bin/python3 $mydir/test_rgw_lc_recompress.py
deactivate
echo OK.

View File

@ -0,0 +1,356 @@
#!/usr/bin/env python3
import io
import logging as log
import json
import sys
import time
import boto3.s3.transfer
from common import exec_cmd, create_user, boto_connect
"""
Tests that RGW lifecycle transitions correctly recompress objects
when moving between storage classes with different compression configs.
The qa suite configures:
STANDARD - no compression
LUKEWARM - zstd compression
FROZEN - zlib compression
Test plan:
1. Upload object to STANDARD (uncompressed)
2. Transition STANDARD -> LUKEWARM (compress with zstd)
3. Transition LUKEWARM -> FROZEN (recompress with zlib)
4. Transition FROZEN -> STANDARD (decompress)
5. GET object and verify data integrity
With --encrypt, objects are uploaded with SSE-KMS and the test
verifies encryption is preserved across each transition. This
requires the compress-encrypted zonegroup feature.
Accepts --size-kb <N> to set the object size in KB (default: runs
both 4KB and 32MB). Sizes above 8MB use multipart upload to
exercise the multipart compressed+encrypted transition path.
"""
USER = 'lc-recompress-tester'
DISPLAY_NAME = 'LC Recompress Testing'
ACCESS_KEY = 'LCRECOMP0123456789AB'
SECRET_KEY = 'lcrecompsecretkey0123456789abcdefghijklmn'
BUCKET_NAME = 'lc-recompress-bucket'
KMS_KEY_ID = 'testkey-1'
LC_POLL_INTERVAL = 10
LC_TIMEOUT = 120
def make_compressible_body(size_bytes):
"""Generate compressible data of the requested size."""
pattern = b'The quick brown fox jumps over the lazy dog. '
repeats = (size_bytes // len(pattern)) + 1
return (pattern * repeats)[:size_bytes]
def object_stat(bucket_name, object_key):
"""Run radosgw-admin object stat and return parsed JSON."""
out = exec_cmd(
f'radosgw-admin object stat --bucket={bucket_name} --object={object_key}'
)
# some attrs (e.g. crypt.keysel) contain raw binary that isn't valid UTF-8
if isinstance(out, bytes):
out = out.decode('utf-8', errors='replace')
return json.loads(out)
def get_compression_type(stat):
"""
Extract the compression_type from object stat output.
Returns None if the object is not compressed.
"""
compression = stat.get('compression')
if compression is None:
return None
ct = compression.get('compression_type', 'none')
if ct.lower() == 'none':
return None
return ct.lower()
def get_storage_class(stat):
"""
Extract the storage class from object stat output.
The storage class attr lives in attrs['user.rgw.storage_class'].
If absent, the object is in the STANDARD storage class.
"""
attrs = stat.get('attrs', {})
sc = attrs.get('user.rgw.storage_class', '')
# The value may be a raw string possibly with trailing null bytes
sc = sc.strip().strip('\x00')
if not sc:
return 'STANDARD'
return sc
def get_crypt_mode(stat):
"""
Extract the encryption mode from object stat output.
Returns None if the object is not encrypted.
"""
attrs = stat.get('attrs', {})
mode = attrs.get('user.rgw.crypt.mode', '')
mode = mode.strip().strip('\x00')
return mode if mode else None
def get_crypt_salt(stat):
"""
Extract the raw crypt salt attr for rotation comparisons.
Returns None if absent. The value may contain non-printable bytes
(decoded with errors='replace') but two distinct 32-byte random
salts are overwhelmingly unlikely to collide under that encoding.
"""
attrs = stat.get('attrs', {})
salt = attrs.get('user.rgw.crypt.salt', '')
return salt if salt else None
def is_aead_crypt_mode(mode):
"""
True for GCM-family crypt modes that derive per-object keys from
a stored salt. CBC modes don't write crypt.salt at all.
"""
return mode is not None and mode.endswith('-GCM')
def put_lifecycle_rule(client, bucket_name, rule_id, target_class):
"""
Set a lifecycle configuration with a single transition rule.
Uses Days=0 for immediate transition.
"""
client.put_bucket_lifecycle_configuration(
Bucket=bucket_name,
LifecycleConfiguration={
'Rules': [
{
'ID': rule_id,
'Filter': {'Prefix': ''},
'Status': 'Enabled',
'Transitions': [
{
'Days': 0,
'StorageClass': target_class,
}
],
}
]
}
)
log.info(f'Set lifecycle rule {rule_id}: transition to {target_class}')
def wait_for_transition(bucket_name, object_key, expected_class, timeout=LC_TIMEOUT):
"""
Poll radosgw-admin lc process + object stat until the object
reaches the expected storage class, or timeout.
"""
deadline = time.time() + timeout
while time.time() < deadline:
exec_cmd(f'radosgw-admin lc process --bucket={bucket_name}'
' --rgw-lc-debug-interval=10')
time.sleep(LC_POLL_INTERVAL)
stat = object_stat(bucket_name, object_key)
sc = get_storage_class(stat)
log.info(f' current storage class: {sc} (waiting for {expected_class})')
if sc == expected_class:
return stat
raise AssertionError(
f'Timed out waiting for object to transition to {expected_class}'
)
def verify_transition(stat, expected_class, expected_compression, encrypt=False):
"""
Verify that the object stat matches the expected storage class
and compression type after a transition. When encrypt=True,
also verify that encryption mode is still present.
"""
sc = get_storage_class(stat)
ct = get_compression_type(stat)
on_disk = stat.get('size', 0)
comp = stat.get('compression', {})
orig = comp.get('orig_size', on_disk)
log.info(f' storage_class={sc}, compression_type={ct},'
f' on_disk={on_disk}, orig_size={orig}')
assert sc == expected_class, \
f'Expected storage class {expected_class}, got {sc}'
assert ct == expected_compression, \
f'Expected compression type {expected_compression}, got {ct}'
if encrypt:
mode = get_crypt_mode(stat)
log.info(f' crypt_mode={mode}')
assert mode is not None, \
'Expected object to be encrypted, but crypt mode is missing'
def run_test(size_kb, encrypt):
"""Run the full transition test with the given object size."""
size_bytes = size_kb * 1024
object_key = f'test-object-{size_kb}kb'
object_body = make_compressible_body(size_bytes)
label = f'{size_kb}KB'
if size_kb >= 1024:
label = f'{size_kb // 1024}MB'
log.info(f'=== Testing {label} object (encrypt={encrypt}) ===')
conn = boto_connect(ACCESS_KEY, SECRET_KEY)
client = conn.meta.client
# Clean up any previous run
try:
bucket = conn.Bucket(BUCKET_NAME)
bucket.objects.all().delete()
bucket.delete()
except Exception:
pass
bucket = conn.create_bucket(Bucket=BUCKET_NAME)
# Upload object — use multipart for sizes above 8MB to exercise
# the multipart compressed+encrypted transition path
MULTIPART_THRESHOLD = 8 * 1024 * 1024
extra_args = {}
if encrypt:
extra_args['ServerSideEncryption'] = 'aws:kms'
extra_args['SSEKMSKeyId'] = KMS_KEY_ID
if size_bytes > MULTIPART_THRESHOLD:
log.info(f'Uploading {len(object_body)} byte object as {object_key} (multipart)')
transfer_config = boto3.s3.transfer.TransferConfig(
multipart_threshold=MULTIPART_THRESHOLD,
multipart_chunksize=MULTIPART_THRESHOLD,
)
client.upload_fileobj(
io.BytesIO(object_body), BUCKET_NAME, object_key,
ExtraArgs=extra_args,
Config=transfer_config,
)
else:
log.info(f'Uploading {len(object_body)} byte object as {object_key}')
bucket.put_object(Key=object_key, Body=object_body, **extra_args)
stat = object_stat(BUCKET_NAME, object_key)
verify_transition(stat, 'STANDARD', None, encrypt)
log.info('Initial upload verified: STANDARD, no compression')
# Salt rotation only applies to AEAD modes (CBC has no salt attr).
prev_salt = get_crypt_salt(stat) if encrypt else None
def assert_salt_rotated(new_stat, prev):
if not encrypt or not is_aead_crypt_mode(get_crypt_mode(new_stat)):
return None
new_salt = get_crypt_salt(new_stat)
assert new_salt is not None, 'AEAD object missing crypt.salt'
assert new_salt != prev, \
'crypt.salt did not rotate across re-encryption'
return new_salt
# Transition 1: STANDARD (none) -> LUKEWARM (zstd)
log.info(f'--- Transition 1: STANDARD -> LUKEWARM (zstd) [{label}] ---')
put_lifecycle_rule(client, BUCKET_NAME, 'to-lukewarm', 'LUKEWARM')
stat = wait_for_transition(BUCKET_NAME, object_key, 'LUKEWARM')
verify_transition(stat, 'LUKEWARM', 'zstd', encrypt)
orig_size = stat['compression']['orig_size']
assert orig_size == len(object_body), \
f'Expected orig_size={len(object_body)}, got {orig_size}'
body = bucket.Object(object_key).get()['Body'].read()
assert body == object_body, 'Data mismatch after transition 1'
prev_salt = assert_salt_rotated(stat, prev_salt)
log.info('Transition 1 verified')
# Transition 2: LUKEWARM (zstd) -> FROZEN (zlib)
log.info(f'--- Transition 2: LUKEWARM -> FROZEN (zlib) [{label}] ---')
put_lifecycle_rule(client, BUCKET_NAME, 'to-frozen', 'FROZEN')
stat = wait_for_transition(BUCKET_NAME, object_key, 'FROZEN')
verify_transition(stat, 'FROZEN', 'zlib', encrypt)
orig_size = stat['compression']['orig_size']
assert orig_size == len(object_body), \
f'Expected orig_size={len(object_body)}, got {orig_size}'
body = bucket.Object(object_key).get()['Body'].read()
assert body == object_body, 'Data mismatch after transition 2'
prev_salt = assert_salt_rotated(stat, prev_salt)
log.info('Transition 2 verified')
# Same-codec CopyObject (compressed+encrypted only) — exercises the
# passthrough path where set_writer skips decompression. Verifies
# that CRYPT_ORIGINAL_SIZE is the plaintext size (not the post-
# decrypt compressed bytes), which drives bucket-index quota.
if encrypt:
copy_key = f'{object_key}-same-codec-copy'
log.info(f'--- Same-codec CopyObject passthrough [{label}] ---')
client.copy_object(
Bucket=BUCKET_NAME, Key=copy_key,
CopySource={'Bucket': BUCKET_NAME, 'Key': object_key},
StorageClass='FROZEN',
ServerSideEncryption='aws:kms', SSEKMSKeyId=KMS_KEY_ID,
MetadataDirective='COPY',
)
copy_stat = object_stat(BUCKET_NAME, copy_key)
verify_transition(copy_stat, 'FROZEN', 'zlib', encrypt)
copy_orig = copy_stat['compression']['orig_size']
assert copy_orig == len(object_body), \
f'Same-codec copy orig_size {copy_orig} != plaintext {len(object_body)}'
if is_aead_crypt_mode(get_crypt_mode(copy_stat)):
copy_salt = get_crypt_salt(copy_stat)
assert copy_salt is not None, 'AEAD copy missing crypt.salt'
assert copy_salt != prev_salt, \
'Copy salt did not rotate vs source'
body = bucket.Object(copy_key).get()['Body'].read()
assert body == object_body, 'Data mismatch on same-codec copy'
bucket.Object(copy_key).delete()
log.info('Same-codec CopyObject passthrough verified')
# Transition 3: FROZEN (zlib) -> STANDARD (none)
log.info(f'--- Transition 3: FROZEN -> STANDARD (none) [{label}] ---')
put_lifecycle_rule(client, BUCKET_NAME, 'to-standard', 'STANDARD')
stat = wait_for_transition(BUCKET_NAME, object_key, 'STANDARD')
verify_transition(stat, 'STANDARD', None, encrypt)
body = bucket.Object(object_key).get()['Body'].read()
assert body == object_body, 'Data mismatch after transition 3'
prev_salt = assert_salt_rotated(stat, prev_salt)
log.info('Transition 3 verified')
# Clean up
bucket.objects.all().delete()
bucket.delete()
log.info(f'{label} test passed')
def main():
encrypt = '--encrypt' in sys.argv
# parse --size-kb <N> for a single size, otherwise run both 4KB and 32MB
sizes_kb = [4, 32 * 1024]
for i, arg in enumerate(sys.argv):
if arg == '--size-kb' and i + 1 < len(sys.argv):
sizes_kb = [int(sys.argv[i + 1])]
break
if encrypt:
log.info('Running with encryption enabled (SSE-KMS)')
log.info('Creating test user')
create_user(USER, DISPLAY_NAME, ACCESS_KEY, SECRET_KEY)
for size_kb in sizes_kb:
run_test(size_kb, encrypt)
suffix = ' (with encryption)' if encrypt else ''
log.info(f'All lifecycle recompression tests passed{suffix}')
main()

View File

@ -38,6 +38,7 @@
#include "rgw_cr_rest.h"
#include "rgw_crypt.h"
#include "rgw_datalog.h"
#include "rgw_op.h"
#include "rgw_putobj_processor.h"
#include "rgw_lc_tier.h"
#include "rgw_restore.h"
@ -4002,6 +4003,8 @@ int RGWRados::rewrite_obj(RGWBucketInfo& dest_bucket_info, const rgw_obj& obj, c
attrset.erase(RGW_ATTR_ID_TAG);
attrset.erase(RGW_ATTR_TAIL_TAG);
attrset.erase(RGW_ATTR_STORAGE_CLASS);
attrset.erase(RGW_ATTR_SHARE_MANIFEST);
attrset.erase(RGW_ATTR_BLAKE3);
ACLOwner owner;
if (auto i = attrset.find(RGW_ATTR_ACL); i != attrset.end()) {
@ -4893,6 +4896,8 @@ int RGWRados::fetch_remote_obj(RGWObjectCtx& dest_obj_ctx,
if (!keep_tags) {
attrs.erase(RGW_ATTR_TAGS);
}
attrs.erase(RGW_ATTR_SHARE_MANIFEST);
attrs.erase(RGW_ATTR_BLAKE3);
if (copy_if_newer) {
uint64_t pg_ver = 0;
@ -5303,6 +5308,8 @@ int RGWRados::copy_obj(RGWObjectCtx& src_obj_ctx,
if (copy_data) { /* refcounting tail wouldn't work here, just copy the data */
attrs.erase(RGW_ATTR_TAIL_TAG);
attrs.erase(RGW_ATTR_SHARE_MANIFEST);
attrs.erase(RGW_ATTR_BLAKE3);
// Data is rewritten as a single stream; drop stale multipart boundaries
attrs.erase(RGW_ATTR_CRYPT_PARTS);
attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
@ -5344,6 +5351,8 @@ int RGWRados::copy_obj(RGWObjectCtx& src_obj_ctx,
if (!copy_itself) {
aio = rgw::make_throttle(cct->_conf->rgw_max_copy_obj_concurrent_io, y);
attrs.erase(RGW_ATTR_TAIL_TAG);
attrs.erase(RGW_ATTR_SHARE_MANIFEST);
attrs.erase(RGW_ATTR_BLAKE3);
manifest = *amanifest;
const rgw_bucket_placement& tail_placement = manifest.get_tail_placement();
if (tail_placement.bucket.name.empty()) {
@ -5558,6 +5567,10 @@ int RGWRados::copy_obj_data(RGWObjectCtx& obj_ctx,
: (src_accounted_size ? src_accounted_size : ofs);
}
if (dp_factory) {
accounted_size = dp_factory->get_accounted_size(accounted_size);
}
const req_context rctx{dpp, y, nullptr};
return aoproc.complete(accounted_size, etag, mtime, set_mtime, attrs,
rgw::cksum::no_cksum, delete_at,
@ -5594,6 +5607,68 @@ int fixup_manifest_to_parts_len(const DoutPrefixProvider *dpp, rgw::sal::Attrs &
return 0;
}
/*
* DataProcessorFactory for lifecycle transitions.
*
* Thin subclass of RGWRecompressDPF that supplies a pre-fetched
* decrypt key and zone compression config. The encrypt key is
* built fresh at set_writer() time so AEAD modes get a new salt
* (avoids GCM nonce reuse on re-encrypted plaintext).
*/
class RGWTransitionDPF : public RGWRecompressDPF {
RGWObjectCtx& obj_ctx;
rgw_obj& obj;
std::string dest_compression;
bool compress_encrypted_enabled;
/* pre-fetched by transition_obj() and passed to constructor */
std::unique_ptr<BlockCrypt> prefetched_decrypt;
protected:
int get_decrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
const rgw::sal::Attrs& src_attrs,
std::unique_ptr<BlockCrypt>* crypt) override
{
*crypt = std::move(prefetched_decrypt);
return 0;
}
int get_encrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
rgw::sal::Attrs& dest_attrs,
std::unique_ptr<BlockCrypt>* crypt) override
{
return rgw_prepare_reencrypt_object(dpp, cct, dest_attrs,
obj.bucket.bucket_id,
obj.key.name, y, crypt);
}
const std::string& get_dest_compression() override { return dest_compression; }
bool supports_compress_encrypted() override {
return compress_encrypted_enabled;
}
void mark_compressed() override { obj_ctx.set_compressed(obj); }
public:
RGWTransitionDPF(CephContext* cct_,
uint64_t& obj_size,
const rgw::sal::Attrs& src_attrs,
RGWObjectCtx& obj_ctx_,
rgw_obj& obj_,
const std::string& dest_compression_,
bool compress_encrypted,
std::unique_ptr<BlockCrypt> decrypt)
: RGWRecompressDPF(cct_, obj_size, src_attrs),
obj_ctx(obj_ctx_), obj(obj_),
dest_compression(dest_compression_),
compress_encrypted_enabled(compress_encrypted),
prefetched_decrypt(std::move(decrypt))
{}
};
int RGWRados::transition_obj(RGWObjectCtx& obj_ctx,
RGWBucketInfo& bucket_info,
rgw_obj obj,
@ -5639,12 +5714,116 @@ int RGWRados::transition_obj(RGWObjectCtx& obj_ctx,
}
attrs.erase(RGW_ATTR_ID_TAG);
attrs.erase(RGW_ATTR_TAIL_TAG);
attrs.erase(RGW_ATTR_SHARE_MANIFEST);
attrs.erase(RGW_ATTR_BLAKE3);
ACLOwner owner;
if (auto i = attrs.find(RGW_ATTR_ACL); i != attrs.end()) {
(void) decode_policy(dpp, i->second, &owner);
}
rgw::sal::DataProcessorFactory* dp_factory = nullptr;
std::optional<RGWTransitionDPF> transition_dpf;
const auto& compression_type =
svc.zone->get_zone_params().get_compression_type(placement_rule);
bool src_compressed = false;
RGWCompressionInfo cs_info;
ret = rgw_compression_info_from_attrset(attrs, src_compressed, cs_info);
if (ret < 0)
return ret;
/*
* Determine the effective destination compression. If the source
* is encrypted and the zonegroup doesn't support compress_encrypted,
* force "none" to avoid incompatible layouts.
*/
std::string dest_compression = compression_type;
bool is_encrypted = attrs.count(RGW_ATTR_CRYPT_MODE);
if (is_encrypted &&
!svc.zone->get_zonegroup().supports(
rgw::zone_features::compress_encrypted)) {
dest_compression = "none";
}
/*
* Skip when the source already matches the destination config.
* "random" never matches because the stored codec is concrete
* (e.g. "zlib") while the config string stays "random".
*/
bool already_matches =
dest_compression != "random" &&
((!src_compressed && dest_compression == "none") ||
(src_compressed && cs_info.compression_type == dest_compression));
bool need_recompress = !already_matches;
/*
* Retrieve the decryption key when the source is encrypted
* and compression needs to change. The re-encryption path will
* fetch the same key again from the backend (after regenerating
* the GCM salt for AEAD modes) a second KMS/SSE-S3 round-trip
* per transitioned object.
*/
std::unique_ptr<BlockCrypt> decrypt_crypt;
if (obj_size == 0) {
ldpp_dout(dpp, 20) << __func__ << " " << obj
<< " is empty, skipping recompression" << dendl;
need_recompress = false;
}
if (need_recompress && is_encrypted) {
ret = rgw_prepare_decrypt_object(
dpp, cct, attrs,
bucket_info.bucket.bucket_id, obj.key.name,
y, &decrypt_crypt);
if (ret == -ENOTSUP) {
ldpp_dout(dpp, 10) << __func__ << " cannot decrypt "
<< obj << ", copying as-is" << dendl;
} else if (ret < 0) {
return ret;
}
if (!decrypt_crypt) {
if (src_compressed && !svc.zone->get_zonegroup().supports(
rgw::zone_features::compress_encrypted)) {
ldpp_dout(dpp, 0) << "ERROR: " << obj
<< " is encrypted+compressed but cannot decrypt;"
" refusing to copy incompatible layout when"
" compress_encrypted is disabled" << dendl;
return -ENOTSUP;
}
ldpp_dout(dpp, 10) << __func__ << " " << obj
<< " encrypted but cannot decrypt, copying as-is" << dendl;
need_recompress = false;
} else {
ldpp_dout(dpp, 10) << __func__ << " re-encrypting "
<< obj << " for recompression" << dendl;
}
}
bool compress_encrypted = svc.zone->get_zonegroup().supports(
rgw::zone_features::compress_encrypted);
if (need_recompress) {
transition_dpf.emplace(cct, obj_size, attrs,
obj_ctx, obj,
dest_compression,
compress_encrypted,
std::move(decrypt_crypt));
dp_factory = &*transition_dpf;
} else if (!is_encrypted) {
ldpp_dout(dpp, 20) << __func__
<< " compression already matches dest config ("
<< dest_compression << "), skipping" << dendl;
} else {
ldpp_dout(dpp, 20) << __func__
<< " encrypted, compression already matches, copying as-is"
<< dendl;
}
ret = copy_obj_data(obj_ctx,
owner,
bucket_info,
@ -5658,7 +5837,7 @@ int RGWRados::transition_obj(RGWObjectCtx& obj_ctx,
olh_epoch,
real_time(),
nullptr /* petag */,
nullptr, /* dp_factory */
dp_factory,
dpp,
y,
0, /* src_accounted_size: unknown here, keep ofs-based size */

View File

@ -3,12 +3,16 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "compressor/Compressor.h"
#include "rgw_putobj.h"
#include "rgw_op.h"
#include "rgw_compression_types.h"
#include "rgw_crypt.h"
int rgw_compression_info_from_attr(const bufferlist& attr,
bool& need_decompress,
@ -60,3 +64,352 @@ public:
std::optional<int32_t> get_compressor_message() { return compressor_message; }
}; /* RGWPutObj_Compress */
/*
* Base class for data-processor factories that implement the
* decrypt -> decompress -> recompress -> re-encrypt pipeline.
*/
class RGWRecompressDPF : public rgw::sal::DataProcessorFactory {
protected:
CephContext* cct;
uint64_t& orig_size;
/*
* Source attrs are COPIED into the DPF. In the transition path the
* same map is used for both source metadata and destination attrs
* passed to copy_obj_data(). Storing by reference would destroy
* source metadata when set_writer() strips crypt attrs.
*/
rgw::sal::Attrs src_attrs;
DataProcessorFilter cb;
RGWGetObj_Filter* filter{&cb};
RGWCompressionInfo decompress_info;
std::optional<RGWGetObj_Decompress> decomp;
std::unique_ptr<RGWGetObj_BlockDecrypt> decrypt_filter;
std::unique_ptr<RGWPutObj_BlockEncrypt> encrypt_filter;
CompressorRef compressor_plugin;
std::optional<RGWPutObj_Compress> compressor;
virtual int get_decrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
const rgw::sal::Attrs& src_attrs,
std::unique_ptr<BlockCrypt>* crypt) = 0;
virtual int get_encrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
rgw::sal::Attrs& dest_attrs,
std::unique_ptr<BlockCrypt>* crypt) = 0;
virtual const std::string& get_dest_compression() = 0;
virtual bool supports_compress_encrypted() = 0;
virtual CompressorRef create_compressor(const std::string& type) {
return Compressor::create(cct, type);
}
virtual void mark_compressed() = 0;
virtual void on_obj_size_changed(uint64_t new_size) {}
public:
RGWRecompressDPF(CephContext* cct_,
uint64_t& obj_size,
const rgw::sal::Attrs& src_attrs_)
: cct(cct_), orig_size(obj_size),
src_attrs(src_attrs_) {}
~RGWRecompressDPF() override = default;
int set_writer(rgw::sal::DataProcessor* writer,
rgw::sal::Attrs& attrs,
const DoutPrefixProvider* dpp,
optional_yield y) override
{
bool src_compressed = false;
RGWCompressionInfo cs_info;
int ret = rgw_compression_info_from_attrset(src_attrs, src_compressed,
cs_info);
if (ret < 0) return ret;
uint64_t on_disk_size = orig_size;
bool src_encrypted = src_attrs.count(RGW_ATTR_CRYPT_MODE);
const std::string src_mode = get_str_attribute(src_attrs, RGW_ATTR_CRYPT_MODE);
const bool aead_src = src_encrypted && is_aead_mode(src_mode);
// capture codec before potential cs_info move
std::string src_comp_type;
if (src_compressed) {
src_comp_type = cs_info.compression_type;
}
// determine decrypt availability first — needed for decompress decision
std::unique_ptr<BlockCrypt> decrypt_crypt;
ret = get_decrypt_crypt(dpp, y, src_attrs, &decrypt_crypt);
if (ret < 0) return ret;
/*
* Read multipart part lengths (shared logic both DPFs need this).
* Hard-fail on decode errors to avoid misdecrypting multipart objects.
*/
std::vector<size_t> decrypt_parts;
std::vector<std::pair<uint32_t, std::string>> decrypt_part_keys;
if (decrypt_crypt) {
auto parts_iter = src_attrs.find(RGW_ATTR_CRYPT_PARTS);
if (parts_iter != src_attrs.end()) {
/*
* CRYPT_PARTS present decode it. An empty vector is the
* sentinel for single-stream re-encrypted objects; do NOT
* fall back to the manifest in that case.
*/
auto bl = parts_iter->second;
auto iter = bl.cbegin();
try {
decode(decrypt_parts, iter);
} catch (buffer::error&) {
ldpp_dout(dpp, 0) << "ERROR: malformed "
RGW_ATTR_CRYPT_PARTS << dendl;
return -EIO;
}
} else {
auto manifest_iter = src_attrs.find(RGW_ATTR_MANIFEST);
if (manifest_iter != src_attrs.end()) {
int r = RGWGetObj_BlockDecrypt::read_manifest_parts(
dpp, manifest_iter->second, decrypt_parts);
if (r < 0) {
ldpp_dout(dpp, 0) << "ERROR: failed to read "
"manifest parts" << dendl;
return r;
}
}
}
/*
* AEAD multipart objects also store the (part_num, salt) pairs
* for per-part IV/key derivation. Empty single-part.
*/
if (aead_src) {
if (auto it = src_attrs.find(RGW_ATTR_CRYPT_PART_NUMS);
it != src_attrs.end()) {
auto bl = it->second;
auto iter = bl.cbegin();
try {
decode(decrypt_part_keys, iter);
} catch (buffer::error&) {
ldpp_dout(dpp, 0) << "ERROR: malformed "
RGW_ATTR_CRYPT_PART_NUMS << dendl;
return -EIO;
}
}
}
}
// determine compression match — needed for decompress decision
std::string dest_comp = get_dest_compression();
bool compression_matches =
dest_comp != "random" &&
((!src_compressed && dest_comp == "none") ||
(src_compressed && src_comp_type == dest_comp));
/*
* Decompress decision must happen after decrypt and
* compression_matches are known. Decompress when source is
* compressed AND:
* - source is not encrypted (always safe to decompress), OR
* - source is encrypted AND decrypt is available AND
* compression needs to change (different codec or "none")
*
* When encrypted + same codec, skip decompress compressed
* data passes through after decrypt/re-encrypt.
*/
bool need_decompress = src_compressed &&
!compression_matches &&
(!src_encrypted || decrypt_crypt);
if (need_decompress) {
decompress_info = std::move(cs_info);
decomp.emplace(cct, &decompress_info, false, filter);
filter = &*decomp;
orig_size = decompress_info.orig_size;
on_obj_size_changed(orig_size);
} else if (src_compressed) {
/*
* Compressed-passthrough (same codec): data stays compressed on
* the wire, but bucket logging and event notifications expect
* orig_size to reflect the S3-logical (plaintext) size recorded
* in the source's compression metadata.
*/
orig_size = cs_info.orig_size;
on_obj_size_changed(orig_size);
} else if (aead_src && decrypt_crypt) {
/*
* AEAD without compression: prefer the stored ORIGINAL_SIZE attr
* (authoritative plaintext) and fall back to the chunk/tag
* derivation formula. Both equal plaintext for non-compressed
* sources; the attr matches even if a chunk size ever changes.
*/
uint64_t pt_size = 0;
if (rgw_get_aead_original_size(dpp, src_attrs, &pt_size) ||
rgw_get_aead_decrypted_size(dpp, src_attrs, on_disk_size, &pt_size)) {
orig_size = pt_size;
on_obj_size_changed(orig_size);
}
}
// read: decrypt wraps decompress (outer)
if (decrypt_crypt) {
if (aead_src) {
decrypt_filter = std::make_unique<RGWGetObj_BlockDecrypt>(
dpp, cct, filter, std::move(decrypt_crypt),
std::move(decrypt_parts), std::move(decrypt_part_keys),
static_cast<off_t>(on_disk_size),
static_cast<bool>(decomp), y);
} else {
decrypt_filter = std::make_unique<RGWGetObj_BlockDecrypt>(
dpp, cct, filter, std::move(decrypt_crypt),
std::move(decrypt_parts), y);
}
filter = decrypt_filter.get();
}
/*
* Crypt attr handling for re-encryption. Two modes:
*
* CopyObject: get_encrypt_crypt() strips all crypt attrs itself
* then writes fresh ones (may change key, mode, etc.)
*
* LC transition: same mode and same upstream key are preserved.
* CRYPT_PARTS / CRYPT_PART_NUMS are always cleared (re-encryption
* produces a single stream). For AEAD modes, get_encrypt_crypt()
* also regenerates CRYPT_SALT so the re-derived per-object key
* differs from the source required to avoid GCM nonce reuse
* when the plaintext changes (e.g., after recompression).
*/
if (decrypt_filter) {
attrs.erase(RGW_ATTR_CRYPT_PARTS);
attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
}
// write: encrypt (inner) then compress (outer)
rgw::sal::DataProcessor* processor = writer;
std::unique_ptr<BlockCrypt> encrypt_crypt;
ret = get_encrypt_crypt(dpp, y, attrs, &encrypt_crypt);
if (ret < 0) return ret;
if (encrypt_crypt) {
encrypt_filter = std::make_unique<RGWPutObj_BlockEncrypt>(
dpp, cct, processor, std::move(encrypt_crypt), y);
processor = encrypt_filter.get();
// write empty CRYPT_PARTS sentinel for re-encrypted objects
if (decrypt_filter) {
std::vector<size_t> empty_parts;
bufferlist parts_bl;
encode(empty_parts, parts_bl);
attrs[RGW_ATTR_CRYPT_PARTS] = std::move(parts_bl);
}
} else if (decrypt_filter) {
// decrypt without re-encrypt (SSE-C -> plaintext) — strip all crypt attrs
auto it = attrs.lower_bound(RGW_ATTR_CRYPT_PREFIX);
while (it != attrs.end() &&
it->first.compare(0, sizeof(RGW_ATTR_CRYPT_PREFIX) - 1,
RGW_ATTR_CRYPT_PREFIX) == 0) {
it = attrs.erase(it);
}
}
if (!compression_matches && dest_comp != "none" &&
(encrypt_filter == nullptr || supports_compress_encrypted())) {
compressor_plugin = create_compressor(dest_comp);
if (compressor_plugin) {
compressor.emplace(cct, compressor_plugin, processor);
processor = &*compressor;
mark_compressed();
}
} else if (src_compressed && compression_matches) {
/*
* Source stays compressed through the pipeline (same codec)
* hint the backend that this data is already incompressible.
*/
mark_compressed();
}
if (decomp || compressor) {
attrs.erase(RGW_ATTR_COMPRESSION);
}
cb.set_processor(processor);
// skip fixup for empty objects — size-1 underflows to max uint
if (on_disk_size == 0) {
return 0;
}
/*
* fixup_range is called with the encrypted on-disk size as the
* "plaintext range" argument. RGWGetObj_BlockDecrypt::fixup_range
* expects plaintext-domain input, but project_encrypt_range()
* clamps the projected encrypted range to encrypted_total_size,
* so the final read range is correct regardless. When a decomp
* filter is downstream, the decrypt call cascades into decomp
* with that intermediate (encrypted-as-plaintext) range, which
* is wrong for the decomp side the second decomp->fixup_range()
* below re-initializes it with the proper plaintext range.
*/
if (decrypt_filter) {
off_t ofs = 0;
off_t end = static_cast<off_t>(on_disk_size) - 1;
filter->fixup_range(ofs, end);
}
if (decomp) {
off_t ofs = 0;
off_t end = orig_size - 1;
decomp->fixup_range(ofs, end);
}
return 0;
}
void finalize_attrs(rgw::sal::Attrs& attrs) override {
if (compressor && compressor->is_compressed()) {
bufferlist tmp;
RGWCompressionInfo cs_info;
cs_info.compression_type = compressor_plugin->get_type_name();
cs_info.orig_size = orig_size;
cs_info.compressor_message = compressor->get_compressor_message();
cs_info.blocks = std::move(compressor->get_compression_blocks());
encode(cs_info, tmp);
attrs[RGW_ATTR_COMPRESSION] = tmp;
}
/*
* AEAD bookkeeping for the re-encrypted dest: ORIGINAL_SIZE must
* match the plaintext payload size. set_writer() guarantees
* orig_size is plaintext on every path (decomp output, compressed-
* passthrough cs_info.orig_size, AEAD ORIGINAL_SIZE/formula, or
* the source's logical size). PART_NUMS is cleared because re-
* encryption always produces a single stream. SALT and
* PREFETCH_ALIGN are written by get_encrypt_crypt().
*/
if (encrypt_filter &&
is_aead_mode(get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE))) {
bufferlist sz_bl;
sz_bl.append(std::to_string(orig_size));
attrs[RGW_ATTR_CRYPT_ORIGINAL_SIZE] = std::move(sz_bl);
attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
}
}
uint64_t get_accounted_size(uint64_t default_size) override {
if (decomp)
return orig_size;
return default_size;
}
// LC only constructs DPF when transformation is decided
bool need_copy_data() override {
return true;
}
RGWGetObj_Filter* get_filter() override { return filter; }
};

View File

@ -3041,3 +3041,257 @@ int rgw_remove_sse_s3_bucket_key(req_state *s, optional_yield y)
* Removing those comments makes that work harder.
* February 25, 2021
*********************************************************************/
/*
* Build an AES_256_GCM decrypt crypt from the salt in attrs and the
* caller-supplied user key. Zeroizes user_key on every path.
*/
static int build_aead_decrypt_crypt(
const DoutPrefixProvider* dpp, CephContext* cct,
const rgw::sal::Attrs& attrs,
std::string& user_key,
const std::string& bucket_id,
const std::string& object_name,
const std::string& domain,
std::unique_ptr<BlockCrypt>* block_crypt)
{
auto cleanup = [&]() {
::ceph::crypto::zeroize_for_security(user_key.data(), user_key.length());
};
if (user_key.size() != AES_256_KEYSIZE) {
cleanup();
ldpp_dout(dpp, 0) << "ERROR: " << domain << " key not 256 bits" << dendl;
return -EINVAL;
}
std::string salt = get_str_attribute(attrs, RGW_ATTR_CRYPT_SALT);
if (salt.size() != AES_256_GCM_SALT_SIZE) {
cleanup();
ldpp_dout(dpp, 0) << "ERROR: " << domain
<< " missing/invalid " RGW_ATTR_CRYPT_SALT << dendl;
return -EIO;
}
auto aes = AES_256_GCM_create(dpp, cct,
reinterpret_cast<const uint8_t*>(user_key.c_str()), AES_256_KEYSIZE,
reinterpret_cast<const uint8_t*>(salt.c_str()), salt.size(), 0);
auto* gcm = dynamic_cast<AES_256_GCM*>(aes.get());
if (!gcm || !gcm->derive_object_key(
reinterpret_cast<const uint8_t*>(user_key.c_str()), AES_256_KEYSIZE,
bucket_id, object_name, 0, domain)) {
cleanup();
ldpp_dout(dpp, 0) << "ERROR: " << domain << " key derivation failed" << dendl;
return -EIO;
}
cleanup();
*block_crypt = std::move(aes);
return 0;
}
int rgw_prepare_decrypt_object(const DoutPrefixProvider* dpp,
CephContext* cct,
const rgw::sal::Attrs& attrs,
const std::string& bucket_id,
const std::string& object_name,
optional_yield y,
std::unique_ptr<BlockCrypt>* block_crypt)
{
*block_crypt = nullptr;
auto mode_iter = attrs.find(RGW_ATTR_CRYPT_MODE);
if (mode_iter == attrs.end()) {
return 0;
}
std::string mode = mode_iter->second.to_str();
if (mode == "SSE-C-AES256" || mode == "SSE-C-AES256-GCM") {
return -ENOTSUP;
}
if (mode == "SSE-KMS") {
std::string actual_key;
auto mutable_attrs = attrs;
int r = reconstitute_actual_key_from_kms(dpp, mutable_attrs, nullptr, y, actual_key);
if (r != 0) {
ldpp_dout(dpp, 10) << "ERROR: failed to retrieve KMS key" << dendl;
return r;
}
if (actual_key.size() != AES_256_KEYSIZE) {
ldpp_dout(dpp, 0) << "ERROR: KMS key is not 256 bits" << dendl;
actual_key.replace(0, actual_key.length(), actual_key.length(), '\000');
return -EINVAL;
}
auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(dpp, cct));
aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()),
AES_256_KEYSIZE);
actual_key.replace(0, actual_key.length(), actual_key.length(), '\000');
*block_crypt = std::move(aes);
return 0;
}
if (mode == "RGW-AUTO") {
std::string master_encryption_key;
auto wipe_master = [&]() {
::ceph::crypto::zeroize_for_security(
master_encryption_key.data(), master_encryption_key.length());
};
try {
master_encryption_key = from_base64(
std::string(cct->_conf->rgw_crypt_default_encryption_key));
} catch (...) {
wipe_master();
ldpp_dout(dpp, 5) << "ERROR: invalid default encryption key" << dendl;
return -EINVAL;
}
if (master_encryption_key.size() != 256 / 8) {
wipe_master();
ldpp_dout(dpp, 0) << "ERROR: default encryption key not 256 bits"
<< dendl;
return -EINVAL;
}
std::string attr_key_selector =
get_str_attribute(attrs, RGW_ATTR_CRYPT_KEYSEL);
if (attr_key_selector.size() != AES_256_CBC::AES_256_KEYSIZE) {
wipe_master();
ldpp_dout(dpp, 0) << "ERROR: missing or invalid "
RGW_ATTR_CRYPT_KEYSEL << dendl;
return -EIO;
}
uint8_t actual_key[AES_256_KEYSIZE];
if (!AES_256_ECB_encrypt(dpp, cct,
reinterpret_cast<const uint8_t*>(
master_encryption_key.c_str()),
AES_256_KEYSIZE,
reinterpret_cast<const uint8_t*>(
attr_key_selector.c_str()),
actual_key, AES_256_KEYSIZE)) {
::ceph::crypto::zeroize_for_security(actual_key, sizeof(actual_key));
wipe_master();
return -EIO;
}
auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(dpp, cct));
aes->set_key(actual_key, AES_256_KEYSIZE);
::ceph::crypto::zeroize_for_security(actual_key, sizeof(actual_key));
wipe_master();
*block_crypt = std::move(aes);
return 0;
}
if (mode == "AES256") {
std::string actual_key;
auto mutable_attrs = attrs;
int r = reconstitute_actual_key_from_sse_s3(dpp, mutable_attrs, y, actual_key);
if (r != 0) {
ldpp_dout(dpp, 10) << "ERROR: failed to retrieve SSE-S3 key" << dendl;
return r;
}
if (actual_key.size() != AES_256_KEYSIZE) {
ldpp_dout(dpp, 0) << "ERROR: SSE-S3 key is not 256 bits" << dendl;
actual_key.replace(0, actual_key.length(), actual_key.length(), '\000');
return -EINVAL;
}
auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(dpp, cct));
aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()),
AES_256_KEYSIZE);
actual_key.replace(0, actual_key.length(), actual_key.length(), '\000');
*block_crypt = std::move(aes);
return 0;
}
if (mode == "SSE-KMS-GCM") {
std::string actual_key;
auto mutable_attrs = attrs;
int r = reconstitute_actual_key_from_kms(dpp, mutable_attrs, nullptr, y, actual_key);
if (r != 0) {
ldpp_dout(dpp, 10) << "ERROR: failed to retrieve KMS key" << dendl;
return r;
}
return build_aead_decrypt_crypt(dpp, cct, attrs, actual_key,
bucket_id, object_name,
"SSE-KMS-GCM", block_crypt);
}
if (mode == "AES256-GCM") {
std::string actual_key;
auto mutable_attrs = attrs;
int r = reconstitute_actual_key_from_sse_s3(dpp, mutable_attrs, y, actual_key);
if (r != 0) {
ldpp_dout(dpp, 10) << "ERROR: failed to retrieve SSE-S3 key" << dendl;
return r;
}
return build_aead_decrypt_crypt(dpp, cct, attrs, actual_key,
bucket_id, object_name,
"AES256-GCM", block_crypt);
}
if (mode == "RGW-AUTO-GCM") {
std::string master_encryption_key;
try {
master_encryption_key = from_base64(
std::string(cct->_conf->rgw_crypt_default_encryption_key));
} catch (...) {
::ceph::crypto::zeroize_for_security(
master_encryption_key.data(), master_encryption_key.length());
ldpp_dout(dpp, 5) << "ERROR: invalid default encryption key" << dendl;
return -EINVAL;
}
return build_aead_decrypt_crypt(dpp, cct, attrs, master_encryption_key,
bucket_id, object_name,
"RGW-AUTO-GCM", block_crypt);
}
return -ENOTSUP;
}
int rgw_prepare_reencrypt_object(const DoutPrefixProvider* dpp,
CephContext* cct,
rgw::sal::Attrs& dest_attrs,
const std::string& bucket_id,
const std::string& object_name,
optional_yield y,
std::unique_ptr<BlockCrypt>* block_crypt)
{
*block_crypt = nullptr;
const std::string mode = get_str_attribute(dest_attrs, RGW_ATTR_CRYPT_MODE);
if (mode.empty()) {
return 0;
}
if (!is_aead_mode(mode)) {
return rgw_prepare_decrypt_object(dpp, cct, dest_attrs,
bucket_id, object_name,
y, block_crypt);
}
/*
* For AEAD, stage a fresh salt so that key derivation produces a
* different per-object key on each re-encrypt. Without this, the
* same key+salt would reuse GCM IVs across distinct plaintexts
* (e.g., after recompression). Roll back on failure so dest_attrs
* stays consistent with the on-disk state.
*/
auto saved_iter = dest_attrs.find(RGW_ATTR_CRYPT_SALT);
const bool had_salt = (saved_iter != dest_attrs.end());
bufferlist saved_bl;
if (had_salt) {
saved_bl = saved_iter->second;
}
std::string salt(AES_256_GCM_SALT_SIZE, '\0');
cct->random()->get_bytes(salt.data(), AES_256_GCM_SALT_SIZE);
bufferlist new_salt_bl;
new_salt_bl.append(salt);
dest_attrs[RGW_ATTR_CRYPT_SALT] = std::move(new_salt_bl);
int r = rgw_prepare_decrypt_object(dpp, cct, dest_attrs,
bucket_id, object_name,
y, block_crypt);
if (r < 0) {
if (had_salt) {
dest_attrs[RGW_ATTR_CRYPT_SALT] = std::move(saved_bl);
} else {
dest_attrs.erase(RGW_ATTR_CRYPT_SALT);
}
}
return r;
}

View File

@ -357,6 +357,32 @@ public:
int process(bufferlist&& data, uint64_t logical_offset) override;
}; /* RGWPutObj_BlockEncrypt */
/*
* Build a decrypt crypt from stored attrs (LC: no req_state).
* bucket_id/object_name are used only for AEAD key derivation.
* Returns -ENOTSUP for SSE-C; 0 + nullptr for unencrypted.
*/
int rgw_prepare_decrypt_object(const DoutPrefixProvider* dpp,
CephContext* cct,
const rgw::sal::Attrs& attrs,
const std::string& bucket_id,
const std::string& object_name,
optional_yield y,
std::unique_ptr<BlockCrypt>* block_crypt);
/*
* Build an encrypt crypt for LC in-place re-encryption. For AEAD,
* regenerates dest_attrs[CRYPT_SALT] so the re-derived per-object
* key differs from the source key (avoids GCM nonce reuse).
*/
int rgw_prepare_reencrypt_object(const DoutPrefixProvider* dpp,
CephContext* cct,
rgw::sal::Attrs& dest_attrs,
const std::string& bucket_id,
const std::string& object_name,
optional_yield y,
std::unique_ptr<BlockCrypt>* block_crypt);
int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
std::map<std::string, ceph::bufferlist>& attrs,

View File

@ -6,6 +6,7 @@
*/
#include <sys/stat.h>
#include <optional>
#include "include/str_map.h"
#include "common/safe_io.h"
#include "rgw/rgw_crypt.h"
@ -1191,10 +1192,12 @@ static int maybe_cache_kms_fetch(
if (kms_cache == nullptr ||
!dpp->get_cct()->_conf->rgw_crypt_s3_kms_cache_enabled) {
const auto ret = fetch(actual_key);
if (ret == -ENOENT) {
perfcounter->inc(l_rgw_kms_error_permanent);
} else if (ret < 0) {
perfcounter->inc(l_rgw_kms_error_transient);
if (perfcounter) {
if (ret == -ENOENT) {
perfcounter->inc(l_rgw_kms_error_permanent);
} else if (ret < 0) {
perfcounter->inc(l_rgw_kms_error_transient);
}
}
return ret;
}
@ -1235,7 +1238,10 @@ int reconstitute_actual_key_from_kms(
}
const auto fetch = [&](std::string& out_secret) -> int {
PerfGuard perf(perfcounter, l_rgw_kms_fetch_lat);
std::optional<PerfGuard> perf;
if (perfcounter) {
perf.emplace(perfcounter, l_rgw_kms_fetch_lat);
}
if (RGW_SSE_KMS_BACKEND_BARBICAN == kms_backend) {
return get_actual_key_from_barbican(dpp, key_id, y, out_secret);
}

View File

@ -6064,203 +6064,123 @@ void RGWDeleteObj::execute(optional_yield y)
}
}
class RGWCopyObjDPF : public rgw::sal::DataProcessorFactory {
rgw::sal::Driver* driver;
req_state* s;
uint64_t &obj_size;
std::map<std::string, std::string>& crypt_http_responses;
DataProcessorFilter cb;
RGWGetObj_Filter* filter{&cb};
bool need_decompress{false};
RGWCompressionInfo decompress_info;
boost::optional<RGWGetObj_Decompress> decompress;
std::unique_ptr<RGWGetObj_Filter> decrypt;
std::unique_ptr<rgw::sal::DataProcessor> encrypt;
std::optional<RGWPutObj_Compress> compressor;
CompressorRef compressor_plugin;
off_t ofs_x{0};
off_t end_x = obj_size;
public:
RGWCopyObjDPF(rgw::sal::Driver* _driver,
req_state* _s,
uint64_t& _obj_size,
std::map<std::string, std::string>& _crypt_http_responses)
: driver(_driver),
s(_s),
obj_size(_obj_size),
crypt_http_responses(_crypt_http_responses)
{}
~RGWCopyObjDPF() override {}
int get_encrypt_filter(std::unique_ptr<rgw::sal::DataProcessor> *filter,
rgw::sal::DataProcessor *cb,
rgw::sal::Attrs& attrs)
{
std::unique_ptr<BlockCrypt> block_crypt;
int res = rgw_s3_prepare_encrypt(s, s->yield, attrs, &block_crypt,
crypt_http_responses);
if (res == 0 && block_crypt != nullptr) {
filter->reset(new RGWPutObj_BlockEncrypt(s, s->cct, cb, std::move(block_crypt), s->yield));
static bool copy_dest_requests_encryption(req_state* s)
{
for (const auto& attr : {"x-amz-server-side-encryption-customer-algorithm",
"x-amz-server-side-encryption"}) {
if (s->info.crypt_attribute_map.count(attr)) {
return true;
}
return res;
}
/*
* rgw_crypt_default_encryption_key produces an RGW-AUTO encrypt
* filter without any explicit SSE header (bucket-level encryption
* already synthesizes the header upstream and is covered above).
*/
if (!s->cct->_conf->rgw_crypt_default_encryption_key.empty()) {
return true;
}
return false;
}
class RGWCopyObjDPF : public RGWRecompressDPF {
req_state* s;
std::string normalized_dest_compression;
std::map<std::string, std::string>& crypt_http_responses;
protected:
int get_decrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
const rgw::sal::Attrs& src_attrs,
std::unique_ptr<BlockCrypt>* crypt) override
{
// copy: rgw_s3_prepare_decrypt takes non-const attrs
auto mutable_attrs = src_attrs;
return rgw_s3_prepare_decrypt(s, y, mutable_attrs, crypt,
nullptr, true /* copy_source */);
}
int set_writer(rgw::sal::DataProcessor* writer,
rgw::sal::Attrs& attrs,
const DoutPrefixProvider *dpp,
optional_yield y) override
int get_encrypt_crypt(const DoutPrefixProvider* dpp,
optional_yield y,
rgw::sal::Attrs& dest_attrs,
std::unique_ptr<BlockCrypt>* crypt) override
{
/* RGWGetObj_Filter */
// decompress
int ret = rgw_compression_info_from_attrset(s->src_object->get_attrs(), need_decompress, decompress_info);
if (ret < 0) {
return ret;
// strip all old crypt attrs — CopyObj may change key/mode
auto it = dest_attrs.lower_bound(RGW_ATTR_CRYPT_PREFIX);
while (it != dest_attrs.end() &&
it->first.compare(0, sizeof(RGW_ATTR_CRYPT_PREFIX) - 1,
RGW_ATTR_CRYPT_PREFIX) == 0) {
it = dest_attrs.erase(it);
}
bool src_encrypted = s->src_object->get_attrs().count(RGW_ATTR_CRYPT_MODE);
// Preserve encrypted size for decrypt range clamping before any plaintext conversion.
off_t encrypted_total_size = obj_size;
// Create decompress filter if source is compressed.
// Must be created BEFORE decrypt so the chain is: decrypt → decompress → cb
if (need_decompress) {
static constexpr bool partial_content = false;
decompress.emplace(s->cct, &decompress_info, partial_content, filter);
filter = &*decompress;
}
// decrypt
if (src_encrypted) {
auto attr_iter = s->src_object->get_attrs().find(RGW_ATTR_MANIFEST);
static constexpr bool copy_source = true;
// part_num=0 for copy source (full object read)
ret = get_decrypt_filter(&decrypt, filter, s, s->src_object->get_attrs(),
attr_iter != s->src_object->get_attrs().end() ? &attr_iter->second : nullptr,
nullptr, copy_source, 0, encrypted_total_size);
if (ret < 0) {
return ret;
}
if (decrypt != nullptr) {
filter = decrypt.get();
}
}
// Set obj_size to the final output size (plaintext) for range handling.
if (need_decompress) {
obj_size = decompress_info.orig_size;
s->src_object->set_obj_size(obj_size);
end_x = obj_size;
} else if (src_encrypted) {
uint64_t decrypted_size = 0;
const auto& src_attrs = s->src_object->get_attrs();
if (rgw_calc_aead_obj_size(dpp, src_attrs, encrypted_total_size,
src_attrs.count(RGW_ATTR_COMPRESSION), &decrypted_size)) {
obj_size = decrypted_size;
s->src_object->set_obj_size(obj_size);
end_x = obj_size;
}
}
filter->fixup_range(ofs_x, end_x);
/* rgw::sal::DataProcessor */
// encrypt
rgw::sal::DataProcessor* processor = writer;
ret = get_encrypt_filter(&encrypt, processor, attrs);
if (ret < 0) {
return ret;
}
if (encrypt != nullptr) {
processor = &*encrypt;
}
// compression
attrs.erase(RGW_ATTR_COMPRESSION); // remove any existing compression info from source object
// a zonegroup feature is required to combine compression and encryption
const RGWZoneGroup& zonegroup = s->penv.site->get_zonegroup();
const bool compress_encrypted = zonegroup.supports(rgw::zone_features::compress_encrypted);
const auto& compression_type = driver->get_compression_type(s->dest_placement);
if (compression_type != "none" &&
(encrypt == nullptr || compress_encrypted)) {
compressor_plugin = get_compressor_plugin(s, compression_type);
if (!compressor_plugin) {
ldpp_dout(s, 1) << "Cannot load plugin for compression type "
<< compression_type << dendl;
} else {
compressor.emplace(s->cct, compressor_plugin, processor);
processor = &*compressor;
// always send incompressible hint when rgw is itself doing compression
s->object->set_compressed();
}
}
cb.set_processor(processor);
std::unique_ptr<BlockCrypt> block_crypt;
int ret = rgw_s3_prepare_encrypt(s, y, dest_attrs, &block_crypt,
crypt_http_responses);
if (ret < 0) return ret;
*crypt = std::move(block_crypt);
return 0;
}
const std::string& get_dest_compression() override {
return normalized_dest_compression;
}
bool supports_compress_encrypted() override {
return s->penv.site->get_zonegroup().supports(
rgw::zone_features::compress_encrypted);
}
// stable codec for multipart copies
CompressorRef create_compressor(const std::string& type) override {
return get_compressor_plugin(s, type);
}
void mark_compressed() override {
s->object->set_compressed();
}
void on_obj_size_changed(uint64_t new_size) override {
// The src_object's in-memory state is what downstream consumers
// in RGWCopyObj::execute (bucket logging, publish_commit, op
// counters) read via the obj_size reference held by the base.
s->src_object->set_obj_size(new_size);
}
bool dest_requests_encryption() const {
return copy_dest_requests_encryption(s);
}
public:
RGWCopyObjDPF(req_state* s_,
uint64_t& obj_size,
const rgw::sal::Attrs& src_attrs,
const std::string& dest_compression,
std::map<std::string, std::string>& crypt_http_responses_)
: RGWRecompressDPF(s_->cct, obj_size, src_attrs),
s(s_),
normalized_dest_compression(dest_compression),
crypt_http_responses(crypt_http_responses_)
{}
bool need_copy_data() override {
// if source object is encrypted, we need to copy data
if (s->src_object->get_attrs().count(RGW_ATTR_CRYPT_MODE)) {
// source is encrypted
if (src_attrs.count(RGW_ATTR_CRYPT_MODE)) return true;
// destination encryption requires slow path
if (dest_requests_encryption()) return true;
// compression change: preserve fast path for same-codec copies
bool src_compressed = false;
RGWCompressionInfo cs_info;
if (rgw_compression_info_from_attrset(src_attrs, src_compressed,
cs_info) < 0) {
return true;
}
// check if it's requested to be encrypted
static const string crypt_attrs[] = {
"x-amz-server-side-encryption-customer-algorithm", // SSE-C
"x-amz-server-side-encryption", // SSE-S3, SSE-KMS
};
for (const auto& attr : crypt_attrs) {
if (s->info.crypt_attribute_map.find(attr) !=
s->info.crypt_attribute_map.end()) {
return true;
}
}
return false;
}
RGWGetObj_Filter* get_filter() override {
return filter;
}
void finalize_attrs(rgw::sal::Attrs& attrs) override {
if (compressor && compressor->is_compressed()) {
bufferlist tmp;
RGWCompressionInfo cs_info;
assert(compressor_plugin != nullptr);
// plugin exists when the compressor does
// coverity[dereference:SUPPRESS]
cs_info.compression_type = compressor_plugin->get_type_name();
cs_info.orig_size = obj_size;
cs_info.compressor_message = compressor->get_compressor_message();
cs_info.blocks = std::move(compressor->get_compression_blocks());
encode(cs_info, tmp);
attrs[RGW_ATTR_COMPRESSION] = tmp;
ldpp_dout(s, 20) << "storing " << RGW_ATTR_COMPRESSION
<< " with type=" << cs_info.compression_type
<< ", orig_size=" << cs_info.orig_size
<< ", compressor_message=" << cs_info.compressor_message
<< ", blocks=" << cs_info.blocks.size() << dendl;
}
// Copy path: ensure AEAD ORIGINAL_SIZE matches copied payload size.
// If it doesn't, stale CRYPT_PARTS and CRYPT_PART_NUMS must be dropped.
const auto mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
if (is_aead_mode(mode)) {
uint64_t existing = 0;
if (!rgw_get_aead_original_size(s, attrs, &existing) ||
existing != obj_size) {
set_attr(attrs, RGW_ATTR_CRYPT_ORIGINAL_SIZE, std::to_string(obj_size));
attrs.erase(RGW_ATTR_CRYPT_PARTS);
attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
}
}
bool compression_matches =
normalized_dest_compression != "random" &&
((!src_compressed && normalized_dest_compression == "none") ||
(src_compressed &&
cs_info.compression_type == normalized_dest_compression));
return !compression_matches;
}
};
@ -6611,7 +6531,24 @@ void RGWCopyObj::execute(optional_yield y)
return;
}
RGWCopyObjDPF copy_obj_dpf(driver, s, obj_size, crypt_http_responses);
/*
* Compute the normalized destination compression for CopyObject.
* When the effective destination will be encrypted and the zonegroup
* doesn't support compress_encrypted, force "none" to prevent
* writing an incompatible compressed+encrypted layout.
*/
const auto& raw_dest_compression =
driver->get_compression_type(s->dest_placement);
std::string dest_compression = raw_dest_compression;
if (copy_dest_requests_encryption(s) &&
!s->penv.site->get_zonegroup().supports(
rgw::zone_features::compress_encrypted)) {
dest_compression = "none";
}
RGWCopyObjDPF copy_obj_dpf(s, obj_size, s->src_object->get_attrs(),
dest_compression, crypt_http_responses);
op_ret = s->src_object->copy_object(s->owner,
s->user->get_id(),

View File

@ -290,6 +290,16 @@ class DataProcessorFactory {
virtual RGWGetObj_Filter* get_filter() = 0;
virtual bool need_copy_data() = 0;
virtual void finalize_attrs(Attrs& attrs) { /* default implementation does nothing */ }
/*
* Override the accounted size for the bucket index. Called after
* finalize_attrs(). Returns the logical object size when the
* factory knows better than the default heuristic, e.g. after
* decompressing data whose recompression was a no-op.
*/
virtual uint64_t get_accounted_size(uint64_t default_size) {
return default_size;
}
};
/**

View File

@ -1523,6 +1523,66 @@ TEST(TestRGWCrypto, verify_PartLocation_no_manifest)
}
/*
* Verify the encrypt decrypt filter pipeline produces
* identical plaintext. Full helper create() tests require
* SAL mocks and are deferred to integration testing.
*/
TEST(TestRGWCrypto, verify_transition_encrypt_decrypt_roundtrip)
{
const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
// test both block-aligned and non-aligned sizes
for (size_t test_size : {8192, 7777}) {
uint8_t key[32];
for (size_t i = 0; i < sizeof(key); i++)
key[i] = i * 3 + 7;
std::vector<uint8_t> plaintext(test_size);
for (size_t i = 0; i < test_size; i++)
plaintext[i] = (i * 7 + 13) & 0xff;
// encrypt
ut_put_sink put_sink;
{
auto cbc = AES_256_CBC_create(&no_dpp, g_ceph_context, key, 32);
ASSERT_NE(cbc.get(), nullptr);
RGWPutObj_BlockEncrypt encrypt(&no_dpp, g_ceph_context, &put_sink,
std::move(cbc), null_yield);
bufferlist bl;
bl.append((char*)plaintext.data(), test_size);
ASSERT_EQ(encrypt.process(std::move(bl), 0), 0);
ASSERT_EQ(encrypt.process({}, test_size), 0);
}
std::string ciphertext = put_sink.get_sink();
// decrypt with a different key instance (same bytes)
ut_get_sink get_sink;
{
auto cbc = AES_256_CBC_create(&no_dpp, g_ceph_context, key, 32);
ASSERT_NE(cbc.get(), nullptr);
RGWGetObj_Filter* filter = &get_sink;
RGWGetObj_BlockDecrypt decrypt(&no_dpp, g_ceph_context, filter,
std::move(cbc),
std::vector<size_t>{}, null_yield);
filter = &decrypt;
off_t ofs = 0;
off_t end = test_size - 1;
filter->fixup_range(ofs, end);
bufferlist enc_bl;
enc_bl.append(ciphertext.data(), ciphertext.size());
ASSERT_EQ(filter->handle_data(enc_bl, ofs, enc_bl.length()), 0);
ASSERT_EQ(filter->flush(), 0);
}
std::string result = get_sink.get_sink();
ASSERT_EQ(result.size(), test_size);
ASSERT_EQ(memcmp(result.data(), plaintext.data(), test_size), 0);
}
}
int main(int argc, char **argv) {
auto args = argv_to_vec(argc, argv);
auto cct = global_init(NULL, args, CEPH_ENTITY_TYPE_CLIENT,