]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
rgw: add AES-256-GCM (AEAD) support for server-side encryption
authorMatthew N. Heler <matthew.heler@hotmail.com>
Wed, 28 Jan 2026 04:06:17 +0000 (22:06 -0600)
committerMatthew N. Heler <matthew.heler@hotmail.com>
Wed, 20 May 2026 18:31:02 +0000 (13:31 -0500)
This adds GCM as an alternative to the existing CBC cipher for SSE-C,
SSE-KMS, SSE-S3, and RGW-AUTO. GCM provides authenticated encryption,
meaning it detects tampering during decryption rather than silently
returning corrupted data.

The new rgw_crypt_sse_algorithm config option controls which cipher is
used for new uploads. The default remains aes-256-cbc for backward
compatibility with older RGW versions in mixed clusters. Once all nodes
are upgraded, administrators can enable aes-256-gcm for new objects.
Existing CBC-encrypted objects continue to decrypt correctly regardless
of this setting.

GCM encrypts in 4KB chunks, each producing 4112 bytes of ciphertext
(4096 plaintext + 16-byte authentication tag). This means encrypted
objects are larger than their plaintext. To preserve correct behavior:
- RGW_ATTR_CRYPT_ORIGINAL_SIZE stores the plaintext size
- Content-Length and bucket listings report the plaintext size
- Range requests translate plaintext offsets to storage offsets

Each object gets a random 12-byte nonce stored in RGW_ATTR_CRYPT_NONCE.
This nonce serves two purposes: it's combined with chunk indices to
derive unique IVs for each encrypted block, and for SSE-C it's included
in the key derivation to bind ciphertext to object identity. Moving
encrypted data at the RADOS level causes decryption to fail rather than
silently producing garbage.

Multipart uploads derive per-part keys and use the S3 part number in
IV derivation to guarantee unique IVs across parts. The actual part
numbers are stored in RGW_ATTR_CRYPT_PART_NUMS during CompleteMultipart
to handle non-contiguous uploads (e.g., parts 1, 3, 5).

The implementation uses generic AEAD abstractions (is_aead_mode(),
aead_plaintext_to_encrypted_size(), etc.) so that adding other
authenticated ciphers like ChaCha20-Poly1305 in the future requires
only implementing the cipher itself—the size handling, range request
translation, and multipart machinery will work unchanged.

Originally-by: Kyle Bader <kbader@ibm.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Matthew N. Heler <matthew.heler@hotmail.com>
13 files changed:
doc/radosgw/compression.rst
doc/radosgw/config-ref.rst
doc/radosgw/encryption.rst
src/common/options/rgw.yaml.in
src/rgw/driver/rados/rgw_rados.cc
src/rgw/driver/rados/rgw_sal_rados.cc
src/rgw/rgw_common.h
src/rgw/rgw_crypt.cc
src/rgw/rgw_crypt.h
src/rgw/rgw_op.cc
src/rgw/rgw_op.h
src/rgw/rgw_rest_s3.cc
src/test/rgw/test_rgw_crypto.cc

index 3a286f032b6f6d6008056181a32a59440bfda99c..3c7ad4a01ae00b3cbd9a7ab6883661847be46702 100644 (file)
@@ -110,8 +110,16 @@ for a given bucket:
   }
 
 Other commands and APIs will report object and bucket sizes based on their
-uncompressed data. 
+uncompressed data.
 
-The ``size_utilized`` and ``size_kb_utilized`` fields represent the total
-size of compressed data, in bytes and kilobytes respectively.
+The ``size_utilized`` and ``size_kb_utilized`` fields represent the actual
+bytes stored on disk, in bytes and kilobytes respectively. This value reflects
+the effects of both compression and encryption:
+
+* With compression only: size after compression
+* With AEAD encryption only (e.g., AES-256-GCM): size after encryption
+  (includes authentication tag overhead)
+* With both compression and encryption: size after compression then encryption
+
+See :ref:`radosgw-encryption` for details on encryption algorithms.
 
index 246bd43b604f0a07e28142126b57f0427378876a..7460229237f0823ca39da64c22489a8be67731ee 100644 (file)
@@ -233,6 +233,7 @@ Server-side Encryption Settings
 ===============================
 
 .. confval:: rgw_crypt_s3_kms_backend
+.. confval:: rgw_crypt_sse_algorithm
 
 Barbican Settings
 =================
index 64e8b63dc3a33d74ce7a3e36b2fd543c855bc078..f973dc28c75d389dbd6dc9a52a9c842d8179b18a 100644 (file)
@@ -18,6 +18,72 @@ Object Gateway stores that data in the Ceph Storage Cluster in encrypted form.
 
 .. note:: Server-side encryption keys must be 256-bit long and base64 encoded.
 
+Encryption Algorithm
+====================
+
+.. versionadded:: Umbrella
+
+The Ceph Object Gateway supports two AES-256 encryption algorithms for
+server-side encryption:
+
+**AES-256-CBC** (Cipher Block Chaining)
+  The legacy encryption algorithm. This mode is compatible with older Ceph
+  releases and is the default for backward compatibility. CBC mode encrypts
+  data but does not provide built-in integrity verification.
+
+**AES-256-GCM** (Galois/Counter Mode)
+  A modern authenticated encryption algorithm that provides both
+  confidentiality and integrity protection. GCM mode detects any tampering
+  or corruption of encrypted data. This is the recommended algorithm for
+  new deployments.
+
+The encryption algorithm for new objects can be configured with::
+
+  rgw crypt sse algorithm = aes-256-cbc    # default, for backward compatibility
+  rgw crypt sse algorithm = aes-256-gcm    # recommended for new deployments
+
+.. note:: This setting only affects newly encrypted objects. Existing objects
+          are always decrypted using the algorithm that was used when they
+          were encrypted, regardless of the current setting. This allows
+          CBC-encrypted and GCM-encrypted objects to coexist in the same
+          cluster.
+
+.. important:: When upgrading from an older Ceph release, keep the default
+               ``aes-256-cbc`` setting until all RGW instances have been
+               upgraded. Once all instances support GCM, you can enable
+               ``aes-256-gcm`` for new uploads.
+
+GCM Encryption Format
+---------------------
+
+AES-256-GCM encrypts data in 4 KB (4096 byte) chunks. Each chunk produces
+4112 bytes of ciphertext (4096 bytes of encrypted data plus a 16-byte
+authentication tag).
+
+.. list-table:: GCM Size Calculation Example
+   :header-rows: 1
+   :widths: 40 30 30
+
+   * - Description
+     - Size
+     - Notes
+   * - Original plaintext
+     - 10,000 bytes
+     - User's data
+   * - Number of chunks
+     - 3
+     - ⌈10000 ÷ 4096⌉
+   * - Authentication tags
+     - 48 bytes
+     - 3 chunks × 16 bytes
+   * - **Encrypted on disk**
+     - **10,048 bytes**
+     - plaintext + tags
+
+The storage overhead is approximately 0.4% (16 bytes per 4 KB). S3 API
+responses always report the original plaintext size, so this overhead is
+transparent to clients.
+
 Customer-Provided Keys
 ======================
 
index af0d84b4ebd63fcc15ea2713877951c2e405fab4..10e26c0f6af244d2354ed02f8d73a94d5f2b1bd3 100644 (file)
@@ -3295,6 +3295,25 @@ options:
   services:
   - rgw
   with_legacy: true
+- name: rgw_crypt_sse_algorithm
+  type: str
+  level: advanced
+  desc: Default encryption algorithm for server-side encryption
+  long_desc: Specifies the default AES encryption algorithm to use for new objects
+    encrypted with SSE-C, SSE-KMS, SSE-S3, or RGW-AUTO modes. Valid values are
+    aes-256-cbc (legacy, compatible with older RGW versions) and aes-256-gcm
+    (recommended, provides authenticated encryption). Existing encrypted objects
+    are always decrypted using the algorithm specified in their metadata,
+    regardless of this setting.
+  default: aes-256-cbc
+  services:
+  - rgw
+  see_also:
+  - rgw_crypt_require_ssl
+  enum_values:
+  - aes-256-cbc
+  - aes-256-gcm
+  with_legacy: true
 - name: rgw_crypt_sse_s3_backend
   type: str
   level: advanced
index 936a1109b551e0f8761931edde13ea414dafd8df..4efd291603c580bb7e407f0282cae1b992a431b1 100644 (file)
@@ -36,6 +36,7 @@
 #include "rgw_rest_conn.h"
 #include "rgw_cr_rados.h"
 #include "rgw_cr_rest.h"
+#include "rgw_crypt.h"
 #include "rgw_datalog.h"
 #include "rgw_putobj_processor.h"
 #include "rgw_lc_tier.h"
@@ -3538,8 +3539,28 @@ int RGWRados::Object::Write::_do_write_meta(uint64_t size, uint64_t accounted_si
     ldpp_dout(rctx.dpp, 0) << "ERROR: complete_atomic_modification returned r=" << r << dendl;
   }
 
+  /**
+   * For AEAD encryption (GCM), set bucket index sizes:
+   *   index_size = encrypted bytes on disk (for size_utilized)
+   *   index_accounted_size = plaintext bytes (for quota/listings)
+   *
+   * This is consistent with compression behavior where:
+   *   index_size = compressed bytes, index_accounted_size = uncompressed bytes
+   *
+   * For non-AEAD (CBC or unencrypted), both remain as passed in.
+   */
+  uint64_t index_size, index_accounted_size;
+  {
+    index_size = size;
+    index_accounted_size = accounted_size;
+    uint64_t original_size = 0;
+    if (rgw_get_aead_original_size(rctx.dpp, attrs, &original_size)) {
+      index_accounted_size = original_size;
+    }
+  }
+
   tracepoint(rgw_rados, complete_enter, req_id.c_str());
-  r = index_op->complete(rctx.dpp, poolid, epoch, size, accounted_size,
+  r = index_op->complete(rctx.dpp, poolid, epoch, index_size, index_accounted_size,
                         meta.set_mtime, etag, content_type,
                         storage_class, meta.owner,
                         meta.category, meta.remove_objs, rctx.y,
@@ -5157,14 +5178,21 @@ int RGWRados::copy_obj(RGWObjectCtx& src_obj_ctx,
   src_attrs.erase(RGW_ATTR_OBJ_REPLICATION_TIMESTAMP);
   src_attrs.erase(RGW_ATTR_OBJ_REPLICATION_STATUS);
 
-  // drop encryption attributes
-  // will be generated by copy_obj_data() if encryption is requested
+  /**
+   * Drop encryption attributes - will be generated by copy_obj_data() if
+   * encryption is requested. CRYPT_ORIGINAL_SIZE and CRYPT_PARTS are preserved
+   * for size calculations. CRYPT_PART_NUMS must be erased because copy writes
+   * data as a single stream (part 0), so stale part numbers from a multipart
+   * source would cause wrong key derivation.
+   */
   src_attrs.erase(RGW_ATTR_CRYPT_KEYSEL);
   src_attrs.erase(RGW_ATTR_CRYPT_CONTEXT);
   src_attrs.erase(RGW_ATTR_CRYPT_MODE);
   src_attrs.erase(RGW_ATTR_CRYPT_KEYID);
   src_attrs.erase(RGW_ATTR_CRYPT_KEYMD5);
   src_attrs.erase(RGW_ATTR_CRYPT_DATAKEY);
+  src_attrs.erase(RGW_ATTR_CRYPT_NONCE);
+  src_attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
 
   set_copy_attrs(src_attrs, attrs, attrs_mod);
   attrs.erase(RGW_ATTR_ID_TAG);
@@ -5260,6 +5288,9 @@ 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);
+    // Data is rewritten as a single stream; drop stale multipart boundaries
+    attrs.erase(RGW_ATTR_CRYPT_PARTS);
+    attrs.erase(RGW_ATTR_CRYPT_PART_NUMS);
     return copy_obj_data(dest_obj_ctx, owner, dest_bucket_info, dest_placement, read_op, obj_size - 1, dest_obj,
                          mtime, real_time(), attrs, olh_epoch, delete_at, petag, dp_factory, dpp, y);
   }
@@ -7138,6 +7169,25 @@ int RGWRados::get_obj_state_impl(const DoutPrefixProvider *dpp, RGWObjectCtx *oc
       s->fake_tag = true;
     }
   }
+
+  /**
+   * For AEAD encryption: adjust accounted_size to original size.
+   * Helpers return false for non-AEAD modes (including CBC), so this is a no-op
+   * outside of AEAD.
+   * Must be after manifest handling since manifest->get_obj_size() returns
+   * encrypted size and re-sets accounted_size.
+   */
+  if (!compressed && s->accounted_size == s->size) {
+    uint64_t original_size = 0;
+    if (rgw_get_aead_original_size(dpp, s->attrset, &original_size)) {
+      s->accounted_size = original_size;
+    } else {
+      uint64_t decrypted_size = 0;
+      if (rgw_get_aead_decrypted_size(dpp, s->attrset, s->size, &decrypted_size)) {
+        s->accounted_size = decrypted_size;
+      }
+    }
+  }
   if (iter = s->attrset.find(RGW_ATTR_PG_VER); iter != s->attrset.end()) {
     const bufferlist& pg_ver_bl = iter->second;
     if (pg_ver_bl.length()) {
@@ -7943,6 +7993,19 @@ int RGWRados::Object::Read::prepare(optional_yield y, const DoutPrefixProvider *
     }
 
     for (auto& iter : src_attrset) {
+      /**
+       * Skip object-level encryption attributes when reading individual parts.
+       * These attrs describe the complete multipart object, not this part:
+       *   - ORIGINAL_SIZE: would cause Content-Length mismatch
+       *   - PARTS: contains sizes of all parts, not applicable to single part
+       *   - PART_NUMS: maps part indices to S3 part numbers for the full object
+       */
+      if (iter.first == RGW_ATTR_CRYPT_ORIGINAL_SIZE ||
+          iter.first == RGW_ATTR_CRYPT_PARTS ||
+          iter.first == RGW_ATTR_CRYPT_PART_NUMS) {
+        ldpp_dout(dpp, 4) << "skip crypt attr for part read: " << iter.first << dendl;
+        continue;
+      }
       ldpp_dout(dpp, 4) << "copy crypt attr: " << iter.first << dendl;
       if (astate->attrset.find(iter.first) == astate->attrset.end()) {
         astate->attrset[iter.first] = std::move(iter.second);
index 0cfddd559875d2913d039ba3a262b1e8948ce7ab..841ac0a6ec03dd03d1395fe5e616d1a5ccba1baf 100644 (file)
@@ -40,6 +40,7 @@
 #include "rgw_aio_throttle.h"
 #include "rgw_bucket.h"
 #include "rgw_bucket_logging.h"
+#include "rgw_crypt.h"
 #include "rgw_bl_rados.h"
 #include "rgw_lc.h"
 #include "rgw_lc_tier.h"
@@ -4279,6 +4280,15 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp,
   auto etags_iter = part_etags.begin();
   rgw::sal::Attrs& attrs = target_obj->get_attrs();
 
+  // Check if this is AEAD encryption to track plaintext size
+  bool is_aead = false;
+  uint64_t plaintext_ofs = 0;
+  auto mode_iter = attrs.find(RGW_ATTR_CRYPT_MODE);
+  if (mode_iter != attrs.end()) {
+    std::string crypt_mode = mode_iter->second.to_str();
+    is_aead = is_aead_mode(crypt_mode);
+  }
+
   do {
     ret = list_parts(dpp, cct, max_parts, marker, &marker, &truncated, y);
     if (ret == -ENOENT) {
@@ -4394,6 +4404,16 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp,
 
       ofs += obj_part.size;
       accounted_size += obj_part.accounted_size;
+
+      // Track plaintext size for AEAD encryption
+      if (is_aead) {
+        if (part_compressed) {
+          // For compressed parts, use the uncompressed size directly
+          plaintext_ofs += obj_part.accounted_size;
+        } else {
+          plaintext_ofs += aead_encrypted_to_plaintext_size(obj_part.size);
+        }
+      }
     }
   } while (truncated);
   hash.Final((unsigned char *)final_etag);
@@ -4432,6 +4452,26 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp,
     attrs[RGW_ATTR_COMPRESSION] = tmp;
   }
 
+  // For AEAD encryption: store total plaintext size (calculated during loop)
+  if (is_aead) {
+    bufferlist bl;
+    bl.append(std::to_string(plaintext_ofs));
+    attrs[RGW_ATTR_CRYPT_ORIGINAL_SIZE] = std::move(bl);
+
+    // Store actual S3 part numbers for correct IV/key derivation during decrypt
+    std::vector<uint32_t> part_nums;
+    part_nums.reserve(part_etags.size());
+    for (const auto& part : part_etags) {
+      part_nums.push_back(static_cast<uint32_t>(part.first));
+    }
+    bufferlist part_nums_bl;
+    using ceph::encode;
+    encode(part_nums, part_nums_bl);
+    attrs[RGW_ATTR_CRYPT_PART_NUMS] = std::move(part_nums_bl);
+    ldpp_dout(dpp, 20) << "Stored CRYPT_PART_NUMS with " << part_nums.size()
+                       << " parts" << dendl;
+  }
+
   target_obj->set_atomic(true);
 
   const RGWBucketInfo& bucket_info = target_obj->get_bucket()->get_info();
index e697749c99d7f0ec98a455b4bed36ed1d4bfc6ce..7a3ba596ff4b97eeb5fd02f49c5a076c830401ae 100644 (file)
@@ -191,6 +191,9 @@ using ceph::crypto::MD5;
 #define RGW_ATTR_CRYPT_CONTEXT  RGW_ATTR_CRYPT_PREFIX "context"
 #define RGW_ATTR_CRYPT_DATAKEY  RGW_ATTR_CRYPT_PREFIX "datakey"
 #define RGW_ATTR_CRYPT_PARTS    RGW_ATTR_CRYPT_PREFIX "part-lengths"
+#define RGW_ATTR_CRYPT_PART_NUMS RGW_ATTR_CRYPT_PREFIX "part-numbers"
+#define RGW_ATTR_CRYPT_NONCE    RGW_ATTR_CRYPT_PREFIX "nonce"
+#define RGW_ATTR_CRYPT_ORIGINAL_SIZE RGW_ATTR_CRYPT_PREFIX "original-size"
 
 /* SSE-S3 Encryption Attributes */
 #define RGW_ATTR_BUCKET_ENCRYPTION_PREFIX RGW_ATTR_PREFIX "sse-s3."
index bef6a2eaeba9a4535e2c0c08ee9049b36ebf95ce..bce48082ee309b8cc0cd31734cb9fea11e5e2162 100644 (file)
@@ -24,6 +24,7 @@
 #include <unicode/normalizer2.h>       // libicu
 
 #include <openssl/evp.h>
+#include "common/ceph_crypto.h"
 
 #define dout_context g_ceph_context
 #define dout_subsys ceph_subsys_rgw
@@ -639,6 +640,672 @@ const uint8_t AES_256_CBC::IV[AES_256_CBC::AES_256_IVSIZE] =
     { 'a', 'e', 's', '2', '5', '6', 'i', 'v', '_', 'c', 't', 'r', '1', '3', '3', '7' };
 
 
+/**
+ * AES-256-GCM encryption implementation
+ * Provides authenticated encryption with 96-bit IVs and 128-bit authentication tags
+ */
+class AES_256_GCM : public BlockCrypt {
+public:
+  static const size_t AES_256_KEYSIZE = 256 / 8;        // 32 bytes
+  static const size_t AES_256_IVSIZE = 96 / 8;          // 12 bytes (GCM standard)
+  static const size_t GCM_TAG_SIZE = 128 / 8;           // 16 bytes
+  static const size_t CHUNK_SIZE = 4096;
+  static const size_t ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + GCM_TAG_SIZE; // 4112
+
+  /**
+   * Combined index layout for IV derivation:
+   *   - Upper 24 bits: part_number (supports up to 16M parts; S3 limit is 10K)
+   *   - Lower 40 bits: chunk_index (supports up to 4 PB per part at 4KB chunks)
+   */
+  static constexpr unsigned CHUNK_INDEX_BITS = 40;
+  static constexpr uint64_t MAX_CHUNK_INDEX = (1ULL << CHUNK_INDEX_BITS) - 1;
+
+  const DoutPrefixProvider* dpp;
+private:
+  CephContext* cct;
+  uint8_t key[AES_256_KEYSIZE];
+  uint8_t base_key[AES_256_KEYSIZE];  // For SSE-C: stores object key before part derivation
+  bool has_base_key = false;          // True if base_key is valid (SSE-C with key derivation)
+  uint8_t base_nonce[AES_256_IVSIZE];
+  bool nonce_initialized = false;
+  uint32_t part_number_ = 0;  // For multipart: ensures unique IVs across parts
+
+public:
+  explicit AES_256_GCM(const DoutPrefixProvider* dpp, CephContext* cct)
+    : dpp(dpp), cct(cct) {
+    memset(base_nonce, 0, AES_256_IVSIZE);
+  }
+
+  ~AES_256_GCM() {
+    ::ceph::crypto::zeroize_for_security(key, AES_256_KEYSIZE);
+    ::ceph::crypto::zeroize_for_security(base_key, AES_256_KEYSIZE);
+    ::ceph::crypto::zeroize_for_security(base_nonce, AES_256_IVSIZE);
+  }
+
+  bool set_key(const uint8_t* _key, size_t key_size) {
+    if (key_size != AES_256_KEYSIZE) {
+      return false;
+    }
+    memcpy(key, _key, AES_256_KEYSIZE);
+    return true;
+  }
+
+  /**
+   * Generate a random base nonce for this object.
+   * Called during encryption to create a unique nonce per object.
+   */
+  bool generate_nonce() {
+    cct->random()->get_bytes(reinterpret_cast<char*>(base_nonce), AES_256_IVSIZE);
+    nonce_initialized = true;
+    return true;
+  }
+
+  /**
+   * Set the base nonce from a stored value.
+   * Called during decryption to restore the object's nonce.
+   */
+  bool set_nonce(const uint8_t* nonce, size_t len) {
+    if (len != AES_256_IVSIZE) {
+      return false;
+    }
+    memcpy(base_nonce, nonce, AES_256_IVSIZE);
+    nonce_initialized = true;
+    return true;
+  }
+
+  /**
+   * Get the base nonce for storage in object attributes.
+   */
+  std::string get_nonce() const {
+    return std::string(reinterpret_cast<const char*>(base_nonce), AES_256_IVSIZE);
+  }
+
+  bool is_nonce_initialized() const {
+    return nonce_initialized;
+  }
+
+  /**
+   * Set part number for multipart IV derivation and key derivation (SSE-C).
+   * Must be called before encrypt/decrypt for multipart uploads.
+   *
+   * For SSE-C mode (has_base_key=true): also re-derives the part-specific key
+   * from base_key, enabling correct decryption when switching between parts
+   * during multipart GET operations.
+   */
+  void set_part_number(uint32_t part_number) override {
+    this->part_number_ = part_number;
+
+    // For SSE-C mode, also derive the correct part key
+    if (has_base_key && part_number > 0) {
+      // Restore base key, then derive part key
+      memcpy(this->key, this->base_key, AES_256_KEYSIZE);
+      derive_part_key(part_number);
+    } else if (has_base_key && part_number == 0) {
+      // Part 0 means single-part or init - use base key directly
+      memcpy(this->key, this->base_key, AES_256_KEYSIZE);
+    }
+    // For non-SSE-C modes (has_base_key=false), only IV derivation uses part_number
+  }
+
+  /**
+   * Derive object-specific encryption key from user key + object identity.
+   * This provides cryptographic binding between ciphertext and object identity:
+   * - If object is moved/renamed at RADOS level → wrong key → decrypt fails
+   *
+   * Key derivation formula:
+   *   ObjectKey = HMAC-SHA256(key, nonce || domain || bucket || object)
+   *
+   * @param user_key The user-provided encryption key (32 bytes)
+   * @param key_len Length of user_key (must be 32)
+   * @param bucket Bucket name
+   * @param object Object name
+   * @param part_number Part number for multipart uploads (0 for non-multipart)
+   * @param domain Domain separator string for algorithm binding (e.g., "SSE-C-GCM", "RGW-AUTO-GCM")
+   * @return true on success
+   */
+  bool derive_object_key(
+      const uint8_t* user_key,
+      size_t key_len,
+      const std::string& bucket,
+      const std::string& object,
+      uint32_t part_number,
+      const std::string& domain = "SSE-C-GCM")
+  {
+    if (key_len != AES_256_KEYSIZE) {
+      ldpp_dout(dpp, 0) << "ERROR: derive_object_key: invalid key length "
+                        << key_len << ", expected " << AES_256_KEYSIZE << dendl;
+      return false;
+    }
+    if (!nonce_initialized) {
+      ldpp_dout(dpp, 0) << "ERROR: derive_object_key: nonce not initialized" << dendl;
+      return false;
+    }
+
+    // HMAC-SHA256(user_key, nonce || domain || bucket || object)
+    try {
+      ceph::crypto::HMACSHA256 hmac(user_key, key_len);
+
+      // Helper to encode length as 4-byte big-endian and update HMAC
+      // Length-prefixing prevents ambiguous concatenation attacks
+      auto hmac_update_with_length = [&hmac](const std::string_view& data) {
+        uint32_t len = static_cast<uint32_t>(data.size());
+        uint8_t len_buf[4] = {
+          static_cast<uint8_t>((len >> 24) & 0xFF),
+          static_cast<uint8_t>((len >> 16) & 0xFF),
+          static_cast<uint8_t>((len >> 8) & 0xFF),
+          static_cast<uint8_t>(len & 0xFF)
+        };
+        hmac.Update(len_buf, 4);
+        if (!data.empty()) {
+          hmac.Update(reinterpret_cast<const uint8_t*>(data.data()), data.size());
+        }
+      };
+
+      // Include nonce in key derivation
+      hmac.Update(base_nonce, AES_256_IVSIZE);
+
+      // Domain separator (length-prefixed for consistency)
+      hmac_update_with_length(domain);
+
+      // Include bucket/object with length prefixes
+      // Format: nonce || len(domain) || domain || len(bucket) || bucket || len(object) || object
+      hmac_update_with_length(bucket);
+      hmac_update_with_length(object);
+
+      hmac.Final(this->key);
+    } catch (const ceph::crypto::DigestException& e) {
+      ldpp_dout(dpp, 0) << "ERROR: derive_object_key: HMAC failed: " << e.what() << dendl;
+      ::ceph::crypto::zeroize_for_security(this->key, AES_256_KEYSIZE);
+      return false;
+    }
+
+    // Store part_number for IV derivation (ensures unique IVs across parts)
+    this->part_number_ = part_number;
+
+    // Save base key for later part key derivation (needed for multipart GET)
+    // This allows set_part_number() to re-derive the correct part key
+    memcpy(this->base_key, this->key, AES_256_KEYSIZE);
+    this->has_base_key = true;
+
+    ldpp_dout(dpp, 20) << "derive_object_key: derived key for bucket=" << bucket
+                       << " object=" << object
+                       << " part_number=" << part_number << dendl;
+
+    // For multipart, derive part-specific key
+    if (part_number > 0) {
+      return derive_part_key(part_number);
+    }
+    return true;
+  }
+
+  /**
+   * Derive part-specific key for multipart uploads.
+   * This prevents part reordering/swapping attacks.
+   *
+   * Formula: PartKey = HMAC-SHA256(ObjectKey, part_number)
+   *
+   * @param part_number Part number (1-based, as per S3 multipart API)
+   * @return true on success
+   */
+  bool derive_part_key(uint32_t part_number) {
+    // Encode part number as big-endian 4 bytes
+    uint8_t part_bytes[4];
+    part_bytes[0] = (part_number >> 24) & 0xFF;
+    part_bytes[1] = (part_number >> 16) & 0xFF;
+    part_bytes[2] = (part_number >> 8) & 0xFF;
+    part_bytes[3] = part_number & 0xFF;
+
+    uint8_t derived[AES_256_KEYSIZE];
+
+    try {
+      ceph::crypto::HMACSHA256 hmac(this->key, AES_256_KEYSIZE);
+      hmac.Update(part_bytes, 4);
+      hmac.Final(derived);
+    } catch (const ceph::crypto::DigestException& e) {
+      ldpp_dout(dpp, 0) << "ERROR: derive_part_key: HMAC failed: " << e.what() << dendl;
+      ::ceph::crypto::zeroize_for_security(derived, AES_256_KEYSIZE);
+      ::ceph::crypto::zeroize_for_security(this->key, AES_256_KEYSIZE);
+      return false;
+    }
+
+    memcpy(this->key, derived, AES_256_KEYSIZE);
+    ::ceph::crypto::zeroize_for_security(derived, AES_256_KEYSIZE);
+
+    ldpp_dout(dpp, 20) << "derive_part_key: derived key for part " << part_number << dendl;
+    return true;
+  }
+
+  size_t get_block_size() override {
+    return CHUNK_SIZE;
+  }
+
+  size_t get_encrypted_block_size() override {
+    return ENCRYPTED_CHUNK_SIZE;
+  }
+
+  /**
+   * Encode chunk index as 8-byte big-endian AAD.
+   * Binds ciphertext to stream position, preventing chunk reordering attacks.
+   */
+  static void encode_chunk_aad(uint8_t (&aad)[8], uint64_t chunk_index) {
+    aad[0] = (chunk_index >> 56) & 0xFF;
+    aad[1] = (chunk_index >> 48) & 0xFF;
+    aad[2] = (chunk_index >> 40) & 0xFF;
+    aad[3] = (chunk_index >> 32) & 0xFF;
+    aad[4] = (chunk_index >> 24) & 0xFF;
+    aad[5] = (chunk_index >> 16) & 0xFF;
+    aad[6] = (chunk_index >> 8) & 0xFF;
+    aad[7] = chunk_index & 0xFF;
+  }
+
+  bool gcm_encrypt_chunk(unsigned char* out,
+                         const unsigned char* in,
+                         size_t size,
+                         const unsigned char (&iv)[AES_256_IVSIZE],
+                         const unsigned char (&key)[AES_256_KEYSIZE],
+                         unsigned char* tag,
+                         uint64_t chunk_index)
+  {
+    using pctx_t = std::unique_ptr<EVP_CIPHER_CTX, decltype(&::EVP_CIPHER_CTX_free)>;
+    pctx_t pctx{ EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free };
+
+    if (!pctx) {
+      ldpp_dout(dpp, 5) << "EVP: failed to create cipher context" << dendl;
+      return false;
+    }
+
+    // 1st init: set cipher type
+    if (1 != EVP_EncryptInit_ex(pctx.get(), EVP_aes_256_gcm(),
+                                 nullptr, nullptr, nullptr)) {
+      ldpp_dout(dpp, 5) << "EVP: failed to initialize GCM" << dendl;
+      return false;
+    }
+
+    // Verify IV size (should be 12 bytes for GCM)
+    if (EVP_CIPHER_CTX_iv_length(pctx.get()) != AES_256_IVSIZE) {
+      ldpp_dout(dpp, 5) << "EVP: unexpected IV length "
+                        << EVP_CIPHER_CTX_iv_length(pctx.get())
+                        << " expected " << AES_256_IVSIZE << dendl;
+      return false;
+    }
+
+    // 2nd init: set key and IV
+    if (1 != EVP_EncryptInit_ex(pctx.get(), nullptr, nullptr, key, iv)) {
+      ldpp_dout(dpp, 5) << "EVP: failed to set key/IV" << dendl;
+      return false;
+    }
+
+    // Add AAD for chunk ordering protection
+    uint8_t aad[8];
+    encode_chunk_aad(aad, chunk_index);
+    int aad_len = 0;
+    if (1 != EVP_EncryptUpdate(pctx.get(), nullptr, &aad_len, aad, sizeof(aad))) {
+      ldpp_dout(dpp, 5) << "EVP: failed to set AAD" << dendl;
+      return false;
+    }
+
+    // Encrypt data (size is at most CHUNK_SIZE, well within int range for EVP API)
+    int written = 0;
+    ceph_assert(size <= CHUNK_SIZE);
+    if (1 != EVP_EncryptUpdate(pctx.get(), out, &written, in, size)) {
+      ldpp_dout(dpp, 5) << "EVP: EncryptUpdate failed" << dendl;
+      return false;
+    }
+
+    // Finalize (GCM doesn't add padding, so finally_written should be 0)
+    int finally_written = 0;
+    if (1 != EVP_EncryptFinal_ex(pctx.get(), out + written, &finally_written)) {
+      ldpp_dout(dpp, 5) << "EVP: EncryptFinal_ex failed" << dendl;
+      return false;
+    }
+
+    // Get authentication tag
+    if (1 != EVP_CIPHER_CTX_ctrl(pctx.get(), EVP_CTRL_GCM_GET_TAG,
+                                  GCM_TAG_SIZE, tag)) {
+      ldpp_dout(dpp, 5) << "EVP: failed to get GCM tag" << dendl;
+      return false;
+    }
+
+    return (written + finally_written) == static_cast<int>(size);
+  }
+
+  bool gcm_decrypt_chunk(unsigned char* out,
+                         const unsigned char* in,
+                         size_t size,
+                         const unsigned char (&iv)[AES_256_IVSIZE],
+                         const unsigned char (&key)[AES_256_KEYSIZE],
+                         const unsigned char* tag,
+                         uint64_t chunk_index)
+  {
+    using pctx_t = std::unique_ptr<EVP_CIPHER_CTX, decltype(&::EVP_CIPHER_CTX_free)>;
+    pctx_t pctx{ EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free };
+
+    if (!pctx) {
+      ldpp_dout(dpp, 5) << "EVP: failed to create cipher context" << dendl;
+      return false;
+    }
+
+    // 1st init: set cipher type
+    if (1 != EVP_DecryptInit_ex(pctx.get(), EVP_aes_256_gcm(),
+                                 nullptr, nullptr, nullptr)) {
+      ldpp_dout(dpp, 5) << "EVP: failed to initialize GCM" << dendl;
+      return false;
+    }
+
+    // 2nd init: set key and IV
+    if (1 != EVP_DecryptInit_ex(pctx.get(), nullptr, nullptr, key, iv)) {
+      ldpp_dout(dpp, 5) << "EVP: failed to set key/IV" << dendl;
+      return false;
+    }
+
+    // Add AAD for chunk ordering protection (must match encryption)
+    uint8_t aad[8];
+    encode_chunk_aad(aad, chunk_index);
+    int aad_len = 0;
+    if (1 != EVP_DecryptUpdate(pctx.get(), nullptr, &aad_len, aad, sizeof(aad))) {
+      ldpp_dout(dpp, 5) << "EVP: failed to set AAD" << dendl;
+      return false;
+    }
+
+    // Decrypt data (size is at most CHUNK_SIZE, well within int range for EVP API)
+    int written = 0;
+    ceph_assert(size <= CHUNK_SIZE);
+    if (1 != EVP_DecryptUpdate(pctx.get(), out, &written, in, size)) {
+      ldpp_dout(dpp, 5) << "EVP: DecryptUpdate failed" << dendl;
+      return false;
+    }
+
+    // Set expected tag for verification
+    if (1 != EVP_CIPHER_CTX_ctrl(pctx.get(), EVP_CTRL_GCM_SET_TAG,
+                                  GCM_TAG_SIZE, const_cast<unsigned char*>(tag))) {
+      ldpp_dout(dpp, 5) << "EVP: failed to set GCM tag" << dendl;
+      return false;
+    }
+
+    // Finalize - this verifies the tag
+    int finally_written = 0;
+    if (1 != EVP_DecryptFinal_ex(pctx.get(), out + written, &finally_written)) {
+      ldpp_dout(dpp, 5) << "EVP: DecryptFinal_ex failed - authentication failure" << dendl;
+      return false;  // Tag verification failed
+    }
+
+    return (written + finally_written) == static_cast<int>(size);
+  }
+
+  bool encrypt(bufferlist& input,
+               off_t in_ofs,
+               size_t size,
+               bufferlist& output,
+               off_t stream_offset,
+               optional_yield y) override
+  {
+    output.clear();
+
+    // Calculate output size: each CHUNK_SIZE plaintext becomes CHUNK_SIZE + GCM_TAG_SIZE
+    size_t num_full_chunks = size / CHUNK_SIZE;
+    size_t remainder = size % CHUNK_SIZE;
+    size_t output_size = num_full_chunks * ENCRYPTED_CHUNK_SIZE;
+    if (remainder > 0) {
+      output_size += remainder + GCM_TAG_SIZE;
+    }
+
+    buffer::ptr buf(output_size);
+    unsigned char* buf_raw = reinterpret_cast<unsigned char*>(buf.c_str());
+    const unsigned char* input_raw = reinterpret_cast<const unsigned char*>(
+        input.c_str() + in_ofs);
+
+    size_t out_pos = 0;
+
+    // Process full chunks
+    for (size_t offset = 0; offset < num_full_chunks * CHUNK_SIZE; offset += CHUNK_SIZE) {
+      unsigned char iv[AES_256_IVSIZE];
+      if (!prepare_iv(iv, stream_offset + offset)) {
+        return false;
+      }
+      uint64_t chunk_index = (stream_offset + offset) / CHUNK_SIZE;
+
+      unsigned char* ciphertext = buf_raw + out_pos;
+      unsigned char* tag = buf_raw + out_pos + CHUNK_SIZE;
+
+      if (!gcm_encrypt_chunk(ciphertext, input_raw + offset, CHUNK_SIZE,
+                             iv, key, tag, chunk_index)) {
+        ldpp_dout(dpp, 5) << "Failed to encrypt chunk at offset " << offset << dendl;
+        return false;
+      }
+
+      out_pos += ENCRYPTED_CHUNK_SIZE;
+    }
+
+    // Process remainder (if any)
+    if (remainder > 0) {
+      unsigned char iv[AES_256_IVSIZE];
+      if (!prepare_iv(iv, stream_offset + num_full_chunks * CHUNK_SIZE)) {
+        return false;
+      }
+      uint64_t chunk_index = (stream_offset + num_full_chunks * CHUNK_SIZE) / CHUNK_SIZE;
+
+      unsigned char* ciphertext = buf_raw + out_pos;
+      unsigned char* tag = buf_raw + out_pos + remainder;
+
+      if (!gcm_encrypt_chunk(ciphertext, input_raw + num_full_chunks * CHUNK_SIZE,
+                             remainder, iv, key, tag, chunk_index)) {
+        ldpp_dout(dpp, 5) << "Failed to encrypt final chunk" << dendl;
+        return false;
+      }
+    }
+
+    ldpp_dout(dpp, 25) << "GCM: Encrypted " << size << " bytes to "
+                       << output_size << " bytes" << dendl;
+    buf.set_length(output_size);
+    output.append(buf);
+    return true;
+  }
+
+  bool decrypt(bufferlist& input,
+               off_t in_ofs,
+               size_t size,
+               bufferlist& output,
+               off_t stream_offset,
+               optional_yield y) override
+  {
+    output.clear();
+
+    // Input is organized as encrypted chunks (ciphertext + tag)
+    size_t num_full_chunks = size / ENCRYPTED_CHUNK_SIZE;
+    size_t remainder = size % ENCRYPTED_CHUNK_SIZE;
+    size_t output_size = num_full_chunks * CHUNK_SIZE;
+
+    if (remainder > 0) {
+      if (remainder <= GCM_TAG_SIZE) {
+        ldpp_dout(dpp, 5) << "GCM: Invalid encrypted data size: " << size << dendl;
+        return false;
+      }
+      output_size += remainder - GCM_TAG_SIZE;
+    }
+
+    buffer::ptr buf(output_size);
+    unsigned char* buf_raw = reinterpret_cast<unsigned char*>(buf.c_str());
+    unsigned char* input_raw = reinterpret_cast<unsigned char*>(
+        input.c_str() + in_ofs);
+
+    size_t in_pos = 0;
+    size_t out_pos = 0;
+
+    // Process full chunks
+    for (size_t i = 0; i < num_full_chunks; i++) {
+      unsigned char iv[AES_256_IVSIZE];
+      if (!prepare_iv(iv, stream_offset + i * CHUNK_SIZE)) {
+        return false;
+      }
+      uint64_t chunk_index = (stream_offset + i * CHUNK_SIZE) / CHUNK_SIZE;
+
+      unsigned char* ciphertext = input_raw + in_pos;
+      unsigned char* tag = input_raw + in_pos + CHUNK_SIZE;
+
+      if (!gcm_decrypt_chunk(buf_raw + out_pos, ciphertext, CHUNK_SIZE,
+                             iv, key, tag, chunk_index)) {
+        ldpp_dout(dpp, 5) << "GCM: Failed to decrypt chunk " << i
+                          << " - authentication failed" << dendl;
+        return false;
+      }
+
+      in_pos += ENCRYPTED_CHUNK_SIZE;
+      out_pos += CHUNK_SIZE;
+    }
+
+    // Process remainder (if any)
+    if (remainder > 0) {
+      size_t plaintext_size = remainder - GCM_TAG_SIZE;
+      unsigned char iv[AES_256_IVSIZE];
+      if (!prepare_iv(iv, stream_offset + num_full_chunks * CHUNK_SIZE)) {
+        return false;
+      }
+      uint64_t chunk_index = (stream_offset + num_full_chunks * CHUNK_SIZE) / CHUNK_SIZE;
+
+      unsigned char* ciphertext = input_raw + in_pos;
+      unsigned char* tag = input_raw + in_pos + plaintext_size;
+
+      if (!gcm_decrypt_chunk(buf_raw + out_pos, ciphertext, plaintext_size,
+                             iv, key, tag, chunk_index)) {
+        ldpp_dout(dpp, 5) << "GCM: Failed to decrypt final chunk - authentication failed" << dendl;
+        return false;
+      }
+    }
+
+    ldpp_dout(dpp, 25) << "GCM: Decrypted " << size << " bytes to "
+                       << output_size << " bytes" << dendl;
+    buf.set_length(output_size);
+    output.append(buf);
+    return true;
+  }
+
+  /**
+   * Derive per-chunk nonce from base_nonce + combined index.
+   * This ensures each chunk has a unique nonce while maintaining
+   * the "number used once" property required by GCM.
+   *
+   * For multipart uploads, we combine part_number and chunk_index to ensure
+   * unique IVs across all parts (since each part's offset starts at 0).
+   *
+   * Combined index layout (64 bits):
+   *   - Upper 24 bits: part_number (supports up to 16M parts; S3 limit is 10K)
+   *   - Lower 40 bits: chunk_index (supports up to 1T chunks per part)
+   */
+  bool prepare_iv(unsigned char (&iv)[AES_256_IVSIZE], off_t offset) {
+    ceph_assert(nonce_initialized);
+
+    // Combine part_number and chunk_index to ensure unique IVs across parts
+    // Without this, multipart parts would reuse IVs (all start at offset 0)
+    uint64_t chunk_index = offset / CHUNK_SIZE;
+
+    // Validate chunk_index fits in CHUNK_INDEX_BITS (supports up to 4 PB per part at 4KB chunks)
+    // This is a runtime check to prevent IV reuse in release builds
+    if (chunk_index > MAX_CHUNK_INDEX) {
+      ldpp_dout(dpp, 0) << "ERROR: chunk_index " << chunk_index
+                        << " exceeds maximum " << MAX_CHUNK_INDEX
+                        << " - IV collision risk, refusing to encrypt" << dendl;
+      return false;
+    }
+
+    uint64_t combined_index = (static_cast<uint64_t>(part_number_) << CHUNK_INDEX_BITS) | chunk_index;
+
+    // Derive IV: base_nonce + combined_index (with carry propagation)
+    int i = AES_256_IVSIZE - 1;
+    unsigned int val;
+    unsigned int carry = 0;
+
+    while (i >= 0) {
+      val = (combined_index & 0xff) + base_nonce[i] + carry;
+      iv[i] = static_cast<unsigned char>(val);
+      carry = val >> 8;
+      combined_index = combined_index >> 8;
+      i--;
+    }
+    return true;
+  }
+};
+
+
+/**
+ * Create an AES-256-GCM BlockCrypt instance.
+ *
+ * For encryption: Pass nonce=nullptr to generate a random nonce.
+ *                 After creation, call get_nonce() to retrieve it for storage.
+ *
+ * For decryption: Pass the stored nonce from RGW_ATTR_CRYPT_NONCE.
+ */
+std::unique_ptr<BlockCrypt> AES_256_GCM_create(const DoutPrefixProvider* dpp,
+                                                CephContext* cct,
+                                                const uint8_t* key,
+                                                size_t key_len,
+                                                const uint8_t* nonce,
+                                                size_t nonce_len,
+                                                uint32_t part_number)
+{
+  // Validate key_len to prevent OOB read if caller passes smaller buffer
+  if (key_len != AES_256_GCM::AES_256_KEYSIZE) {
+    ldpp_dout(dpp, 5) << "AES_256_GCM_create: invalid key size " << key_len
+                      << ", expected " << AES_256_GCM::AES_256_KEYSIZE << dendl;
+    return nullptr;
+  }
+
+  auto gcm = std::unique_ptr<AES_256_GCM>(new AES_256_GCM(dpp, cct));
+  if (!gcm->set_key(key, key_len)) {
+    return nullptr;
+  }
+
+  // Set part_number for multipart IV derivation (ensures unique IVs across parts)
+  gcm->set_part_number(part_number);
+
+  if (nonce != nullptr) {
+    // Decryption path: use the provided stored nonce
+    if (!gcm->set_nonce(nonce, nonce_len)) {
+      ldpp_dout(dpp, 5) << "AES_256_GCM_create: invalid nonce size " << nonce_len << dendl;
+      return nullptr;
+    }
+  } else {
+    // Encryption path: generate a random nonce
+    gcm->generate_nonce();
+  }
+
+  return gcm;
+}
+
+
+/**
+ * Retrieve the nonce from a BlockCrypt instance for storage.
+ * Returns empty string if the BlockCrypt is not an AES_256_GCM instance.
+ */
+std::string AES_256_GCM_get_nonce(BlockCrypt* block_crypt)
+{
+  auto* gcm = dynamic_cast<AES_256_GCM*>(block_crypt);
+  if (gcm && gcm->is_nonce_initialized()) {
+    return gcm->get_nonce();
+  }
+  return {};
+}
+
+/**
+ * Test helper: derive object key for a BlockCrypt instance.
+ * Returns true on success.
+ */
+bool AES_256_GCM_derive_object_key(BlockCrypt* block_crypt,
+                                    const uint8_t* user_key,
+                                    size_t key_len,
+                                    const std::string& bucket,
+                                    const std::string& object,
+                                    uint32_t part_number,
+                                    const std::string& domain)
+{
+  auto* gcm = dynamic_cast<AES_256_GCM*>(block_crypt);
+  if (!gcm) {
+    return false;
+  }
+  return gcm->derive_object_key(user_key, key_len, bucket, object,
+                                 part_number, domain);
+}
+
+
 bool AES_256_ECB_encrypt(const DoutPrefixProvider* dpp,
                          CephContext* cct,
                          const uint8_t* key,
@@ -663,6 +1330,9 @@ RGWGetObj_BlockDecrypt::RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp,
                                                RGWGetObj_Filter* next,
                                                std::unique_ptr<BlockCrypt> crypt,
                                                std::vector<size_t> parts_len,
+                                               std::vector<uint32_t> part_nums,
+                                               off_t encrypted_total_size,
+                                               bool has_compression,
                                                optional_yield y)
     :
     RGWGetObj_Filter(next),
@@ -671,12 +1341,46 @@ RGWGetObj_BlockDecrypt::RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp,
     crypt(std::move(crypt)),
     enc_begin_skip(0),
     ofs(0),
+    enc_ofs(0),
     end(0),
+    encrypted_total_size(encrypted_total_size),
+    has_compression(has_compression),
     cache(),
     y(y),
-    parts_len(std::move(parts_len))
+    parts_len(std::move(parts_len)),
+    part_nums(std::move(part_nums)),
+    current_part_num(0)
 {
   block_size = this->crypt->get_block_size();
+  encrypted_block_size = this->crypt->get_encrypted_block_size();
+
+  /**
+   * Sanity check: when BOTH part_nums and parts_len are populated, they must
+   * match in size. A mismatch indicates data corruption or a bug.
+   *
+   * When parts_len is empty (e.g., GET ?partNumber=N where CRYPT_PARTS is
+   * intentionally skipped and the part object has no manifest), we can only
+   * trust a single fallback part number.
+   */
+  if (!this->part_nums.empty() && !this->parts_len.empty() &&
+      this->part_nums.size() != this->parts_len.size()) {
+    ldpp_dout(dpp, 0) << "ERROR: part_nums.size()=" << this->part_nums.size()
+                      << " != parts_len.size()=" << this->parts_len.size()
+                      << " - possible data corruption" << dendl;
+    this->part_nums.clear();
+  }
+  if (this->parts_len.empty() && this->part_nums.size() > 1) {
+    ldpp_dout(dpp, 0) << "ERROR: part_nums.size()=" << this->part_nums.size()
+                      << " but parts_len is empty - cannot map part boundaries"
+                      << dendl;
+    this->part_nums.clear();
+  }
+
+  // Initialize with first part's key if multipart
+  if (!this->part_nums.empty()) {
+    current_part_num = this->part_nums[0];
+    this->crypt->set_part_number(current_part_num);
+  }
 }
 
 RGWGetObj_BlockDecrypt::~RGWGetObj_BlockDecrypt() {
@@ -710,53 +1414,76 @@ int RGWGetObj_BlockDecrypt::read_manifest_parts(const DoutPrefixProvider *dpp,
 }
 
 int RGWGetObj_BlockDecrypt::fixup_range(off_t& bl_ofs, off_t& bl_end) {
-  /*
-   * Cascade to the next filter first so it sees the original
-   * plaintext range before we block-align for decryption.
-   */
-  if (next)
-    next->fixup_range(bl_ofs, bl_end);
-
   off_t inp_ofs = bl_ofs;
   off_t inp_end = bl_end;
-  if (parts_len.size() > 0) {
-    off_t in_ofs = bl_ofs;
-    off_t in_end = bl_end;
 
-    size_t i = 0;
-    while (i<parts_len.size() && (in_ofs >= (off_t)parts_len[i])) {
-      in_ofs -= parts_len[i];
-      i++;
-    }
-    //in_ofs is inside block i
-    size_t j = 0;
-    while (j<(parts_len.size() - 1) && (in_end >= (off_t)parts_len[j])) {
-      in_end -= parts_len[j];
-      j++;
-    }
-    //in_end is inside part j, OR j is the last part
-
-    size_t rounded_end = ( in_end & ~(block_size - 1) ) + (block_size - 1);
-    if (rounded_end > parts_len[j]) {
-      rounded_end = parts_len[j] - 1;
+  // If compression is present, let the decompression filter remap the range
+  // from plaintext to compressed domain first. We'll then map compressed -> encrypted.
+  if (has_compression && next) {
+    int r = next->fixup_range(bl_ofs, bl_end);
+    if (r < 0) {
+      return r;
     }
+  }
 
-    enc_begin_skip = in_ofs & (block_size - 1);
+  if (parts_len.size() > 0) {
+    // Multipart object: find parts containing start and end offsets
+    PartLocation start_loc = find_part_for_plaintext_offset(bl_ofs, false);
+    PartLocation end_loc = find_part_for_plaintext_offset(bl_end, true);  // clamp to last
+
+    // Block-align end within its part (in plaintext space)
+    size_t part_plaintext_end = encrypted_to_plaintext_size(parts_len[end_loc.part_idx]);
+    off_t rounded_end = std::min(
+        (off_t)((end_loc.offset_in_part & ~(block_size - 1)) + (block_size - 1)),
+        (off_t)(part_plaintext_end - 1));
+
+    // enc_begin_skip is offset within the starting block
+    enc_begin_skip = start_loc.offset_in_part & (block_size - 1);
     ofs = bl_ofs - enc_begin_skip;
     end = bl_end;
-    bl_end += rounded_end - in_end;
-    bl_ofs = std::min(bl_ofs - enc_begin_skip, bl_end);
+
+    // Convert end offset: plaintext -> encrypted, then align to encrypted block
+    off_t enc_end = align_to_encrypted_block_end(logical_to_encrypted_offset(rounded_end));
+    enc_end = std::min(enc_end, (off_t)(parts_len[end_loc.part_idx] - 1));
+    bl_end = end_loc.cumulative_encrypted + enc_end;
+
+    // Convert start offset: align in plaintext, then convert to encrypted
+    off_t aligned_start = std::max((off_t)0, start_loc.offset_in_part - enc_begin_skip);
+    bl_ofs = start_loc.cumulative_encrypted + logical_to_encrypted_offset(aligned_start);
+
+    // Clamp start to end (handles invalid ranges)
+    bl_ofs = std::min(bl_ofs, bl_end);
+    enc_ofs = bl_ofs;
   }
   else
   {
+    // Simple object (no multipart)
     enc_begin_skip = bl_ofs & (block_size - 1);
     ofs = bl_ofs & ~(block_size - 1);
     end = bl_end;
-    bl_ofs = bl_ofs & ~(block_size - 1);
-    bl_end = ( bl_end & ~(block_size - 1) ) + (block_size - 1);
+
+    // Calculate block-aligned logical range
+    off_t aligned_start = bl_ofs & ~(block_size - 1);
+    off_t aligned_end = (bl_end & ~(block_size - 1)) + (block_size - 1);
+
+    // Convert to encrypted offsets
+    bl_ofs = logical_to_encrypted_offset(aligned_start);
+    bl_end = align_to_encrypted_block_end(logical_to_encrypted_offset(aligned_end));
+    enc_ofs = bl_ofs;
+
+    // Clamp to actual encrypted object size
+    if (encrypted_total_size > 0 && bl_end >= encrypted_total_size) {
+      bl_end = encrypted_total_size - 1;
+    }
   }
+
   ldpp_dout(this->dpp, 20) << "fixup_range [" << inp_ofs << "," << inp_end
-      << "] => [" << bl_ofs << "," << bl_end << "]" << dendl;
+      << "] => [" << bl_ofs << "," << bl_end << "]"
+      << " (block_size=" << block_size
+      << ", encrypted_block_size=" << encrypted_block_size << ")" << dendl;
+
+  if (next && !has_compression)
+    return next->fixup_range(bl_ofs, bl_end);
 
   return 0;
 }
@@ -767,41 +1494,122 @@ int RGWGetObj_BlockDecrypt::process(bufferlist& in, size_t part_ofs, size_t size
   if (!crypt->decrypt(in, 0, size, data, part_ofs, y)) {
     return -ERR_INTERNAL_ERROR;
   }
-  off_t send_size = size - enc_begin_skip;
+
+  /**
+   * data.length() is the decrypted (plaintext) size.
+   * For GCM: data.length() < size (auth tags removed during decryption)
+   * For CBC: data.length() == size (no overhead)
+   */
+  off_t decrypted_size = data.length();
+
+  off_t send_size = decrypted_size - enc_begin_skip;
   if (ofs + enc_begin_skip + send_size > end + 1) {
     send_size = end + 1 - ofs - enc_begin_skip;
   }
   int res = next->handle_data(data, enc_begin_skip, send_size);
   enc_begin_skip = 0;
-  ofs += size;
-  in.splice(0, size);
+  ofs += decrypted_size;  // Advance plaintext position
+  enc_ofs += size;        // Advance encrypted position (for part boundary tracking)
+  in.splice(0, size);     // Remove encrypted data from input buffer
   return res;
 }
 
-int RGWGetObj_BlockDecrypt::handle_data(bufferlist& bl, off_t bl_ofs, off_t bl_len) {
-  ldpp_dout(this->dpp, 25) << "Decrypt " << bl_len << " bytes" << dendl;
-  bl.begin(bl_ofs).copy(bl_len, cache);
-
+int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out) {
+  size_t enc_part_ofs = enc_ofs;
+  size_t plain_part_ofs = ofs;
+  const bool is_multipart = !part_nums.empty();
+  uint32_t part_idx = 0;
   int res = 0;
-  size_t part_ofs = ofs;
+
   for (size_t part : parts_len) {
-    if (part_ofs >= part) {
-      part_ofs -= part;
-    } else if (part_ofs + cache.length() >= part) {
-      // flush data up to part boundaries, aligned or not
-      res = process(cache, part_ofs, part - part_ofs);
+    // Get actual S3 part number from attribute (not calculated!)
+    uint32_t this_part_num = 0;
+    if (is_multipart && part_idx < part_nums.size()) {
+      this_part_num = part_nums[part_idx];
+    }
+
+    if (enc_part_ofs >= part) {
+      // Past this part entirely, skip to next
+      enc_part_ofs -= part;
+      plain_part_ofs -= encrypted_to_plaintext_size(part);
+      part_idx++;
+    } else if (enc_part_ofs + cache.length() >= part) {
+      // Ensure cipher has correct part number
+      if (is_multipart && current_part_num != this_part_num) {
+        current_part_num = this_part_num;
+        crypt->set_part_number(current_part_num);
+      }
+
+      // Data crosses part boundary - process up to boundary
+      size_t enc_bytes_this_part = part - enc_part_ofs;
+      res = process(cache, plain_part_ofs, enc_bytes_this_part);
       if (res < 0) {
         return res;
       }
-      part_ofs = 0;
+
+      // Move to next part
+      part_idx++;
+      uint32_t next_part_num = 0;
+      if (is_multipart && part_idx < part_nums.size()) {
+        next_part_num = part_nums[part_idx];
+      }
+      if (is_multipart && part_idx < parts_len.size() && current_part_num != next_part_num) {
+        current_part_num = next_part_num;
+        crypt->set_part_number(current_part_num);
+      }
+
+      enc_part_ofs = 0;
+      plain_part_ofs = 0;
     } else {
+      // Ensure cipher has correct part number
+      if (is_multipart && current_part_num != this_part_num) {
+        current_part_num = this_part_num;
+        crypt->set_part_number(current_part_num);
+      }
       break;
     }
   }
-  // write up to block boundaries, aligned only
-  off_t aligned_size = cache.length() & ~(block_size - 1);
+
+  plain_part_ofs_out = plain_part_ofs;
+  return 0;
+}
+
+RGWGetObj_BlockDecrypt::PartLocation
+RGWGetObj_BlockDecrypt::find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const
+{
+  PartLocation loc = {0, plaintext_ofs, 0};
+
+  // If clamp_to_last, stop at second-to-last part (used for end offsets)
+  size_t limit = (clamp_to_last && parts_len.size() > 0)
+                 ? (parts_len.size() - 1)
+                 : parts_len.size();
+
+  while (loc.part_idx < limit) {
+    size_t part_plain = encrypted_to_plaintext_size(parts_len[loc.part_idx]);
+    if (loc.offset_in_part < (off_t)part_plain) {
+      break;  // Found the part containing this offset
+    }
+    loc.offset_in_part -= part_plain;
+    loc.cumulative_encrypted += parts_len[loc.part_idx];
+    loc.part_idx++;
+  }
+
+  return loc;
+}
+
+int RGWGetObj_BlockDecrypt::handle_data(bufferlist& bl, off_t bl_ofs, off_t bl_len) {
+  ldpp_dout(this->dpp, 25) << "Decrypt " << bl_len << " bytes"
+      << " (encrypted_block_size=" << encrypted_block_size << ")" << dendl;
+  bl.begin(bl_ofs).copy(bl_len, cache);
+
+  size_t plain_part_ofs;
+  int res = process_part_boundaries(plain_part_ofs);
+  if (res < 0) return res;
+
+  // Process up to encrypted block boundaries
+  off_t aligned_size = (cache.length() / encrypted_block_size) * encrypted_block_size;
   if (aligned_size > 0) {
-    res = process(cache, part_ofs, aligned_size);
+    res = process(cache, plain_part_ofs, aligned_size);
   }
   return res;
 }
@@ -811,34 +1619,18 @@ int RGWGetObj_BlockDecrypt::handle_data(bufferlist& bl, off_t bl_ofs, off_t bl_l
  */
 int RGWGetObj_BlockDecrypt::flush() {
   ldpp_dout(this->dpp, 25) << "Decrypt flushing " << cache.length() << " bytes" << dendl;
-  int res = 0;
-  size_t part_ofs = ofs;
-  for (size_t part : parts_len) {
-    if (part_ofs >= part) {
-      part_ofs -= part;
-    } else if (part_ofs + cache.length() >= part) {
-      // flush data up to part boundaries, aligned or not
-      res = process(cache, part_ofs, part - part_ofs);
-      if (res < 0) {
-        return res;
-      }
-      part_ofs = 0;
-    } else {
-      break;
-    }
-  }
-  // flush up to block boundaries, aligned or not
+
+  size_t plain_part_ofs;
+  int res = process_part_boundaries(plain_part_ofs);
+  if (res < 0) return res;
+
+  // Flush remaining data (possibly unaligned final block)
   if (cache.length() > 0) {
-    res = process(cache, part_ofs, cache.length());
-    if (res < 0) {
-      return res;
-    }
+    res = process(cache, plain_part_ofs, cache.length());
+    if (res < 0) return res;
   }
 
-  if (next)
-    return next->flush();
-
-  return 0;
+  return next ? next->flush() : 0;
 }
 
 RGWPutObj_BlockEncrypt::RGWPutObj_BlockEncrypt(const DoutPrefixProvider *dpp,
@@ -876,15 +1668,17 @@ int RGWPutObj_BlockEncrypt::process(bufferlist&& data, uint64_t logical_offset)
     if (!crypt->encrypt(in, 0, proc_size, out, logical_offset, y)) {
       return -ERR_INTERNAL_ERROR;
     }
-    int r = Pipe::process(std::move(out), logical_offset);
-    logical_offset += proc_size;
+    // For size-expanding ciphers (GCM), out.length() > proc_size
+    // Use encrypted_offset for downstream writes, not plaintext logical_offset
+    int r = Pipe::process(std::move(out), encrypted_offset);
+    encrypted_offset += out.length();
     if (r < 0)
       return r;
   }
 
   if (flush) {
     /*replicate 0-sized handle_data*/
-    return Pipe::process({}, logical_offset);
+    return Pipe::process({}, encrypted_offset);
   }
   return 0;
 }
@@ -1033,22 +1827,160 @@ static int get_sse_s3_bucket_key(req_state *s, optional_yield y,
         break;
       }
     }
-    if (res != 0) {
-      ldpp_dout(s, 5) << "ERROR: unable to save new key_id on bucket" << dendl;
-      s->err.message = "Server side error - unable to save key_id";
-      return res;
+    if (res != 0) {
+      ldpp_dout(s, 5) << "ERROR: unable to save new key_id on bucket" << dendl;
+      s->err.message = "Server side error - unable to save key_id";
+      return res;
+    }
+  }
+  return 0;
+}
+
+
+/**
+ * Generate random GCM nonce and store in attributes.
+ */
+static std::string generate_gcm_nonce(
+    req_state* s,
+    std::map<std::string, ceph::bufferlist>& attrs)
+{
+  std::string nonce(AES_256_GCM_NONCE_SIZE, '\0');
+  s->cct->random()->get_bytes(nonce.data(), AES_256_GCM_NONCE_SIZE);
+  set_attr(attrs, RGW_ATTR_CRYPT_NONCE, nonce);
+  return nonce;
+}
+
+/**
+ * Store plaintext size for GCM objects (needed for size accounting).
+ * Note: Only set when content_length is actually known (> 0).
+ * For chunked uploads without x-amz-decoded-content-length, content_length
+ * is 0 and we must NOT set ORIGINAL_SIZE here - it will be set later in
+ * RGWPutObj::execute() after all data is processed.
+ */
+static void set_gcm_plaintext_size(
+    req_state* s,
+    std::map<std::string, ceph::bufferlist>& attrs,
+    bool is_copy)
+{
+  if (s->content_length > 0 && !is_copy) {
+    set_attr(attrs, RGW_ATTR_CRYPT_ORIGINAL_SIZE,
+             std::to_string(s->content_length));
+  }
+}
+
+/**
+ * Retrieve and validate stored GCM nonce for decryption.
+ * Returns empty string on error (caller should return -EIO).
+ */
+static std::string get_gcm_nonce(
+    const DoutPrefixProvider* dpp,
+    req_state* s,
+    const std::map<std::string, ceph::bufferlist>& attrs,
+    std::string_view mode_name)
+{
+  std::string stored_nonce = get_str_attribute(attrs, RGW_ATTR_CRYPT_NONCE);
+  if (stored_nonce.empty()) {
+    ldpp_dout(dpp, 5) << "ERROR: " << mode_name << " decryption failed: "
+                      << "nonce attribute is missing" << dendl;
+    s->err.message = "Object encryption metadata is corrupted.";
+    return {};
+  }
+  if (stored_nonce.size() != AES_256_GCM_NONCE_SIZE) {
+    ldpp_dout(dpp, 5) << "ERROR: " << mode_name << " decryption failed: "
+                      << "stored nonce has invalid size " << stored_nonce.size()
+                      << " (expected " << AES_256_GCM_NONCE_SIZE << ")" << dendl;
+    s->err.message = "Object encryption metadata is corrupted.";
+    return {};
+  }
+  return stored_nonce;
+}
+
+bool rgw_get_aead_original_size(const DoutPrefixProvider* dpp,
+                                const std::map<std::string, bufferlist>& attrs,
+                                uint64_t* original_size)
+{
+  if (!original_size) {
+    return false;
+  }
+
+  const auto mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
+  if (!is_aead_mode(mode)) {
+    return false;
+  }
+
+  auto p = attrs.find(RGW_ATTR_CRYPT_ORIGINAL_SIZE);
+  if (p == attrs.end()) {
+    return false;
+  }
+
+  try {
+    *original_size = std::stoull(p->second.to_str());
+    return true;
+  } catch (const std::exception& e) {
+    if (dpp) {
+      ldpp_dout(dpp, 0) << "ERROR: invalid RGW_ATTR_CRYPT_ORIGINAL_SIZE: "
+                        << e.what() << dendl;
+    }
+    return false;
+  }
+}
+
+bool rgw_get_aead_decrypted_size(const DoutPrefixProvider* dpp,
+                                 const std::map<std::string, bufferlist>& attrs,
+                                 uint64_t encrypted_size,
+                                 uint64_t* decrypted_size)
+{
+  if (!decrypted_size) {
+    return false;
+  }
+
+  const auto mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
+  if (!is_aead_mode(mode)) {
+    return false;
+  }
+
+  /* Try CRYPT_PARTS first (more accurate for multipart) */
+  if (auto i = attrs.find(RGW_ATTR_CRYPT_PARTS); i != attrs.end()) {
+    std::vector<size_t> parts_len;
+    try {
+      auto iter = i->second.cbegin();
+      using ceph::decode;
+      decode(parts_len, iter);
+    } catch (const buffer::error&) {
+      if (dpp) {
+        ldpp_dout(dpp, 1) << "failed to decode RGW_ATTR_CRYPT_PARTS" << dendl;
+      }
+      parts_len.clear();
+    }
+    if (!parts_len.empty()) {
+      uint64_t total = 0;
+      for (size_t enc_part : parts_len) {
+        total += aead_encrypted_to_plaintext_size(enc_part, dpp);
+      }
+      *decrypted_size = total;
+      return true;
     }
   }
-  return 0;
+
+  /* Fallback: calculate from total encrypted size */
+  *decrypted_size = aead_encrypted_to_plaintext_size(encrypted_size, dpp);
+  if (dpp) {
+    ldpp_dout(dpp, 20) << "AEAD: calculated decrypted size " << *decrypted_size
+                       << " from encrypted " << encrypted_size << dendl;
+  }
+  return true;
 }
 
+
 int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
                            std::map<std::string, ceph::bufferlist>& attrs,
                            std::unique_ptr<BlockCrypt>* block_crypt,
-                           std::map<std::string, std::string>& crypt_http_responses)
+                           std::map<std::string, std::string>& crypt_http_responses,
+                           uint32_t part_number)
 {
   int res = 0;
   CryptAttributes crypt_attributes { s };
+  const bool is_copy = (s->op_type == RGW_OP_COPY_OBJ);
   crypt_http_responses.clear();
 
   {
@@ -1123,13 +2055,46 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
         return -EINVAL;
       }
 
-      set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-C-AES256");
       set_attr(attrs, RGW_ATTR_CRYPT_KEYMD5, keymd5_bin);
 
-      if (block_crypt) {
-        auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
-        aes->set_key(reinterpret_cast<const uint8_t*>(key_bin.c_str()), AES_256_KEYSIZE);
-        *block_crypt = std::move(aes);
+      // Check config for encryption algorithm preference
+      const bool use_gcm = (s->cct->_conf->rgw_crypt_sse_algorithm == "aes-256-gcm");
+
+      if (use_gcm) {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-C-AES256-GCM");
+        std::string nonce = generate_gcm_nonce(s, attrs);
+        set_gcm_plaintext_size(s, attrs, is_copy);
+
+        if (block_crypt) {
+          auto gcm = std::unique_ptr<AES_256_GCM>(new AES_256_GCM(s, s->cct));
+          if (!gcm->set_nonce(reinterpret_cast<const uint8_t*>(nonce.c_str()), nonce.size())) {
+            ldpp_dout(s, 5) << "ERROR: SSE-C-AES256-GCM encryption failed: "
+                             << "could not initialize nonce" << dendl;
+            ::ceph::crypto::zeroize_for_security(key_bin.data(), key_bin.length());
+            return -EIO;
+          }
+          // Derive encryption key from user key + object identity
+          if (!gcm->derive_object_key(
+                  reinterpret_cast<const uint8_t*>(key_bin.c_str()),
+                  AES_256_KEYSIZE,
+                  s->bucket->get_name(),
+                  s->object->get_name(),
+                  part_number)) {
+            ldpp_dout(s, 5) << "ERROR: SSE-C-AES256-GCM key derivation failed for "
+                             << s->bucket->get_name() << "/" << s->object->get_name() << dendl;
+            s->err.message = "Failed to derive encryption key.";
+            ::ceph::crypto::zeroize_for_security(key_bin.data(), key_bin.length());
+            return -EIO;
+          }
+          *block_crypt = std::move(gcm);
+        }
+      } else {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-C-AES256");
+        if (block_crypt) {
+          auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
+          aes->set_key(reinterpret_cast<const uint8_t*>(key_bin.c_str()), AES_256_KEYSIZE);
+          *block_crypt = std::move(aes);
+        }
       }
 
       crypt_http_responses["x-amz-server-side-encryption-customer-algorithm"] = "AES256";
@@ -1189,7 +2154,6 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
           std::string key_selector = create_random_key_selector(s->cct);
           set_attr(attrs, RGW_ATTR_CRYPT_KEYSEL, key_selector);
         }
-        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-KMS");
         set_attr(attrs, RGW_ATTR_CRYPT_KEYID, key_id);
         set_attr(attrs, RGW_ATTR_CRYPT_CONTEXT, cooked_context);
         std::string actual_key;
@@ -1206,10 +2170,34 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
           return -EINVAL;
         }
 
-        if (block_crypt) {
-          auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
-          aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()), AES_256_KEYSIZE);
-          *block_crypt = std::move(aes);
+        const bool use_gcm = (s->cct->_conf->rgw_crypt_sse_algorithm == "aes-256-gcm");
+
+        if (use_gcm) {
+          set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-KMS-GCM");
+          std::string nonce = generate_gcm_nonce(s, attrs);
+          set_gcm_plaintext_size(s, attrs, is_copy);
+
+          if (block_crypt) {
+            auto aes = AES_256_GCM_create(s, s->cct,
+                                           reinterpret_cast<const uint8_t*>(actual_key.c_str()),
+                                           AES_256_KEYSIZE,
+                                           reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                           nonce.size(),
+                                           part_number);
+            if (!aes) {
+              ldpp_dout(s, 5) << "ERROR: Failed to create AES-256-GCM instance" << dendl;
+              ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+              return -EIO;
+            }
+            *block_crypt = std::move(aes);
+          }
+        } else {
+          set_attr(attrs, RGW_ATTR_CRYPT_MODE, "SSE-KMS");
+          if (block_crypt) {
+            auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
+            aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()), AES_256_KEYSIZE);
+            *block_crypt = std::move(aes);
+          }
         }
         ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
 
@@ -1245,7 +2233,6 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
       }
 
       set_attr(attrs, RGW_ATTR_CRYPT_CONTEXT, cooked_context);
-      set_attr(attrs, RGW_ATTR_CRYPT_MODE, "AES256");
       set_attr(attrs, RGW_ATTR_CRYPT_KEYID, key_id);
       std::string actual_key;
       res = make_actual_key_from_sse_s3(s, attrs, y, actual_key);
@@ -1261,10 +2248,34 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
         return -EINVAL;
       }
 
-      if (block_crypt) {
-        auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
-        aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()), AES_256_KEYSIZE);
-        *block_crypt = std::move(aes);
+      const bool use_gcm = (s->cct->_conf->rgw_crypt_sse_algorithm == "aes-256-gcm");
+
+      if (use_gcm) {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "AES256-GCM");
+        std::string nonce = generate_gcm_nonce(s, attrs);
+        set_gcm_plaintext_size(s, attrs, is_copy);
+
+        if (block_crypt) {
+          auto aes = AES_256_GCM_create(s, s->cct,
+                                         reinterpret_cast<const uint8_t*>(actual_key.c_str()),
+                                         AES_256_KEYSIZE,
+                                         reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                         nonce.size(),
+                                         part_number);
+          if (!aes) {
+            ldpp_dout(s, 5) << "ERROR: Failed to create AES-256-GCM instance" << dendl;
+            ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+            return -EIO;
+          }
+          *block_crypt = std::move(aes);
+        }
+      } else {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "AES256");
+        if (block_crypt) {
+          auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
+          aes->set_key(reinterpret_cast<const uint8_t*>(actual_key.c_str()), AES_256_KEYSIZE);
+          *block_crypt = std::move(aes);
+        }
       }
       ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
 
@@ -1290,24 +2301,57 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
         return 0;
       }
 
-      set_attr(attrs, RGW_ATTR_CRYPT_MODE, "RGW-AUTO");
-      std::string key_selector = create_random_key_selector(s->cct);
-      set_attr(attrs, RGW_ATTR_CRYPT_KEYSEL, key_selector);
+      const bool use_gcm = (s->cct->_conf->rgw_crypt_sse_algorithm == "aes-256-gcm");
+
+      if (use_gcm) {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "RGW-AUTO-GCM");
+        std::string nonce = generate_gcm_nonce(s, attrs);
+        set_gcm_plaintext_size(s, attrs, is_copy);
+
+        if (block_crypt) {
+          auto gcm = std::unique_ptr<AES_256_GCM>(new AES_256_GCM(s, s->cct));
+          if (!gcm->set_nonce(reinterpret_cast<const uint8_t*>(nonce.c_str()), nonce.size())) {
+            ldpp_dout(s, 5) << "ERROR: RGW-AUTO-GCM: could not initialize nonce" << dendl;
+            return -EIO;
+          }
+
+          // Derive encryption key using HMAC-SHA256 with context binding
+          // Key = HMAC-SHA256(master_key, nonce || "RGW-AUTO-GCM" || bucket || object)
+          if (!gcm->derive_object_key(
+                  reinterpret_cast<const uint8_t*>(master_encryption_key.c_str()),
+                  AES_256_KEYSIZE,
+                  s->bucket->get_name(),
+                  s->object->get_name(),
+                  part_number,
+                  "RGW-AUTO-GCM")) {
+            ldpp_dout(s, 5) << "ERROR: RGW-AUTO-GCM key derivation failed for "
+                             << s->bucket->get_name() << "/" << s->object->get_name() << dendl;
+            return -EIO;
+          }
+          *block_crypt = std::move(gcm);
+        }
+      } else {
+        // CBC mode: use AES-ECB key derivation (legacy approach)
+        std::string key_selector = create_random_key_selector(s->cct);
+        set_attr(attrs, RGW_ATTR_CRYPT_KEYSEL, key_selector);
+
+        uint8_t actual_key[AES_256_KEYSIZE];
+        if (AES_256_ECB_encrypt(s, s->cct,
+                                reinterpret_cast<const uint8_t*>(master_encryption_key.c_str()), AES_256_KEYSIZE,
+                                reinterpret_cast<const uint8_t*>(key_selector.c_str()),
+                                actual_key, AES_256_KEYSIZE) != true) {
+          ::ceph::crypto::zeroize_for_security(actual_key, sizeof(actual_key));
+          return -EIO;
+        }
 
-      uint8_t actual_key[AES_256_KEYSIZE];
-      if (AES_256_ECB_encrypt(s, s->cct,
-                              reinterpret_cast<const uint8_t*>(master_encryption_key.c_str()), AES_256_KEYSIZE,
-                              reinterpret_cast<const uint8_t*>(key_selector.c_str()),
-                              actual_key, AES_256_KEYSIZE) != true) {
+        set_attr(attrs, RGW_ATTR_CRYPT_MODE, "RGW-AUTO");
+        if (block_crypt) {
+          auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
+          aes->set_key(actual_key, AES_256_KEYSIZE);
+          *block_crypt = std::move(aes);
+        }
         ::ceph::crypto::zeroize_for_security(actual_key, sizeof(actual_key));
-        return -EIO;
       }
-      if (block_crypt) {
-        auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->cct));
-        aes->set_key(reinterpret_cast<const uint8_t*>(actual_key), AES_256_KEYSIZE);
-        *block_crypt = std::move(aes);
-      }
-      ::ceph::crypto::zeroize_for_security(actual_key, sizeof(actual_key));
       return 0;
     }
   }
@@ -1315,11 +2359,35 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
 }
 
 
+static void pick_gcm_identity(req_state* s,
+                              bool copy_source,
+                              const rgw_crypt_src_identity* src_identity,
+                              std::string& bucket_name,
+                              std::string& object_name)
+{
+  if (copy_source) {
+    if (src_identity && src_identity->valid()) {
+      bucket_name = std::string(src_identity->bucket);
+      object_name = std::string(src_identity->object);
+      return;
+    }
+    if (s->src_object) {
+      bucket_name = s->src_bucket_name;
+      object_name = s->src_object->get_name();
+      return;
+    }
+  }
+  bucket_name = s->bucket->get_name();
+  object_name = s->object->get_name();
+}
+
 int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
                            map<string, bufferlist>& attrs,
                            std::unique_ptr<BlockCrypt>* block_crypt,
                            std::map<std::string, std::string>* crypt_http_responses,
-                           bool copy_source)
+                           bool copy_source,
+                           uint32_t part_number,
+                           const rgw_crypt_src_identity* src_identity)
 {
   int res = 0;
   std::string stored_mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
@@ -1420,6 +2488,117 @@ int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
     return 0;
   }
 
+  if (stored_mode == "SSE-C-AES256-GCM") {
+    if (s->cct->_conf->rgw_crypt_require_ssl &&
+        !rgw_transport_is_secure(s->cct, *s->info.env)) {
+      ldpp_dout(s, 5) << "ERROR: Insecure request, rgw_crypt_require_ssl is set" << dendl;
+      return -ERR_INVALID_REQUEST;
+    }
+
+    const char *sse_c_algo_hdr = copy_source ? "HTTP_X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM" :
+                                               "HTTP_X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM";
+    const char *req_cust_alg = s->info.env->get(sse_c_algo_hdr, NULL);
+    if (nullptr == req_cust_alg)  {
+      ldpp_dout(s, 5) << "ERROR: Request for SSE-C encrypted object missing "
+                       << "x-amz-server-side-encryption-customer-algorithm"
+                       << dendl;
+      s->err.message = "Requests specifying Server Side Encryption with Customer "
+                       "provided keys must provide a valid encryption algorithm.";
+      return -EINVAL;
+    } else if (strcmp(req_cust_alg, "AES256") != 0) {
+      ldpp_dout(s, 5) << "ERROR: The requested encryption algorithm is not valid, must be AES256." << dendl;
+      s->err.message = "The requested encryption algorithm is not valid, must be AES256.";
+      return -ERR_INVALID_ENCRYPTION_ALGORITHM;
+    }
+
+    const char *sse_c_key_hdr = copy_source ? "HTTP_X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY" :
+                                              "HTTP_X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY";
+    std::string key_bin;
+    try {
+      key_bin = from_base64(s->info.env->get(sse_c_key_hdr, ""));
+    } catch (...) {
+      ldpp_dout(s, 5) << "ERROR: rgw_s3_prepare_decrypt invalid encryption key "
+                       << "which contains character that is not base64 encoded."
+                       << dendl;
+      s->err.message = "Requests specifying Server Side Encryption with Customer "
+                       "provided keys must provide an appropriate secret key.";
+      return -EINVAL;
+    }
+
+    if (key_bin.size() != AES_256_KEYSIZE) {
+      ldpp_dout(s, 5) << "ERROR: Invalid encryption key size" << dendl;
+      s->err.message = "Requests specifying Server Side Encryption with Customer "
+                       "provided keys must provide an appropriate secret key.";
+      return -EINVAL;
+    }
+
+    const char *sse_c_key_md5_hdr = copy_source ? "HTTP_X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5" :
+                                                  "HTTP_X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5";
+    std::string keymd5 = s->info.env->get(sse_c_key_md5_hdr, "");
+    std::string keymd5_bin;
+    try {
+      keymd5_bin = from_base64(keymd5);
+    } catch (...) {
+      ldpp_dout(s, 5) << "ERROR: rgw_s3_prepare_decrypt invalid encryption key md5 "
+                       << "which contains character that is not base64 encoded."
+                       << dendl;
+      s->err.message = "Requests specifying Server Side Encryption with Customer "
+                       "provided keys must provide an appropriate secret key md5.";
+      return -EINVAL;
+    }
+
+    if (keymd5_bin.size() != CEPH_CRYPTO_MD5_DIGESTSIZE) {
+      ldpp_dout(s, 5) << "ERROR: Invalid key md5 size " << dendl;
+      s->err.message = "Requests specifying Server Side Encryption with Customer "
+                       "provided keys must provide an appropriate secret key md5.";
+      return -EINVAL;
+    }
+
+    MD5 key_hash;
+    // Allow use of MD5 digest in FIPS mode for non-cryptographic purposes
+    key_hash.SetFlags(EVP_MD_CTX_FLAG_NON_FIPS_ALLOW);
+    uint8_t key_hash_res[CEPH_CRYPTO_MD5_DIGESTSIZE];
+    key_hash.Update(reinterpret_cast<const unsigned char*>(key_bin.c_str()), key_bin.size());
+    key_hash.Final(key_hash_res);
+
+    if ((memcmp(key_hash_res, keymd5_bin.c_str(), CEPH_CRYPTO_MD5_DIGESTSIZE) != 0) ||
+        (get_str_attribute(attrs, RGW_ATTR_CRYPT_KEYMD5) != keymd5_bin)) {
+      s->err.message = "The calculated MD5 hash of the key did not match the hash that was provided.";
+      return -EINVAL;
+    }
+
+    std::string stored_nonce = get_gcm_nonce(s, s, attrs, "SSE-C-AES256-GCM");
+    if (stored_nonce.empty()) return -EIO;
+
+    auto gcm = std::make_unique<AES_256_GCM>(s, s->cct);
+    gcm->set_nonce(reinterpret_cast<const uint8_t*>(stored_nonce.c_str()),
+                   stored_nonce.size());
+    // Re-derive encryption key from user key + object identity
+    // For CopyObject, use the SOURCE object's identity (not destination)
+    std::string bucket_name;
+    std::string object_name;
+    pick_gcm_identity(s, copy_source, src_identity, bucket_name, object_name);
+    if (!gcm->derive_object_key(
+            reinterpret_cast<const uint8_t*>(key_bin.c_str()),
+            AES_256_KEYSIZE,
+            bucket_name,
+            object_name,
+            part_number)) {
+      ldpp_dout(s, 5) << "ERROR: SSE-C-AES256-GCM key derivation failed for "
+                       << bucket_name << "/" << object_name << dendl;
+      s->err.message = "Failed to derive decryption key.";
+      return -EIO;
+    }
+    if (block_crypt) *block_crypt = std::move(gcm);
+
+    if (crypt_http_responses) {
+      crypt_http_responses->emplace("x-amz-server-side-encryption-customer-algorithm", "AES256");
+      crypt_http_responses->emplace("x-amz-server-side-encryption-customer-key-MD5", keymd5);
+    }
+
+    return 0;
+  }
+
   if (stored_mode == "SSE-KMS") {
     if (s->cct->_conf->rgw_crypt_require_ssl &&
         !rgw_transport_is_secure(s->cct, *s->info.env)) {
@@ -1444,7 +2623,56 @@ int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
 
     auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->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');
+    ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+    if (block_crypt) *block_crypt = std::move(aes);
+
+    if (crypt_http_responses) {
+      crypt_http_responses->emplace("x-amz-server-side-encryption", "aws:kms");
+      crypt_http_responses->emplace("x-amz-server-side-encryption-aws-kms-key-id", key_id);
+    }
+
+    return 0;
+  }
+
+  if (stored_mode == "SSE-KMS-GCM") {
+    if (s->cct->_conf->rgw_crypt_require_ssl &&
+        !rgw_transport_is_secure(s->cct, *s->info.env)) {
+      ldpp_dout(s, 5) << "ERROR: Insecure request, rgw_crypt_require_ssl is set" << dendl;
+      return -ERR_INVALID_REQUEST;
+    }
+    /* try to retrieve actual key */
+    std::string key_id = get_str_attribute(attrs, RGW_ATTR_CRYPT_KEYID);
+    std::string actual_key;
+    res = reconstitute_actual_key_from_kms(s, attrs, y, actual_key);
+    if (res != 0) {
+      ldpp_dout(s, 10) << "ERROR: failed to retrieve actual key from key_id: " << key_id << dendl;
+      s->err.message = "Failed to retrieve the actual key, kms-keyid: " + key_id;
+      return res;
+    }
+    if (actual_key.size() != AES_256_KEYSIZE) {
+      ldpp_dout(s, 0) << "ERROR: key obtained from key_id:" <<
+          key_id << " is not 256 bit size" << dendl;
+      s->err.message = "KMS provided an invalid key for the given kms-keyid.";
+      return -EINVAL;
+    }
+
+    std::string stored_nonce = get_gcm_nonce(s, s, attrs, "SSE-KMS-GCM");
+    if (stored_nonce.empty()) {
+      ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+      return -EIO;
+    }
+
+    auto aes = AES_256_GCM_create(s, s->cct,
+                                   reinterpret_cast<const uint8_t*>(actual_key.c_str()),
+                                   AES_256_KEYSIZE,
+                                   reinterpret_cast<const uint8_t*>(stored_nonce.c_str()),
+                                   stored_nonce.size(),
+                                   part_number);
+    ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+    if (!aes) {
+      ldpp_dout(s, 5) << "ERROR: Failed to create AES-256-GCM instance for decryption" << dendl;
+      return -EIO;
+    }
     if (block_crypt) *block_crypt = std::move(aes);
 
     if (crypt_http_responses) {
@@ -1492,6 +2720,53 @@ int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
     return 0;
   }
 
+  if (stored_mode == "RGW-AUTO-GCM") {
+    std::string master_encryption_key;
+    try {
+      master_encryption_key = from_base64(std::string(s->cct->_conf->rgw_crypt_default_encryption_key));
+    } catch (...) {
+      ldpp_dout(s, 5) << "ERROR: rgw_s3_prepare_decrypt invalid default encryption key "
+                       << "which contains character that is not base64 encoded."
+                       << dendl;
+      s->err.message = "The default encryption key is not valid base64.";
+      return -EINVAL;
+    }
+
+    if (master_encryption_key.size() != 256 / 8) {
+      ldpp_dout(s, 0) << "ERROR: failed to decode 'rgw crypt default encryption key' to 256 bit string" << dendl;
+      return -EIO;
+    }
+
+    std::string stored_nonce = get_gcm_nonce(s, s, attrs, "RGW-AUTO-GCM");
+    if (stored_nonce.empty()) return -EIO;
+
+    auto gcm = std::make_unique<AES_256_GCM>(s, s->cct);
+    gcm->set_nonce(reinterpret_cast<const uint8_t*>(stored_nonce.c_str()),
+                   stored_nonce.size());
+
+    // Re-derive encryption key using HMAC-SHA256 with context binding
+    // For CopyObject, use the SOURCE object's identity (not destination)
+    std::string bucket_name;
+    std::string object_name;
+    pick_gcm_identity(s, copy_source, src_identity, bucket_name, object_name);
+
+    if (!gcm->derive_object_key(
+            reinterpret_cast<const uint8_t*>(master_encryption_key.c_str()),
+            AES_256_KEYSIZE,
+            bucket_name,
+            object_name,
+            part_number,
+            "RGW-AUTO-GCM")) {
+      ldpp_dout(s, 5) << "ERROR: RGW-AUTO-GCM key derivation failed for "
+                       << bucket_name << "/" << object_name << dendl;
+      s->err.message = "Failed to derive decryption key.";
+      return -EIO;
+    }
+
+    if (block_crypt) *block_crypt = std::move(gcm);
+    return 0;
+  }
+
   /* SSE-S3 */
   if (stored_mode == "AES256") {
     /* try to retrieve actual key */
@@ -1512,7 +2787,51 @@ int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
 
     auto aes = std::unique_ptr<AES_256_CBC>(new AES_256_CBC(s, s->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');
+    ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+    if (block_crypt) *block_crypt = std::move(aes);
+
+    if (crypt_http_responses) {
+      crypt_http_responses->emplace("x-amz-server-side-encryption", "AES256");
+    }
+
+    return 0;
+  }
+
+  /* SSE-S3 with GCM */
+  if (stored_mode == "AES256-GCM") {
+    /* try to retrieve actual key */
+    std::string key_id = get_str_attribute(attrs, RGW_ATTR_CRYPT_KEYID);
+    std::string actual_key;
+    res = reconstitute_actual_key_from_sse_s3(s, attrs, y, actual_key);
+    if (res != 0) {
+      ldpp_dout(s, 10) << "ERROR: failed to retrieve actual key" << dendl;
+      s->err.message = "Failed to retrieve the actual key";
+      return res;
+    }
+    if (actual_key.size() != AES_256_KEYSIZE) {
+      ldpp_dout(s, 0) << "ERROR: key obtained " <<
+          "is not 256 bit size" << dendl;
+      s->err.message = "SSE-S3 provided an invalid key for the given keyid.";
+      return -EINVAL;
+    }
+
+    std::string stored_nonce = get_gcm_nonce(s, s, attrs, "AES256-GCM");
+    if (stored_nonce.empty()) {
+      ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+      return -EIO;
+    }
+
+    auto aes = AES_256_GCM_create(s, s->cct,
+                                   reinterpret_cast<const uint8_t*>(actual_key.c_str()),
+                                   AES_256_KEYSIZE,
+                                   reinterpret_cast<const uint8_t*>(stored_nonce.c_str()),
+                                   stored_nonce.size(),
+                                   part_number);
+    ::ceph::crypto::zeroize_for_security(actual_key.data(), actual_key.length());
+    if (!aes) {
+      ldpp_dout(s, 5) << "ERROR: Failed to create AES-256-GCM instance for decryption" << dendl;
+      return -EIO;
+    }
     if (block_crypt) *block_crypt = std::move(aes);
 
     if (crypt_http_responses) {
index beba77c0994085431b217aa5843a50c3aa87f0ec..a931785846c723f6f0d08c1baaf522d43dc97df5 100644 (file)
 #include "rgw_putobj.h"
 #include "common/async/yield_context.h"
 
+struct rgw_crypt_src_identity {
+  std::string_view bucket;
+  std::string_view object;
+
+  bool valid() const {
+    return !bucket.empty() && !object.empty();
+  }
+};
+
 /**
  * \brief Interface for block encryption methods
  *
@@ -38,6 +47,15 @@ public:
     */
   virtual size_t get_block_size() = 0;
 
+  /**
+   * Returns size of encrypted block (ciphertext + metadata like auth tags).
+   * For most ciphers this equals get_block_size(), but for AEAD modes like GCM
+   * it includes the authentication tag.
+   */
+  virtual size_t get_encrypted_block_size() {
+    return get_block_size();
+  }
+
   /**
    * Encrypts data.
    * Argument \ref stream_offset shows where in generalized stream chunk is located.
@@ -79,9 +97,93 @@ public:
                        bufferlist& output,
                        off_t stream_offset,
                        optional_yield y) = 0;
+
+  /**
+   * Set the part number for multipart object decryption.
+   * AEAD modes use this for per-part IV derivation.
+   * Default is no-op; CBC derives IVs from block offsets instead.
+   */
+  virtual void set_part_number(uint32_t part_number) {}
 };
 
 static const size_t AES_256_KEYSIZE = 256 / 8;
+static const size_t AES_256_GCM_NONCE_SIZE = 96 / 8;  // 12 bytes, GCM standard
+
+/**
+ * AEAD chunk size constants used for size calculations across RGW.
+ * All supported AEAD ciphers use 128-bit (16-byte) authentication tags.
+ */
+static constexpr size_t AEAD_CHUNK_SIZE = 4096;
+static constexpr size_t AEAD_TAG_SIZE = 16;
+static constexpr size_t AEAD_ENCRYPTED_CHUNK_SIZE = AEAD_CHUNK_SIZE + AEAD_TAG_SIZE;  // 4112
+
+/**
+ * Check if encryption mode is AEAD.
+ * AEAD modes have ciphertext expansion from auth tags and need special
+ * handling for size calculations and multipart part numbers.
+ *
+ * All AEAD modes currently end in "-GCM". When adding non-GCM AEAD modes
+ * (e.g., ChaCha20-Poly1305), update this function to match them.
+ */
+inline bool is_aead_mode(const std::string& mode) {
+  return mode.size() >= 4 && mode.compare(mode.size() - 4, 4, "-GCM") == 0;
+}
+
+/**
+ * Check if encryption mode string indicates CBC mode.
+ * CBC modes: SSE-C-AES256, SSE-KMS, RGW-AUTO, AES256
+ */
+inline bool is_cbc_mode(const std::string& mode) {
+  return mode == "SSE-C-AES256" || mode == "SSE-KMS" ||
+         mode == "RGW-AUTO" || mode == "AES256";
+}
+
+/**
+ * Convert encrypted size to plaintext size for AEAD modes.
+ * Each AEAD_CHUNK_SIZE-byte plaintext chunk becomes AEAD_ENCRYPTED_CHUNK_SIZE bytes
+ * (plaintext + AEAD_TAG_SIZE-byte auth tag).
+ * Pass dpp to enable warning logging for malformed data.
+ */
+inline uint64_t aead_encrypted_to_plaintext_size(uint64_t encrypted_size,
+                                                  const DoutPrefixProvider* dpp = nullptr) {
+  uint64_t full_chunks = encrypted_size / AEAD_ENCRYPTED_CHUNK_SIZE;
+  uint64_t remainder = encrypted_size % AEAD_ENCRYPTED_CHUNK_SIZE;
+
+  if (remainder > 0 && remainder <= AEAD_TAG_SIZE) {
+    // Malformed: partial chunk has no ciphertext, only tag bytes
+    if (dpp) {
+      ldpp_dout(dpp, 1) << "WARNING: aead_encrypted_to_plaintext_size: "
+          << "partial chunk size " << remainder
+          << " is <= tag size " << AEAD_TAG_SIZE
+          << " - data may be corrupted" << dendl;
+    }
+    return full_chunks * AEAD_CHUNK_SIZE;
+  }
+
+  uint64_t partial = (remainder > AEAD_TAG_SIZE) ? (remainder - AEAD_TAG_SIZE) : 0;
+  return full_chunks * AEAD_CHUNK_SIZE + partial;
+}
+
+/**
+ * Convert plaintext size to encrypted size for AEAD modes.
+ * Each AEAD_CHUNK_SIZE-byte plaintext chunk becomes AEAD_ENCRYPTED_CHUNK_SIZE bytes.
+ */
+inline uint64_t aead_plaintext_to_encrypted_size(uint64_t plaintext_size) {
+  if (plaintext_size == 0) return 0;
+  uint64_t num_chunks = (plaintext_size + AEAD_CHUNK_SIZE - 1) / AEAD_CHUNK_SIZE;
+  return plaintext_size + (num_chunks * AEAD_TAG_SIZE);
+}
+
+/**
+ * Convert plaintext offset to encrypted offset for AEAD modes.
+ * Accounts for AEAD_TAG_SIZE-byte auth tag per chunk.
+ */
+inline uint64_t aead_plaintext_to_encrypted_offset(uint64_t plaintext_ofs) {
+  uint64_t chunk_idx = plaintext_ofs / AEAD_CHUNK_SIZE;
+  uint64_t offset_in_chunk = plaintext_ofs % AEAD_CHUNK_SIZE;
+  return chunk_idx * AEAD_ENCRYPTED_CHUNK_SIZE + offset_in_chunk;
+}
+
 bool AES_256_ECB_encrypt(const DoutPrefixProvider* dpp,
                          CephContext* cct,
                          const uint8_t* key,
@@ -90,28 +192,126 @@ bool AES_256_ECB_encrypt(const DoutPrefixProvider* dpp,
                          uint8_t* data_out,
                          size_t data_size);
 
+/**
+ * Create an AES-256-GCM BlockCrypt instance.
+ *
+ * For encryption: Pass nonce=nullptr to generate a random nonce.
+ *                 After creation, call AES_256_GCM_get_nonce() to retrieve it for storage.
+ *
+ * For decryption: Pass the stored nonce from RGW_ATTR_CRYPT_NONCE.
+ */
+std::unique_ptr<BlockCrypt> AES_256_GCM_create(const DoutPrefixProvider* dpp,
+                                                CephContext* cct,
+                                                const uint8_t* key,
+                                                size_t key_len,
+                                                const uint8_t* nonce = nullptr,
+                                                size_t nonce_len = 0,
+                                                uint32_t part_number = 0);
+
+/**
+ * Retrieve the nonce from a BlockCrypt instance for storage in RGW_ATTR_CRYPT_NONCE.
+ * Returns empty string if the BlockCrypt is not an AES_256_GCM instance.
+ */
+std::string AES_256_GCM_get_nonce(BlockCrypt* block_crypt);
+
 class RGWGetObj_BlockDecrypt : public RGWGetObj_Filter {
+  friend class TestableBlockDecrypt;  // For unit testing private members
   const DoutPrefixProvider *dpp;
   CephContext* cct;
   std::unique_ptr<BlockCrypt> crypt; /**< already configured stateless BlockCrypt
                                           for operations when enough data is accumulated */
   off_t enc_begin_skip; /**< amount of data to skip from beginning of received data */
-  off_t ofs; /**< stream offset of data we expect to show up next through \ref handle_data */
+  off_t ofs; /**< plaintext stream offset of data we expect to show up next through \ref handle_data */
+  off_t enc_ofs; /**< encrypted stream offset, for comparing against parts_len which contains encrypted sizes */
   off_t end; /**< stream offset of last byte that is requested */
+  off_t encrypted_total_size; /**< total encrypted object size (for clamping ranges in fixup_range) */
+  bool has_compression{false}; /**< true if a decompression filter is present downstream */
   bufferlist cache; /**< stores extra data that could not (yet) be processed by BlockCrypt */
-  size_t block_size; /**< snapshot of \ref BlockCrypt.get_block_size() */
+  size_t block_size; /**< snapshot of \ref BlockCrypt.get_block_size() (plaintext block size) */
+  size_t encrypted_block_size; /**< snapshot of \ref BlockCrypt.get_encrypted_block_size() (includes auth tag for GCM) */
   optional_yield y;
   std::vector<size_t> parts_len; /**< size of parts of multipart object, parsed from manifest */
+  std::vector<uint32_t> part_nums; /**< actual S3 part numbers for multipart (e.g., [1,3,5]) */
+  uint32_t current_part_num = 0; /**< current part number (1-based, 0 means single-part object) */
 
   int process(bufferlist& cipher, size_t part_ofs, size_t size);
 
+  /**
+   * Process cached data across part boundaries.
+   * Updates current_part_num and calls crypt->set_part_number() as needed.
+   * Returns 0 on success, negative error code on failure.
+   * On success, plain_part_ofs_out contains the plaintext offset for remaining data.
+   */
+  int process_part_boundaries(size_t& plain_part_ofs_out);
+
+  /**
+   * Result of finding which part contains a plaintext offset.
+   */
+  struct PartLocation {
+    size_t part_idx;            /**< Index into parts_len */
+    off_t offset_in_part;       /**< Plaintext offset within the part */
+    off_t cumulative_encrypted; /**< Total encrypted bytes before this part */
+  };
+
+  /**
+   * Find which part contains a given plaintext offset.
+   * @param plaintext_ofs  The plaintext offset to locate
+   * @param clamp_to_last  If true, stop at second-to-last part (for end offsets)
+   */
+  PartLocation find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const;
+
+  /**
+   * Align an encrypted offset up to the end of an encrypted block.
+   * For GCM: ensures we read complete blocks including auth tags.
+   */
+  off_t align_to_encrypted_block_end(off_t enc_ofs) const {
+    if (block_size == encrypted_block_size) {
+      return enc_ofs;  // CBC - no alignment needed
+    }
+    return (enc_ofs / encrypted_block_size) * encrypted_block_size + (encrypted_block_size - 1);
+  }
+
+  /**
+   * Convert a logical (plaintext) offset to encrypted (storage) offset.
+   * For AEAD modes: accounts for auth tag overhead per chunk.
+   */
+  off_t logical_to_encrypted_offset(off_t logical_ofs) const {
+    if (block_size == encrypted_block_size) {
+      return logical_ofs; // Non-AEAD (CBC) - no conversion needed
+    }
+    return aead_plaintext_to_encrypted_offset(logical_ofs);
+  }
+
+  /**
+   * Convert an encrypted size to plaintext size.
+   * For AEAD modes: removes the auth tag overhead per chunk.
+   */
+  size_t encrypted_to_plaintext_size(size_t encrypted_size) const {
+    if (block_size == encrypted_block_size) {
+      return encrypted_size;  // Non-AEAD (CBC) - no conversion needed
+    }
+    return aead_encrypted_to_plaintext_size(encrypted_size, dpp);
+  }
+
 public:
   RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp,
                          CephContext* cct,
                          RGWGetObj_Filter* next,
                          std::unique_ptr<BlockCrypt> crypt,
                          std::vector<size_t> parts_len,
+                         std::vector<uint32_t> part_nums,
+                         off_t encrypted_total_size,
+                         bool has_compression,
                          optional_yield y);
+  // Backward-compatible constructor for CBC mode (no size expansion)
+  RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp,
+                         CephContext* cct,
+                         RGWGetObj_Filter* next,
+                         std::unique_ptr<BlockCrypt> crypt,
+                         std::vector<size_t> parts_len,
+                         optional_yield y)
+    : RGWGetObj_BlockDecrypt(dpp, cct, next, std::move(crypt),
+                             std::move(parts_len), {}, 0, false, y) {}
   virtual ~RGWGetObj_BlockDecrypt();
 
   virtual int fixup_range(off_t& bl_ofs,
@@ -124,6 +324,23 @@ public:
   static int read_manifest_parts(const DoutPrefixProvider *dpp,
                                  const bufferlist& manifest_bl,
                                  std::vector<size_t>& parts_len);
+
+  /**
+   * Returns true if this cipher expands the data size (e.g., AEAD adds auth tags).
+   * Used to determine if obj_size needs adjustment for Content-Length.
+   */
+  bool has_size_expansion() const {
+    return block_size != encrypted_block_size;
+  }
+
+  /**
+   * Calculate the plaintext size from encrypted size.
+   * For AEAD: removes the 16-byte auth tag overhead per chunk.
+   * Public wrapper for use by callers that need to adjust Content-Length.
+   */
+  uint64_t get_plaintext_size(uint64_t encrypted_size) const {
+    return encrypted_to_plaintext_size(encrypted_size);
+  }
 }; /* RGWGetObj_BlockDecrypt */
 
 
@@ -134,7 +351,8 @@ class RGWPutObj_BlockEncrypt : public rgw::putobj::Pipe
   std::unique_ptr<BlockCrypt> crypt; /**< already configured stateless BlockCrypt
                                           for operations when enough data is accumulated */
   bufferlist cache; /**< stores extra data that could not (yet) be processed by BlockCrypt */
-  const size_t block_size; /**< snapshot of \ref BlockCrypt.get_block_size() */
+  const size_t block_size; /**< snapshot of \ref BlockCrypt.get_block_size() (plaintext block size) */
+  uint64_t encrypted_offset = 0; /**< tracks write position in encrypted stream (differs from plaintext for GCM) */
   optional_yield y;
 public:
   RGWPutObj_BlockEncrypt(const DoutPrefixProvider *dpp,
@@ -151,13 +369,37 @@ int rgw_s3_prepare_encrypt(req_state* s, optional_yield y,
                            std::map<std::string, ceph::bufferlist>& attrs,
                            std::unique_ptr<BlockCrypt>* block_crypt,
                            std::map<std::string,
-                                    std::string>& crypt_http_responses);
+                                    std::string>& crypt_http_responses,
+                           uint32_t part_number = 0);
 
 int rgw_s3_prepare_decrypt(req_state* s, optional_yield y,
                            std::map<std::string, ceph::bufferlist>& attrs,
                            std::unique_ptr<BlockCrypt>* block_crypt,
                            std::map<std::string, std::string>* crypt_http_responses,
-                           bool copy_source);
+                           bool copy_source,
+                           uint32_t part_number = 0,
+                           const rgw_crypt_src_identity* src_identity = nullptr);
+
+/**
+ * Get the original (uncompressed, unencrypted) size from ORIGINAL_SIZE attr.
+ * Use for: Content-Length, bucket index, quota.
+ * Returns false if attr missing, not AEAD mode, or parse failure.
+ */
+bool rgw_get_aead_original_size(const DoutPrefixProvider* dpp,
+                                const std::map<std::string, ceph::bufferlist>& attrs,
+                                uint64_t* original_size);
+
+/**
+ * Get the decrypted (but possibly still compressed) size.
+ * Uses CRYPT_PARTS if available, otherwise calculates from encrypted size.
+ * Use for: Copy path where data stays in compressed domain.
+ * WARNING: Never use when caller expects original (uncompressed) size AND
+ *          compression may be present. This yields compressed-domain sizes.
+ */
+bool rgw_get_aead_decrypted_size(const DoutPrefixProvider* dpp,
+                                 const std::map<std::string, ceph::bufferlist>& attrs,
+                                 uint64_t encrypted_size,
+                                 uint64_t* decrypted_size);
 
 static inline void set_attr(std::map<std::string, bufferlist>& attrs,
                             const char* key,
index 7feb78ff7d1aeec0a81c5e92d2741364759b9500..8a316cdba91e8447c0709e650a22a7ea68145cab 100644 (file)
@@ -2048,12 +2048,22 @@ int RGWGetObj::read_user_manifest_part(rgw::sal::Bucket* bucket,
   }
   else
   {
-    if (part->get_size() != ent.meta.size) {
+    /**
+     * For AEAD encryption, the on-disk size includes authentication tags,
+     * but the bucket index stores plaintext size. Convert encrypted size
+     * to plaintext for comparison against the index entry.
+     */
+    uint64_t obj_size = part->get_size();
+    uint64_t decrypted_size = 0;
+    if (rgw_get_aead_decrypted_size(this, part->get_attrs(), obj_size, &decrypted_size)) {
+      obj_size = decrypted_size;
+    }
+    if (obj_size != ent.meta.size) {
       // hmm.. something wrong, object not as expected, abort!
-      ldpp_dout(this, 0) << "ERROR: expected obj_size=" << part->get_size()
+      ldpp_dout(this, 0) << "ERROR: expected obj_size=" << obj_size
           << ", actual read size=" << ent.meta.size << dendl;
       return -EIO;
-         }
+    }
   }
 
   op_ret = rgw_policy_from_attrset(s, s->cct, part->get_attrs(), &obj_policy);
@@ -2576,6 +2586,29 @@ static inline void rgw_cond_decode_objtags(
   }
 }
 
+/**
+ * Calculate the correct object size for AEAD modes.
+ *
+ * Used for range request and Content-Length handling. When compression is
+ * present, stays in the compressed domain and avoids using ORIGINAL_SIZE
+ * since the compressed->encrypted pipeline differs from plaintext->encrypted.
+ */
+static bool rgw_calc_aead_obj_size(const DoutPrefixProvider* dpp,
+                                   const std::map<std::string, bufferlist>& attrs,
+                                   uint64_t encrypted_size,
+                                   bool has_compression,
+                                   uint64_t* out_size)
+{
+  if (!out_size) {
+    return false;
+  }
+  if (!has_compression &&
+      rgw_get_aead_original_size(dpp, attrs, out_size)) {
+    return true;
+  }
+  return rgw_get_aead_decrypted_size(dpp, attrs, encrypted_size, out_size);
+}
+
 void RGWGetObj::execute(optional_yield y)
 {
   bufferlist bl;
@@ -2630,6 +2663,9 @@ void RGWGetObj::execute(optional_yield y)
   op_ret = read_op->prepare(s->yield, this);
   version_id = s->object->get_instance();
   s->obj_size = s->object->get_size();
+  // Preserve encrypted size before compression/decompression modifies s->obj_size
+  // (needed for AEAD decrypt filter range clamping)
+  encrypted_obj_size = s->obj_size;
   attrs = s->object->get_attrs();
   multipart_parts_count = read_op->params.parts_count;
   if (op_ret < 0)
@@ -2646,11 +2682,15 @@ void RGWGetObj::execute(optional_yield y)
   /* start gettorrent */
   if (get_torrent) {
     attr_iter = attrs.find(RGW_ATTR_CRYPT_MODE);
-    if (attr_iter != attrs.end() && attr_iter->second.to_str() == "SSE-C-AES256") {
-      ldpp_dout(this, 0) << "ERROR: torrents are not supported for objects "
-          "encrypted with SSE-C" << dendl;
-      op_ret = -EINVAL;
-      goto done_err;
+    if (attr_iter != attrs.end()) {
+      std::string crypt_mode = attr_iter->second.to_str();
+      // Block torrents for any SSE-C mode (SSE-C-AES256, SSE-C-AES256-GCM, etc.)
+      if (crypt_mode.compare(0, 5, "SSE-C") == 0) {
+        ldpp_dout(this, 0) << "ERROR: torrents are not supported for objects "
+            "encrypted with SSE-C" << dendl;
+        op_ret = -EINVAL;
+        goto done_err;
+      }
     }
     // read torrent info from attr
     bufferlist torrentbl;
@@ -2774,6 +2814,24 @@ void RGWGetObj::execute(optional_yield y)
     return;
   }
 
+  /**
+   * For AEAD encryption: use original size for Content-Length/ranges.
+   * Key rule: compression active => never use AEAD decrypted fallback.
+   * Use encrypted_obj_size (saved earlier) as the raw encrypted input.
+   */
+  if (encrypted && !skip_decrypt) {
+    if (need_decompress) {
+      // compression active: cs_info.orig_size already set obj_size
+    } else {
+      // no compression: try ORIGINAL_SIZE, then decrypted fallback
+      uint64_t size = 0;
+      if (rgw_calc_aead_obj_size(this, attrs, encrypted_obj_size, false, &size)) {
+        s->obj_size = size;
+        s->object->set_obj_size(s->obj_size);
+      }
+    }
+  }
+
   // for range requests with obj size 0
   if (range_str && !(s->obj_size)) {
     total_len = 0;
@@ -4464,6 +4522,7 @@ int RGWPutObj::get_data(const off_t fst, const off_t lst, bufferlist& bl)
     return ret;
 
   obj_size = obj->get_size();
+  uint64_t encrypted_size = obj_size; // capture before any mutation
 
   bool need_decompress;
   op_ret = rgw_compression_info_from_attrset(obj->get_attrs(), need_decompress, cs_info);
@@ -4492,6 +4551,23 @@ int RGWPutObj::get_data(const off_t fst, const off_t lst, bufferlist& bl)
     return op_ret;
   }
 
+  /**
+   * For AEAD encryption: adjust obj_size for range validation.
+   * Key rule: compression active => never use AEAD decrypted fallback.
+   */
+  if (decrypt != nullptr) {
+    if (need_decompress) {
+      // compression active: cs_info.orig_size already set obj_size
+    } else {
+      // no compression: try ORIGINAL_SIZE, then decrypted fallback (safe)
+      uint64_t size = 0;
+      if (rgw_calc_aead_obj_size(this, obj->get_attrs(), encrypted_size,
+                                obj->get_attrs().count(RGW_ATTR_COMPRESSION), &size)) {
+        obj_size = size;
+      }
+    }
+  }
+
   ret = obj->range_to_ofs(obj_size, new_ofs, new_end);
   if (ret < 0)
     return ret;
@@ -4886,6 +4962,20 @@ void RGWPutObj::execute(optional_yield y)
   s->obj_size = ofs;
   s->object->set_obj_size(ofs);
 
+  /* For AEAD modes, ensure ORIGINAL_SIZE is set now that final size is known.
+   * This handles cases where size was unknown at encryption setup:
+   * - Chunked uploads without x-amz-decoded-content-length
+   * - Copy-source-range operations
+   * Without this, bucket index would get size=0 for chunked AEAD uploads. */
+  {
+    const auto mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
+    if (is_aead_mode(mode)) {
+      if (attrs.find(RGW_ATTR_CRYPT_ORIGINAL_SIZE) == attrs.end()) {
+        set_attr(attrs, RGW_ATTR_CRYPT_ORIGINAL_SIZE, std::to_string(s->obj_size));
+      }
+    }
+  }
+
   rgw::op_counters::inc(counters, l_rgw_op_put_obj_b, s->obj_size);
 
   op_ret = do_aws4_auth_completion();
@@ -5270,6 +5360,16 @@ void RGWPostObj::execute(optional_yield y)
     s->object->set_obj_size(ofs);
     obj->set_obj_size(ofs);
 
+    /* For AEAD modes, always overwrite ORIGINAL_SIZE with the actual file
+     * payload size. set_gcm_plaintext_size() stored s->content_length which,
+     * for POST form uploads, is the entire HTTP body (form fields + boundaries
+     * + file data) — not just the file payload. */
+    {
+      const auto mode = get_str_attribute(attrs, RGW_ATTR_CRYPT_MODE);
+      if (is_aead_mode(mode)) {
+        set_attr(attrs, RGW_ATTR_CRYPT_ORIGINAL_SIZE, std::to_string(s->obj_size));
+      }
+    }
 
     op_ret = s->bucket->check_quota(this, quota, s->obj_size, y);
     if (op_ret < 0) {
@@ -5950,22 +6050,26 @@ public:
     }
 
     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) {
-      obj_size = decompress_info.orig_size;
-      s->src_object->set_obj_size(obj_size);
       static constexpr bool partial_content = false;
       decompress.emplace(s->cct, &decompress_info, partial_content, filter);
       filter = &*decompress;
-      end_x = obj_size;
     }
 
     // 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);
+                               nullptr, copy_source, 0, encrypted_total_size);
       if (ret < 0) {
         return ret;
       }
@@ -5974,6 +6078,22 @@ public:
       }
     }
 
+    // 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 */
@@ -6059,6 +6179,19 @@ public:
           << ", 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);
+      }
+    }
   }
 };
 
@@ -10143,11 +10276,15 @@ int get_decrypt_filter(
   std::map<std::string, bufferlist>& attrs,
   bufferlist* manifest_bl,
   std::map<std::string, std::string>* crypt_http_responses,
-  bool copy_source)
+  bool copy_source,
+  uint32_t part_num,
+  off_t encrypted_total_size,
+  const rgw_crypt_src_identity* src_identity)
 {
   std::unique_ptr<BlockCrypt> block_crypt;
   int res = rgw_s3_prepare_decrypt(s, s->yield, attrs, &block_crypt,
-                                   crypt_http_responses, copy_source);
+                                   crypt_http_responses, copy_source, part_num,
+                                   src_identity);
   if (res < 0) {
     return res;
   }
@@ -10159,6 +10296,29 @@ int get_decrypt_filter(
   // correctly decrypt across part boundaries
   std::vector<size_t> parts_len;
 
+  // Read actual S3 part numbers from attribute (set by CompleteMultipartUpload)
+  std::vector<uint32_t> part_nums;
+  if (auto it = attrs.find(RGW_ATTR_CRYPT_PART_NUMS); it != attrs.end()) {
+    try {
+      auto p = it->second.cbegin();
+      using ceph::decode;
+      decode(part_nums, p);
+    } catch (const buffer::error&) {
+      ldpp_dout(s, 1) << "failed to decode RGW_ATTR_CRYPT_PART_NUMS" << dendl;
+      // Continue with empty part_nums - will fail for multipart, ok for single-part
+    }
+  }
+
+  /**
+   * Fallback for GET ?partNumber=N (single part read).
+   * When reading an individual part, the CRYPT_PART_NUMS attribute is skipped
+   * (see rgw_rados.cc skip list), so we use the requested part_num to ensure
+   * correct key derivation and IV generation.
+   */
+  if (part_nums.empty() && part_num > 0) {
+    part_nums.push_back(part_num);
+  }
+
   // for replicated objects, the original part lengths are preserved in an xattr
   if (auto i = attrs.find(RGW_ATTR_CRYPT_PARTS); i != attrs.end()) {
     try {
@@ -10178,8 +10338,37 @@ int get_decrypt_filter(
     }
   }
 
+  /**
+   * For AEAD ciphers (GCM), we need encrypted_total_size to properly clamp
+   * range requests to the actual on-disk object size. AEAD ciphers expand
+   * data (block_size != encrypted_block_size due to auth tags).
+   *
+   * Derivation priority:
+   *   1. parts_len sum - most accurate, already in encrypted domain
+   *   2. CRYPT_ORIGINAL_SIZE - convert plaintext to encrypted size
+   *
+   * Skip derivation for compressed objects: compression changes the input
+   * to encryption, so plaintext_to_encrypted(ORIGINAL_SIZE) would be wrong.
+   * Compressed objects rely on the decompression filter for size handling.
+   */
+  if (encrypted_total_size == 0 &&
+      block_crypt->get_block_size() != block_crypt->get_encrypted_block_size()) {
+    if (!parts_len.empty()) {
+      for (size_t part_len : parts_len) {
+        encrypted_total_size += part_len;
+      }
+    } else if (!attrs.count(RGW_ATTR_COMPRESSION)) {
+      uint64_t orig_size = 0;
+      if (rgw_get_aead_original_size(s, attrs, &orig_size)) {
+        encrypted_total_size = aead_plaintext_to_encrypted_size(orig_size);
+      }
+    }
+  }
+
+  const bool has_compression = attrs.count(RGW_ATTR_COMPRESSION);
   *filter = std::make_unique<RGWGetObj_BlockDecrypt>(
       s, s->cct, cb, std::move(block_crypt),
-      std::move(parts_len), s->yield);
+      std::move(parts_len), std::move(part_nums), encrypted_total_size,
+      has_compression, s->yield);
   return 0;
 }
index 9120bccc3738f52fa91c0320131fb651e1b6a657..c4cfb72f35e6687ac425be1f0970f5a7646c4b6d 100644 (file)
@@ -38,6 +38,8 @@
 #include "rgw_cksum.h"
 #include "rgw_common.h"
 #include "rgw_dmclock.h"
+
+struct rgw_crypt_src_identity;
 #include "rgw_sal.h"
 #include "driver/rados/rgw_user.h"
 #include "rgw_bucket.h"
@@ -393,8 +395,9 @@ public:
     // DataProcessor requires ownership of the entire bufferlist.
     // RGWGetObj_Filter, however, may reuse the original bufferlist after this call.
     // To avoid unintended side effects, we create a copy of the relevant portion.
+    // Note: bl_ofs is the offset into this bufferlist, not an object offset.
     bufferlist copy_bl;
-    bl.begin().copy(bl_len, copy_bl);
+    bl.begin(bl_ofs).copy(bl_len, copy_bl);
 
     int ret = processor->process(std::move(copy_bl), ofs);
     if (ret < 0) return ret;
@@ -458,6 +461,10 @@ protected:
   // PartsCount response when partNumber is specified
   std::optional<int> multipart_parts_count;
 
+  // For AEAD encryption ciphers: preserve original encrypted size before plaintext conversion.
+  // Used by decrypt filter for range clamping. For non-AEAD modes, equals s->obj_size.
+  off_t encrypted_obj_size{0};
+
   int init_common();
 public:
   RGWGetObj() {
@@ -2907,4 +2914,7 @@ int get_decrypt_filter(
   std::map<std::string, bufferlist>& attrs,
   bufferlist* manifest_bl,
   std::map<std::string, std::string>* crypt_http_responses,
-  bool copy_source);
+  bool copy_source,
+  uint32_t part_num = 0,
+  off_t encrypted_total_size = 0,
+  const rgw_crypt_src_identity* src_identity = nullptr);
index 103b2cbf25e3ac52462402ee717bd01bca2d0211..57d93b13664c0c68adee9b5e5fd23dc619ffd5af 100644 (file)
@@ -796,7 +796,12 @@ int RGWGetObj_ObjStore_S3::get_decrypt_filter(std::unique_ptr<RGWGetObj_Filter>
   }
 
   static constexpr bool copy_source = false;
-  return ::get_decrypt_filter(filter, cb, s, attrs, manifest_bl, &crypt_http_responses, copy_source);
+  // Only use part_num for actual multipart objects (parts_count is set)
+  uint32_t part_num = (multipart_part_num && multipart_parts_count)
+                        ? static_cast<uint32_t>(*multipart_part_num)
+                        : 0;
+  return ::get_decrypt_filter(filter, cb, s, attrs, manifest_bl, &crypt_http_responses, copy_source,
+                              part_num, encrypted_obj_size);
 }
 
 int RGWGetObj_ObjStore_S3::verify_requester(const rgw::auth::StrategyRegistry& auth_registry, optional_yield y) 
@@ -3084,7 +3089,10 @@ int RGWPutObj_ObjStore_S3::get_decrypt_filter(
     bufferlist* manifest_bl)
 {
   static constexpr bool copy_source = true;
-  return ::get_decrypt_filter(filter, cb, s, attrs, manifest_bl, nullptr, copy_source);
+  rgw_crypt_src_identity src_identity{copy_source_bucket_name, copy_source_object_name};
+  // part_num=0 for copy source (full object read)
+  return ::get_decrypt_filter(filter, cb, s, attrs, manifest_bl, nullptr, copy_source,
+                              0, 0, &src_identity);
 }
 
 int RGWPutObj_ObjStore_S3::get_encrypt_filter(
@@ -3104,8 +3112,10 @@ int RGWPutObj_ObjStore_S3::get_encrypt_filter(
       /* We are adding to existing object.
        * We use crypto mode that configured as if we were decrypting. */
       static constexpr bool copy_source = false;
+      // Pass part_number for AEAD IV derivation - ensures unique IVs across parts
       res = rgw_s3_prepare_decrypt(s, s->yield, obj->get_attrs(),
-                                   &block_crypt, &crypt_http_responses, copy_source);
+                                   &block_crypt, &crypt_http_responses, copy_source,
+                                   multipart_part_num);
       if (res == 0 && block_crypt != nullptr)
         filter->reset(new RGWPutObj_BlockEncrypt(s, s->cct, cb, std::move(block_crypt), s->yield));
     }
@@ -3114,8 +3124,9 @@ int RGWPutObj_ObjStore_S3::get_encrypt_filter(
   else
   {
     std::unique_ptr<BlockCrypt> block_crypt;
+    // Pass part_number for AEAD IV derivation - ensures unique IVs across parts
     res = rgw_s3_prepare_encrypt(s, s->yield, attrs, &block_crypt,
-                                 crypt_http_responses);
+                                 crypt_http_responses, multipart_part_num);
     if (res == 0 && block_crypt != nullptr) {
       filter->reset(new RGWPutObj_BlockEncrypt(s, s->cct, cb, std::move(block_crypt), s->yield));
     }
index b5ce97fcb1fd69961e4e185dc4b48da077cb56bc..58a2d4eaccfb81502df02518592add1be771d246 100644 (file)
@@ -26,6 +26,13 @@ using namespace std;
 
 
 std::unique_ptr<BlockCrypt> AES_256_CBC_create(const DoutPrefixProvider *dpp, CephContext* cct, const uint8_t* key, size_t len);
+bool AES_256_GCM_derive_object_key(BlockCrypt* block_crypt,
+                                    const uint8_t* user_key,
+                                    size_t key_len,
+                                    const std::string& bucket,
+                                    const std::string& object,
+                                    uint32_t part_number,
+                                    const std::string& domain = "SSE-C-GCM");
 
 class ut_get_sink : public RGWGetObj_Filter {
   std::stringstream sink;
@@ -94,6 +101,16 @@ public:
   }
 };
 
+// Mock that simulates AEAD size expansion (encrypted_block_size > block_size)
+class BlockCryptNoneAEAD: public BlockCryptNone {
+public:
+  BlockCryptNoneAEAD() : BlockCryptNone(AEAD_CHUNK_SIZE) {}
+  size_t get_encrypted_block_size() override
+  {
+    return AEAD_ENCRYPTED_CHUNK_SIZE;  // 4096 + 16 = 4112
+  }
+};
+
 TEST(TestRGWCrypto, verify_AES_256_CBC_identity)
 {
   const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
@@ -813,6 +830,735 @@ TEST(TestRGWCrypto, verify_Encrypt_Decrypt)
 }
 
 
+// AEAD Tests
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_identity)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  // Create some input for encryption
+  const off_t test_range = 1024*1024;
+  buffer::ptr buf(test_range);
+  char* p = buf.c_str();
+  for(size_t i = 0; i < buf.length(); i++)
+    p[i] = i + i*i + (i >> 2);
+
+  bufferlist input;
+  input.append(buf);
+
+  for (unsigned int step : {1, 2, 3, 5, 7, 11, 13, 17})
+  {
+    // Make some random key
+    uint8_t key[32];
+    for(size_t i=0;i<sizeof(key);i++)
+      key[i]=i*step;
+
+    auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+    ASSERT_NE(aes.get(), nullptr);
+
+    size_t block_size = aes->get_block_size();
+    ASSERT_EQ(block_size, 4096u);
+
+    for (size_t r = 97; r < 123 ; r++)
+    {
+      off_t begin = (r*r*r*r*r % test_range);
+      begin = begin - begin % block_size;
+      off_t end = begin + r*r*r*r*r*r*r % (test_range - begin);
+      if (r % 3)
+        end = end - end % block_size;
+      off_t offset = r*r*r*r*r*r*r*r % (1000*1000*1000);
+      offset = offset - offset % block_size;
+
+      ASSERT_EQ(begin % block_size, 0u);
+      ASSERT_LE(end, test_range);
+      ASSERT_EQ(offset % block_size, 0u);
+
+      bufferlist encrypted;
+      ASSERT_TRUE(aes->encrypt(input, begin, end - begin, encrypted, offset, null_yield));
+
+      size_t expected_encrypted_size = aead_plaintext_to_encrypted_size(end - begin);
+      ASSERT_EQ(encrypted.length(), expected_encrypted_size);
+
+      bufferlist decrypted;
+      ASSERT_TRUE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted, offset, null_yield));
+
+      ASSERT_EQ(decrypted.length(), end - begin);
+      ASSERT_EQ(std::string_view(input.c_str() + begin, end - begin),
+                std::string_view(decrypted.c_str(), end - begin) );
+    }
+  }
+}
+
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_chunk_sizes)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  uint8_t key[32];
+  for(size_t i=0;i<sizeof(key);i++)
+    key[i]=i*3;
+
+  auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes.get(), nullptr);
+
+  // Test various sizes to verify chunk + tag handling
+  for (size_t size : {0, 1, 16, 4095, 4096, 4097, 8192, 10000})
+  {
+    buffer::ptr buf(size);
+    char* p = buf.c_str();
+    for(size_t i = 0; i < size; i++)
+      p[i] = i & 0xFF;
+
+    bufferlist input;
+    input.append(buf);
+
+    bufferlist encrypted;
+    ASSERT_TRUE(aes->encrypt(input, 0, size, encrypted, 0, null_yield));
+
+    size_t expected_size = aead_plaintext_to_encrypted_size(size);
+    ASSERT_EQ(encrypted.length(), expected_size);
+
+    bufferlist decrypted;
+    ASSERT_TRUE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+    ASSERT_EQ(decrypted.length(), size);
+
+    if (size > 0) {
+      ASSERT_EQ(std::string_view(input.c_str(), size),
+                std::string_view(decrypted.c_str(), size));
+    }
+  }
+}
+
+
+TEST(TestRGWCrypto, verify_AEAD_size_conversion)
+{
+  // Test is_aead_mode() - AEAD modes have ciphertext expansion
+  ASSERT_TRUE(is_aead_mode("SSE-C-AES256-GCM"));
+  ASSERT_TRUE(is_aead_mode("SSE-KMS-GCM"));
+  ASSERT_TRUE(is_aead_mode("RGW-AUTO-GCM"));
+  ASSERT_TRUE(is_aead_mode("AES256-GCM"));
+  ASSERT_FALSE(is_aead_mode("SSE-C-AES256"));
+  ASSERT_FALSE(is_aead_mode("SSE-KMS"));
+  ASSERT_FALSE(is_aead_mode(""));
+  ASSERT_FALSE(is_aead_mode("GCM"));
+
+  // Test is_cbc_mode() - exact match for known CBC modes
+  ASSERT_TRUE(is_cbc_mode("SSE-C-AES256"));
+  ASSERT_TRUE(is_cbc_mode("SSE-KMS"));
+  ASSERT_TRUE(is_cbc_mode("RGW-AUTO"));
+  ASSERT_TRUE(is_cbc_mode("AES256"));
+  ASSERT_FALSE(is_cbc_mode("SSE-C-AES256-GCM"));
+  ASSERT_FALSE(is_cbc_mode("SSE-KMS-GCM"));
+  ASSERT_FALSE(is_cbc_mode(""));
+  ASSERT_FALSE(is_cbc_mode("invalid"));
+
+  // Test aead_plaintext_to_encrypted_size()
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(0), 0UL);       // Zero bytes
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(1), 17UL);      // 1 + 16 tag
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(100), 116UL);   // 100 + 16 tag
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(4095), 4111UL); // 4095 + 16 tag
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(4096), 4112UL); // Full chunk
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(4097), 4129UL); // 4097 + 32 tags (2 chunks)
+  ASSERT_EQ(aead_plaintext_to_encrypted_size(8192), 8224UL); // Two full chunks
+
+  // Test aead_encrypted_to_plaintext_size()
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(0), 0UL);       // Zero bytes
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(16), 0UL);      // Tag only -> 0 plaintext
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(17), 1UL);      // 1 byte + tag
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(100), 84UL);    // 100 - 16 tag
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(4112), 4096UL); // Full chunk
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(4212), 4180UL); // 4096 + 84 (one chunk + partial)
+  ASSERT_EQ(aead_encrypted_to_plaintext_size(8224), 8192UL); // Two full chunks
+
+  // Test roundtrip: plaintext -> encrypted -> plaintext
+  for (uint64_t plain : {0UL, 1UL, 16UL, 100UL, 4095UL, 4096UL, 4097UL,
+                         8192UL, 10000UL, 1000000UL}) {
+    uint64_t enc = aead_plaintext_to_encrypted_size(plain);
+    uint64_t recovered = aead_encrypted_to_plaintext_size(enc);
+    ASSERT_EQ(plain, recovered)
+      << "Roundtrip failed for plaintext=" << plain
+      << " encrypted=" << enc << " recovered=" << recovered;
+  }
+
+  // Test aead_plaintext_to_encrypted_offset()
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(0), 0UL);       // Start of file
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(100), 100UL);   // Within first chunk
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(4095), 4095UL); // End of first chunk
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(4096), 4112UL); // Start of second chunk
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(4196), 4212UL); // 100 bytes into second chunk
+  ASSERT_EQ(aead_plaintext_to_encrypted_offset(8192), 8224UL); // Start of third chunk
+
+  // Test has_size_expansion() via RGWGetObj_BlockDecrypt
+  // GCM expands data (adds 16-byte auth tag per chunk), CBC does not
+  {
+    const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+    ut_get_sink get_sink;
+
+    auto cbc = std::make_unique<BlockCryptNone>(4096);
+    RGWGetObj_BlockDecrypt decrypt_cbc(&no_dpp, g_ceph_context, &get_sink,
+                                       std::move(cbc), {}, null_yield);
+    ASSERT_FALSE(decrypt_cbc.has_size_expansion());
+
+    auto gcm = std::make_unique<BlockCryptNoneAEAD>();
+    RGWGetObj_BlockDecrypt decrypt_gcm(&no_dpp, g_ceph_context, &get_sink,
+                                       std::move(gcm), {}, null_yield);
+    ASSERT_TRUE(decrypt_gcm.has_size_expansion());
+  }
+}
+
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_tag_verification)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  uint8_t key[32];
+  for(size_t i=0;i<sizeof(key);i++)
+    key[i]=i*7;
+
+  auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes.get(), nullptr);
+
+  const size_t size = 8192;  // 2 chunks
+  buffer::ptr buf(size);
+  char* p = buf.c_str();
+  for(size_t i = 0; i < size; i++)
+    p[i] = i & 0xFF;
+
+  bufferlist input;
+  input.append(buf);
+
+  bufferlist encrypted;
+  ASSERT_TRUE(aes->encrypt(input, 0, size, encrypted, 0, null_yield));
+
+  // Corrupt the ciphertext
+  char* enc_p = encrypted.c_str();
+  enc_p[100] ^= 0xFF;
+
+  bufferlist decrypted;
+  // Decryption should fail due to tag mismatch
+  ASSERT_FALSE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+}
+
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_nonce_uniqueness)
+{
+  // This test verifies the MinIO-style per-object random nonce mechanism:
+  // 1. Each GCM instance gets a unique random nonce
+  // 2. Decryption with wrong nonce fails
+  // 3. Decryption with correct nonce succeeds
+
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  uint8_t key[32];
+  for(size_t i=0;i<sizeof(key);i++)
+    key[i]=i*11;
+
+  const size_t size = 4096;
+  buffer::ptr buf(size);
+  char* p = buf.c_str();
+  for(size_t i = 0; i < size; i++)
+    p[i] = i & 0xFF;
+
+  bufferlist input;
+  input.append(buf);
+
+  // Create first instance - auto-generates random nonce
+  auto aes1(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes1.get(), nullptr);
+
+  bufferlist encrypted1;
+  ASSERT_TRUE(aes1->encrypt(input, 0, size, encrypted1, 0, null_yield));
+
+  // Create second instance with different random nonce
+  auto aes2(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes2.get(), nullptr);
+
+  // Try to decrypt data from aes1 with aes2 - should FAIL
+  // (different nonces, even with same key)
+  bufferlist decrypted_wrong;
+  ASSERT_FALSE(aes2->decrypt(encrypted1, 0, encrypted1.length(), decrypted_wrong, 0, null_yield));
+
+  // Decrypt with original instance - should succeed
+  bufferlist decrypted_correct;
+  ASSERT_TRUE(aes1->decrypt(encrypted1, 0, encrypted1.length(), decrypted_correct, 0, null_yield));
+  ASSERT_EQ(decrypted_correct.length(), size);
+  ASSERT_EQ(std::string_view(input.c_str(), size),
+            std::string_view(decrypted_correct.c_str(), size));
+}
+
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_nonce_restore)
+{
+  // This test simulates the encrypt/decrypt flow with stored nonce:
+  // 1. Encrypt with auto-generated nonce
+  // 2. Extract nonce (would be stored in RGW_ATTR_CRYPT_NONCE)
+  // 3. Create new instance with restored nonce
+  // 4. Decrypt successfully
+
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  uint8_t key[32];
+  for(size_t i=0;i<sizeof(key);i++)
+    key[i]=i*13;
+
+  const size_t size = 8192;
+  buffer::ptr buf(size);
+  char* p = buf.c_str();
+  for(size_t i = 0; i < size; i++)
+    p[i] = (i * 7) & 0xFF;
+
+  bufferlist input;
+  input.append(buf);
+
+  // Simulate encryption path
+  auto aes_encrypt(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes_encrypt.get(), nullptr);
+
+  bufferlist encrypted;
+  ASSERT_TRUE(aes_encrypt->encrypt(input, 0, size, encrypted, 0, null_yield));
+
+  // Extract nonce (simulates storing to RGW_ATTR_CRYPT_NONCE)
+  std::string stored_nonce = AES_256_GCM_get_nonce(aes_encrypt.get());
+  ASSERT_EQ(stored_nonce.size(), AES_256_GCM_NONCE_SIZE);
+
+  // Release encryption instance (simulates different request)
+  aes_encrypt.reset();
+
+  // Simulate decryption path with restored nonce
+  auto aes_decrypt(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32,
+                                       reinterpret_cast<const uint8_t*>(stored_nonce.c_str()),
+                                       stored_nonce.size()));
+  ASSERT_NE(aes_decrypt.get(), nullptr);
+
+  bufferlist decrypted;
+  ASSERT_TRUE(aes_decrypt->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+
+  ASSERT_EQ(decrypted.length(), size);
+  ASSERT_EQ(std::string_view(input.c_str(), size),
+            std::string_view(decrypted.c_str(), size));
+}
+
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_key_derivation)
+{
+  NoDoutPrefix no_dpp(g_ceph_context, ceph_subsys_rgw);
+  uint8_t user_key[32];
+  for (size_t i = 0; i < sizeof(user_key); i++) user_key[i] = i;
+  const std::string plaintext = "test data for key derivation";
+
+  // Test 1: Same identity produces same derived key (encrypt/decrypt roundtrip)
+  {
+    auto aes1(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32));
+    ASSERT_NE(aes1.get(), nullptr);
+    std::string nonce = AES_256_GCM_get_nonce(aes1.get());
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes1.get(), user_key, 32,
+                                               "mybucket", "myobject", 0));
+
+    bufferlist input;
+    input.append(plaintext);
+    bufferlist encrypted;
+    ASSERT_TRUE(aes1->encrypt(input, 0, input.length(), encrypted, 0, null_yield));
+
+    auto aes2(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32,
+                                  reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                  nonce.size()));
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes2.get(), user_key, 32,
+                                               "mybucket", "myobject", 0));
+
+    bufferlist decrypted;
+    ASSERT_TRUE(aes2->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+    ASSERT_EQ(std::string_view(input.c_str(), input.length()),
+              std::string_view(decrypted.c_str(), decrypted.length()));
+  }
+
+  // Test 2: Different bucket/object produces different derived key
+  {
+    auto aes1(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32));
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes1.get(), user_key, 32,
+                                               "bucket1", "object1", 0));
+    std::string nonce = AES_256_GCM_get_nonce(aes1.get());
+
+    auto aes2(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32,
+                                  reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                  nonce.size()));
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes2.get(), user_key, 32,
+                                               "bucket2", "object2", 0));
+
+    bufferlist input;
+    input.append(plaintext);
+    bufferlist enc1, enc2;
+    ASSERT_TRUE(aes1->encrypt(input, 0, input.length(), enc1, 0, null_yield));
+    ASSERT_TRUE(aes2->encrypt(input, 0, input.length(), enc2, 0, null_yield));
+    ASSERT_NE(std::string_view(enc1.c_str(), enc1.length()),
+              std::string_view(enc2.c_str(), enc2.length()));
+  }
+
+  // Test 3: Different part numbers produce different derived keys
+  {
+    auto aes1(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32));
+    std::string nonce = AES_256_GCM_get_nonce(aes1.get());
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes1.get(), user_key, 32,
+                                               "bucket", "object", 1));
+
+    auto aes2(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32,
+                                  reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                  nonce.size()));
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes2.get(), user_key, 32,
+                                               "bucket", "object", 2));
+
+    bufferlist input;
+    input.append(plaintext);
+    bufferlist enc1, enc2;
+    ASSERT_TRUE(aes1->encrypt(input, 0, input.length(), enc1, 0, null_yield));
+    ASSERT_TRUE(aes2->encrypt(input, 0, input.length(), enc2, 0, null_yield));
+    ASSERT_NE(std::string_view(enc1.c_str(), enc1.length()),
+              std::string_view(enc2.c_str(), enc2.length()));
+  }
+
+  // Test 4: Wrong identity fails decryption (auth tag mismatch)
+  {
+    auto aes_enc(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32));
+    std::string nonce = AES_256_GCM_get_nonce(aes_enc.get());
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes_enc.get(), user_key, 32,
+                                               "bucket1", "object1", 0));
+
+    bufferlist input;
+    input.append(plaintext);
+    bufferlist encrypted;
+    ASSERT_TRUE(aes_enc->encrypt(input, 0, input.length(), encrypted, 0, null_yield));
+
+    auto aes_dec(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32,
+                                     reinterpret_cast<const uint8_t*>(nonce.c_str()),
+                                     nonce.size()));
+    ASSERT_TRUE(AES_256_GCM_derive_object_key(aes_dec.get(), user_key, 32,
+                                               "bucket2", "object2", 0));
+
+    bufferlist decrypted;
+    ASSERT_FALSE(aes_dec->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+  }
+}
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_chunk_reorder_detection)
+{
+  // Verify that swapping chunk positions is detected via AAD
+  NoDoutPrefix no_dpp(g_ceph_context, ceph_subsys_rgw);
+  uint8_t key[32];
+  for (size_t i = 0; i < sizeof(key); i++) key[i] = i * 7;
+
+  auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes.get(), nullptr);
+
+  // Create 2 chunks of data (8192 bytes = 2 x 4096)
+  const size_t size = 8192;
+  buffer::ptr buf(size);
+  char* p = buf.c_str();
+  for (size_t i = 0; i < size; i++) p[i] = i & 0xFF;
+
+  bufferlist input;
+  input.append(buf);
+
+  bufferlist encrypted;
+  ASSERT_TRUE(aes->encrypt(input, 0, size, encrypted, 0, null_yield));
+
+  // Encrypted layout: [chunk0_cipher(4096)][tag0(16)][chunk1_cipher(4096)][tag1(16)]
+  // Total: 8192 + 32 = 8224 bytes
+  ASSERT_EQ(encrypted.length(), 8224u);
+
+  // Swap the two encrypted chunks (including their tags)
+  buffer::ptr enc_copy(encrypted.length());
+  encrypted.begin().copy(encrypted.length(), enc_copy.c_str());
+  char* enc_data = enc_copy.c_str();
+  const size_t enc_chunk_size = 4096 + 16;  // ciphertext + tag
+  char temp[4112];
+  memcpy(temp, enc_data, enc_chunk_size);                       // save chunk0
+  memcpy(enc_data, enc_data + enc_chunk_size, enc_chunk_size);  // chunk1 -> chunk0 position
+  memcpy(enc_data + enc_chunk_size, temp, enc_chunk_size);      // chunk0 -> chunk1 position
+
+  bufferlist swapped;
+  swapped.append(enc_copy);
+
+  // Decryption should fail because AAD (chunk_index) won't match
+  bufferlist decrypted;
+  ASSERT_FALSE(aes->decrypt(swapped, 0, swapped.length(), decrypted, 0, null_yield));
+}
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_aad_roundtrip)
+{
+  // Verify basic encrypt/decrypt still works correctly with AAD
+  NoDoutPrefix no_dpp(g_ceph_context, ceph_subsys_rgw);
+  uint8_t key[32];
+  for (size_t i = 0; i < sizeof(key); i++) key[i] = i * 11;
+
+  // Test multiple chunk scenarios
+  for (size_t size : {4096u, 8192u, 12288u, 10000u}) {
+    auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+    ASSERT_NE(aes.get(), nullptr);
+
+    buffer::ptr buf(size);
+    char* p = buf.c_str();
+    for (size_t i = 0; i < size; i++) p[i] = (i * 17) & 0xFF;
+
+    bufferlist input;
+    input.append(buf);
+
+    bufferlist encrypted;
+    ASSERT_TRUE(aes->encrypt(input, 0, size, encrypted, 0, null_yield));
+
+    bufferlist decrypted;
+    ASSERT_TRUE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted, 0, null_yield));
+
+    ASSERT_EQ(decrypted.length(), size);
+    ASSERT_EQ(std::string_view(input.c_str(), size),
+              std::string_view(decrypted.c_str(), size));
+  }
+}
+
+TEST(TestRGWCrypto, verify_AES_256_GCM_aad_offset_mismatch)
+{
+  // Verify AAD detects wrong stream offset during decryption
+  NoDoutPrefix no_dpp(g_ceph_context, ceph_subsys_rgw);
+  uint8_t key[32];
+  for (size_t i = 0; i < sizeof(key); i++) key[i] = i * 13;
+
+  auto aes(AES_256_GCM_create(&no_dpp, g_ceph_context, &key[0], 32));
+  ASSERT_NE(aes.get(), nullptr);
+
+  const size_t size = 4096;
+  buffer::ptr buf(size);
+  char* p = buf.c_str();
+  for (size_t i = 0; i < size; i++) p[i] = i & 0xFF;
+
+  bufferlist input;
+  input.append(buf);
+
+  // Encrypt at offset 8192 (chunk index 2)
+  off_t stream_offset = 8192;
+  bufferlist encrypted;
+  ASSERT_TRUE(aes->encrypt(input, 0, size, encrypted, stream_offset, null_yield));
+
+  // Decrypt with same offset - should succeed
+  bufferlist decrypted;
+  ASSERT_TRUE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted, stream_offset, null_yield));
+  ASSERT_EQ(std::string_view(input.c_str(), size),
+            std::string_view(decrypted.c_str(), size));
+
+  // Decrypt with wrong offset (0 instead of 8192) - should FAIL (AAD mismatch)
+  bufferlist decrypted_wrong;
+  ASSERT_FALSE(aes->decrypt(encrypted, 0, encrypted.length(), decrypted_wrong, 0, null_yield));
+}
+
+
+// Test helper class to expose private methods for unit testing
+// (declared as friend in RGWGetObj_BlockDecrypt)
+class TestableBlockDecrypt : public RGWGetObj_BlockDecrypt {
+public:
+  // Re-export PartLocation struct for test code
+  using PartLocation = RGWGetObj_BlockDecrypt::PartLocation;
+
+  TestableBlockDecrypt(const DoutPrefixProvider* dpp,
+                       CephContext* cct,
+                       RGWGetObj_Filter* next,
+                       std::unique_ptr<BlockCrypt> crypt,
+                       std::vector<size_t> parts_len)
+    : RGWGetObj_BlockDecrypt(dpp, cct, next, std::move(crypt),
+                             std::move(parts_len), null_yield) {}
+
+  // Wrapper to expose private method for testing
+  PartLocation find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const {
+    return RGWGetObj_BlockDecrypt::find_part_for_plaintext_offset(plaintext_ofs, clamp_to_last);
+  }
+};
+
+TEST(TestRGWCrypto, verify_PartLocation_single_part)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  std::vector<size_t> parts = {10 * 1024 * 1024};
+  auto crypt = std::make_unique<BlockCryptNone>(4096);
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(1000, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 1000);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(10 * 1024 * 1024 - 1, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 10 * 1024 * 1024 - 1);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+}
+
+TEST(TestRGWCrypto, verify_PartLocation_multipart_cbc)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  const size_t part_size = 5 * 1024 * 1024;
+  std::vector<size_t> parts = {part_size, part_size, part_size};
+  auto crypt = std::make_unique<BlockCryptNone>(4096);
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(part_size - 1, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size - 1));
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(part_size, false);
+  ASSERT_EQ(loc.part_idx, 1u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)part_size);
+
+  loc = decrypt.find_part_for_plaintext_offset(part_size + 1000, false);
+  ASSERT_EQ(loc.part_idx, 1u);
+  ASSERT_EQ(loc.offset_in_part, 1000);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)part_size);
+
+  loc = decrypt.find_part_for_plaintext_offset(2 * part_size, false);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * part_size));
+
+  loc = decrypt.find_part_for_plaintext_offset(3 * part_size - 100, false);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size - 100));
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * part_size));
+}
+
+TEST(TestRGWCrypto, verify_PartLocation_multipart_aead)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  // For AEAD, parts_len contains ENCRYPTED sizes (with auth tag overhead)
+  const size_t plaintext_per_part = 1024 * AEAD_CHUNK_SIZE;  // 4MB
+  const size_t encrypted_per_part = aead_plaintext_to_encrypted_size(plaintext_per_part);
+  std::vector<size_t> parts = {encrypted_per_part, encrypted_per_part, encrypted_per_part};
+
+  auto crypt = std::make_unique<BlockCryptNoneAEAD>();
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part - 1, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, (off_t)(plaintext_per_part - 1));
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part, false);
+  ASSERT_EQ(loc.part_idx, 1u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)encrypted_per_part);
+
+  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part + 8192, false);
+  ASSERT_EQ(loc.part_idx, 1u);
+  ASSERT_EQ(loc.offset_in_part, 8192);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)encrypted_per_part);
+
+  loc = decrypt.find_part_for_plaintext_offset(2 * plaintext_per_part, false);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * encrypted_per_part));
+}
+
+TEST(TestRGWCrypto, verify_PartLocation_clamp_to_last)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  const size_t part_size = 5 * 1024 * 1024;
+  std::vector<size_t> parts = {part_size, part_size, part_size};
+  auto crypt = std::make_unique<BlockCryptNone>(4096);
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  // Offset within object - both modes return the same
+  auto loc = decrypt.find_part_for_plaintext_offset(2 * part_size + 1000, false);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, 1000);
+
+  loc = decrypt.find_part_for_plaintext_offset(2 * part_size + 1000, true);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, 1000);
+
+  // Offset beyond all parts - without clamping returns invalid index
+  loc = decrypt.find_part_for_plaintext_offset(3 * part_size + 5000, false);
+  ASSERT_EQ(loc.part_idx, 3u);
+  ASSERT_EQ(loc.offset_in_part, 5000);
+
+  // With clamping - stays at last valid part
+  loc = decrypt.find_part_for_plaintext_offset(3 * part_size + 5000, true);
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size + 5000));
+}
+
+TEST(TestRGWCrypto, verify_PartLocation_unequal_parts)
+{
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  // Parts: 1MB, 3MB, 2MB
+  std::vector<size_t> parts = {1024 * 1024, 3 * 1024 * 1024, 2 * 1024 * 1024};
+  auto crypt = std::make_unique<BlockCryptNone>(4096);
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  auto loc = decrypt.find_part_for_plaintext_offset(500 * 1024, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 500 * 1024);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(1536 * 1024, false);  // 1.5MB
+  ASSERT_EQ(loc.part_idx, 1u);
+  ASSERT_EQ(loc.offset_in_part, 512 * 1024);
+  ASSERT_EQ(loc.cumulative_encrypted, 1024 * 1024);
+
+  loc = decrypt.find_part_for_plaintext_offset(4608 * 1024, false);  // 4.5MB
+  ASSERT_EQ(loc.part_idx, 2u);
+  ASSERT_EQ(loc.offset_in_part, 512 * 1024);
+  ASSERT_EQ(loc.cumulative_encrypted, 4 * 1024 * 1024);
+}
+
+TEST(TestRGWCrypto, verify_PartLocation_no_manifest)
+{
+  // Single-part objects don't have a multipart manifest, so parts_len is empty.
+  // The code treats this as one logical part starting at offset 0.
+  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
+  ut_get_sink get_sink;
+
+  std::vector<size_t> parts = {};
+  auto crypt = std::make_unique<BlockCryptNone>(4096);
+  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
+                               std::move(crypt), parts);
+
+  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 0);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(1000, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 1000);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+
+  loc = decrypt.find_part_for_plaintext_offset(10 * 1024 * 1024, false);
+  ASSERT_EQ(loc.part_idx, 0u);
+  ASSERT_EQ(loc.offset_in_part, 10 * 1024 * 1024);
+  ASSERT_EQ(loc.cumulative_encrypted, 0);
+}
+
+
 int main(int argc, char **argv) {
   auto args = argv_to_vec(argc, argv);
   auto cct = global_init(NULL, args, CEPH_ENTITY_TYPE_CLIENT,