This is an automated email from the ASF dual-hosted git repository.

Gargi-jais11 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 16545021c4c HDDS-15141. Support `HMAC-SHA256` signature verification 
for S3 chunked uploads (#11061).
16545021c4c is described below

commit 16545021c4c90f523c583ba7d8d321d1985e73c5
Author: KUAN-HAO HUANG <[email protected]>
AuthorDate: Tue Sep 8 12:27:45 2026 +0800

    HDDS-15141. Support `HMAC-SHA256` signature verification for S3 chunked 
uploads (#11061).
---
 .../hadoop/ozone/s3/MultiDigestInputStream.java    |   8 +
 .../hadoop/ozone/s3/SignedChunksInputStream.java   | 263 ++++++++++++++++-----
 .../hadoop/ozone/s3/endpoint/EndpointBase.java     |  67 +++++-
 .../hadoop/ozone/s3/endpoint/ObjectEndpoint.java   |  31 ++-
 .../ozone/s3/endpoint/ObjectEndpointStreaming.java |  45 ++--
 .../s3/endpoint/S3ObjectStreamingWriteGuard.java   |   5 +
 .../ozone/s3/endpoint/S3ObjectWriteGuard.java      |  30 +++
 .../hadoop/ozone/s3/exception/S3ErrorTable.java    |   5 +
 .../hadoop/ozone/s3/signature/ChunksValidator.java | 124 ++++++++++
 .../hadoop/ozone/client/ClientProtocolStub.java    |  35 +++
 .../hadoop/ozone/client/OzoneBucketStub.java       |  79 +++++++
 .../ozone/s3/TestSignedChunksInputStream.java      | 181 ++++++++++++--
 .../hadoop/ozone/s3/endpoint/TestObjectPut.java    | 205 ++++++++++++++--
 .../hadoop/ozone/s3/endpoint/TestPartUpload.java   |  69 +++++-
 .../ozone/s3/endpoint/TestPermissionCheck.java     |   2 +-
 .../ozone/s3/endpoint/TestS3ObjectWriteGuard.java  |  31 +++
 .../ozone/s3/endpoint/TestUploadWithStream.java    |  53 ++++-
 .../ozone/s3/signature/SignatureTestUtils.java     | 128 ++++++++++
 .../ozone/s3/signature/TestChunksValidator.java    | 138 +++++++++++
 19 files changed, 1351 insertions(+), 148 deletions(-)

diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/MultiDigestInputStream.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/MultiDigestInputStream.java
index 587cbec0516..1f35aaaa16c 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/MultiDigestInputStream.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/MultiDigestInputStream.java
@@ -176,6 +176,14 @@ public Map<String, MessageDigest> getAllDigests() {
     return new HashMap<>(digests);
   }
 
+  /**
+   * @return the underlying stream this digest stream wraps. Lets a caller 
reach a wrapped
+   *         {@link SignedChunksInputStream} to attach a chunk validator once 
the signing key is known.
+   */
+  public InputStream getWrappedStream() {
+    return in;
+  }
+
   /**
    * Resets all message digests by calling {@link MessageDigest#reset()} on 
each
    * registered digest.
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/SignedChunksInputStream.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/SignedChunksInputStream.java
index f3c825db4e6..098111222d5 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/SignedChunksInputStream.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/SignedChunksInputStream.java
@@ -21,9 +21,16 @@
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.xml.bind.DatatypeConverter;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.apache.hadoop.ozone.s3.exception.S3ErrorTable;
+import org.apache.hadoop.ozone.s3.signature.ChunksValidator;
 
 /**
  * Input stream implementation to read body of a signed chunked upload. This 
should also work
@@ -53,8 +60,9 @@
  * </p>
  *
  * <p>
- * Note that there are no actual chunk signature verification taking place. 
The InputStream only
- * returns the actual chunk payload from chunked signatures format.
+ * If a {@link ChunksValidator} is supplied, the signature of each chunk is 
verified (in real time, without
+ * buffering the whole chunk) as the payload is read. Without a validator the 
stream only strips the chunk
+ * signatures format and returns the payload.
  * </p>
  *
  * Reference:
@@ -71,11 +79,27 @@
  */
 public class SignedChunksInputStream extends InputStream {
 
-  private final Pattern signatureLinePattern =
-      Pattern.compile("([0-9A-Fa-f]+);chunk-signature=.*");
+  /**
+   * Chunk header line. The signature is HMAC-SHA256, i.e. exactly 64 hex 
characters: the S3 Gateway only
+   * accepts the AWS4-HMAC-SHA256 signing algorithm, so this is the only chunk 
signature format it can see.
+   */
+  private static final Pattern SIGNATURE_LINE_PATTERN =
+      Pattern.compile("([0-9A-Fa-f]+);chunk-signature=([0-9A-Fa-f]{64})");
 
   private final InputStream originalStream;
 
+  /** Identifies the object in the S3 error response for a malformed body. */
+  private final String keyPath;
+
+  /** Verifies each chunk signature, or {@code null} to skip verification. */
+  private ChunksValidator validator;
+
+  /** SHA-256 of the current chunk payload; {@code null} when not verifying. */
+  private MessageDigest chunkDigest;
+
+  /** Signature parsed from the current chunk header line. */
+  private String chunkSignature;
+
   /**
    * Size of the chunk payload. If zero, the signature line should be parsed to
    * retrieve the subsequent chunk payload size.
@@ -88,34 +112,49 @@ public class SignedChunksInputStream extends InputStream {
    */
   private boolean isFinalChunkEncountered = false;
 
-  public SignedChunksInputStream(InputStream inputStream) {
+  public SignedChunksInputStream(InputStream inputStream, String keyPath) {
     originalStream = inputStream;
+    this.keyPath = keyPath;
+  }
+
+  /**
+   * Enable chunk-signature verification. Used when the signing key (derived by
+   * OM, see HDDS-15140) only becomes available after the key is opened, i.e.
+   * after this stream is constructed. Must be called before the first read.
+   */
+  public void attachValidator(ChunksValidator chunksValidator) {
+    this.validator = Objects.requireNonNull(chunksValidator, "chunksValidator 
== null");
+    this.chunkDigest = newSha256();
+  }
+
+  /**
+   * @return the signed chunk stream the request body is built on, or {@code 
null} if the body is not
+   *         a signed multi-chunk upload.
+   */
+  public static SignedChunksInputStream unwrap(InputStream body) {
+    if (body instanceof MultiDigestInputStream) {
+      InputStream wrapped = ((MultiDigestInputStream) body).getWrappedStream();
+      if (wrapped instanceof SignedChunksInputStream) {
+        return (SignedChunksInputStream) wrapped;
+      }
+    }
+    return null;
   }
 
   @Override
   public int read() throws IOException {
-    if (isFinalChunkEncountered) {
+    if (isFinalChunkEncountered || !ensureChunkPayload()) {
       return -1;
     }
-    if (remainingData > 0) {
-      int curr = originalStream.read();
-      remainingData--;
-      if (remainingData == 0) {
-        //read the "\r\n" at the end of the data section
-        originalStream.read();
-        originalStream.read();
-      }
-      return curr;
-    } else {
-      remainingData = readContentLengthFromHeader();
-      if (remainingData <= 0) {
-        // there is always a final zero byte chunk so we can stop reading
-        // if we encounter this chunk
-        isFinalChunkEncountered = true;
-        return -1;
-      }
-      return read();
+    int curr = originalStream.read();
+    if (curr == -1) {
+      stopAtUnexpectedEof();
+      return -1;
     }
+    remainingData--;
+    updateDigest((byte) curr);
+    endChunkIfComplete();
+    return curr;
   }
 
   @Override
@@ -126,46 +165,108 @@ public int read(byte[] b, int off, int len) throws 
IOException {
           + len + " don't match the array length of " + b.length);
     } else if (len == 0) {
       return 0;
-    } else if (isFinalChunkEncountered) {
-      return -1;
     }
     int currentOff = off;
     int currentLen = len;
     int totalReadBytes = 0;
-    int realReadLen = 0;
-    int maxReadLen = 0;
-    do {
-      if (remainingData > 0) {
-        // The chunk payload size has been decoded, now read the actual chunk 
payload
-        maxReadLen = Math.min(remainingData, currentLen);
-        realReadLen = originalStream.read(b, currentOff, maxReadLen);
-        if (realReadLen == -1) {
-          break;
-        }
-        currentOff += realReadLen;
-        currentLen -= realReadLen;
-        totalReadBytes += realReadLen;
-        remainingData -= realReadLen;
-        if (remainingData == 0) {
-          //read the "\r\n" at the end of the data section
-          originalStream.read();
-          originalStream.read();
-        }
-      } else {
-        remainingData = readContentLengthFromHeader();
-        if (remainingData == 0) {
-          // there is always a final zero byte chunk so we can stop reading
-          // if we encounter this chunk
-          isFinalChunkEncountered = true;
-        }
-        if (isFinalChunkEncountered || remainingData == -1) {
-          break;
-        }
+    while (currentLen > 0 && !isFinalChunkEncountered && ensureChunkPayload()) 
{
+      // The chunk payload size has been decoded, now read the actual chunk 
payload
+      int realReadLen = originalStream.read(b, currentOff, 
Math.min(remainingData, currentLen));
+      if (realReadLen == -1) {
+        stopAtUnexpectedEof();
+        break;
       }
-    } while (currentLen > 0);
+      updateDigest(b, currentOff, realReadLen);
+      currentOff += realReadLen;
+      currentLen -= realReadLen;
+      totalReadBytes += realReadLen;
+      remainingData -= realReadLen;
+      endChunkIfComplete();
+    }
     return totalReadBytes > 0 ? totalReadBytes : -1;
   }
 
+  /**
+   * Make sure a chunk payload is available to read, reading the next chunk 
header if the previous chunk was
+   * fully consumed. Returns false at the end of the chunked stream: either 
the terminating zero-byte chunk
+   * (verified here, since it has an empty payload) or a body that ends 
without one.
+   */
+  private boolean ensureChunkPayload() throws IOException {
+    if (remainingData > 0) {
+      return true;
+    }
+    remainingData = readContentLengthFromHeader();
+    if (remainingData > 0) {
+      return true;
+    }
+    if (remainingData == 0) {
+      // final zero-byte chunk: verify it (empty payload) and stop reading
+      if (validator != null) {
+        readChunkTerminator();
+      }
+      validateChunk();
+      isFinalChunkEncountered = true;
+    } else {
+      stopAtUnexpectedEof();
+    }
+    return false;
+  }
+
+  /** Read the "\r\n" after the payload and verify the chunk, once its payload 
is fully consumed. */
+  private void endChunkIfComplete() throws IOException {
+    if (remainingData == 0) {
+      readChunkTerminator();
+      validateChunk();
+    }
+  }
+
+  /**
+   * A malformed chunked body is the client's error, so it must not surface as 
an InternalError.
+   * The message is specific; the S3 error code is the generic InvalidRequest 
(HTTP 400).
+   */
+  private OS3Exception invalidBody(String message) {
+    OS3Exception ex = S3ErrorTable.newError(S3ErrorTable.INVALID_REQUEST, 
keyPath);
+    ex.setErrorMessage(message);
+    return ex;
+  }
+
+  private void stopAtUnexpectedEof() throws IOException {
+    checkNotTruncated();
+    isFinalChunkEncountered = true;
+  }
+
+  /**
+   * Complete verification after the caller has consumed the decoded content 
length. This verifies the terminating
+   * zero-byte chunk and rejects a decoded length that ends in the middle of a 
chunk. No-op without a validator.
+   */
+  public void verifyComplete() throws IOException {
+    if (validator == null || isFinalChunkEncountered) {
+      return;
+    }
+    if (read() != -1) {
+      throw invalidBody("Decoded content length ended before the chunked 
stream");
+    }
+  }
+
+  /**
+   * A verified stream must end with the terminating 0-byte chunk. Reaching 
EOF before it means the
+   * body was truncated, so the payload read so far was never fully 
authenticated. Without a validator
+   * the stream keeps its lenient behavior.
+   */
+  private void checkNotTruncated() throws IOException {
+    if (validator != null) {
+      throw invalidBody("Chunked stream ended before the terminating 0-byte 
chunk");
+    }
+  }
+
+  private void readChunkTerminator() throws IOException {
+    int carriageReturn = originalStream.read();
+    int lineFeed = originalStream.read();
+    if (validator != null && (carriageReturn != '\r' || lineFeed != '\n')) {
+      throw invalidBody("Invalid chunk data terminator");
+    }
+  }
+
   private int readContentLengthFromHeader() throws IOException {
     int prev = -1;
     int curr = 0;
@@ -180,6 +281,9 @@ private int readContentLengthFromHeader() throws 
IOException {
       prev = curr;
       curr = next;
     }
+    if (!eol(prev, curr)) {
+      checkNotTruncated();
+    }
     // Example of a single chunk data:
     //  
10000;chunk-signature=b474d8862b1487a5145d686f57f013e54db672cee1c953b3010fb58501ef5aa2\r\n
     //  <65536-bytes>\r\n
@@ -191,12 +295,47 @@ private int readContentLengthFromHeader() throws 
IOException {
       return -1;
     }
 
-    //parse the data length.
-    Matcher matcher = signatureLinePattern.matcher(signatureLine);
-    if (matcher.matches()) {
-      return Integer.parseInt(matcher.group(1), 16);
-    } else {
-      throw new IOException("Invalid signature line: " + signatureLine);
+    //parse the data length and the chunk signature.
+    Matcher matcher = SIGNATURE_LINE_PATTERN.matcher(signatureLine);
+    if (!matcher.matches()) {
+      throw invalidBody("Invalid signature line: " + signatureLine);
+    }
+    chunkSignature = matcher.group(2);
+    return Integer.parseInt(matcher.group(1), 16);
+  }
+
+  private void updateDigest(byte b) {
+    if (chunkDigest != null) {
+      chunkDigest.update(b);
+    }
+  }
+
+  private void updateDigest(byte[] b, int off, int len) {
+    if (chunkDigest != null) {
+      chunkDigest.update(b, off, len);
+    }
+  }
+
+  /**
+   * Verify the signature of the chunk just read. {@link 
MessageDigest#digest()}
+   * also resets the digest for the next chunk. No-op without a validator.
+   */
+  private void validateChunk() {
+    if (validator != null) {
+      validator.validateChunk(chunkSignature,
+          
DatatypeConverter.printHexBinary(chunkDigest.digest()).toLowerCase(Locale.ROOT));
+    }
+  }
+
+  /**
+   * A dedicated instance rather than {@code 
EndpointBase.getSha256DigestInstance()}: that is a shared
+   * ThreadLocal, and {@link MultiDigestInputStream} may already be using it 
for the request's own SHA-256.
+   */
+  private static MessageDigest newSha256() {
+    try {
+      return MessageDigest.getInstance("SHA-256");
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException("SHA-256 not available", e);
     }
   }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
index 854dcf80e27..8315b337bb5 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java
@@ -42,6 +42,7 @@
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.RESERVED_USER_METADATA_KEY_PREFIX;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CONFIG_HEADER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_HMAC_SHA256_PAYLOAD;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_KEY_LENGTH_LIMIT;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_NUM_LIMIT;
@@ -58,6 +59,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
@@ -87,6 +89,7 @@
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.conf.StorageUnit;
 import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.OzoneSecurityUtil;
 import org.apache.hadoop.ozone.audit.AuditAction;
 import org.apache.hadoop.ozone.audit.AuditEventStatus;
 import org.apache.hadoop.ozone.audit.AuditLogger;
@@ -110,6 +113,7 @@
 import org.apache.hadoop.ozone.s3.exception.OS3Exception;
 import org.apache.hadoop.ozone.s3.exception.S3ErrorTable;
 import org.apache.hadoop.ozone.s3.metrics.S3GatewayMetrics;
+import org.apache.hadoop.ozone.s3.signature.ChunksValidator;
 import org.apache.hadoop.ozone.s3.signature.SignatureInfo;
 import org.apache.hadoop.ozone.s3.util.AuditUtils;
 import org.apache.hadoop.ozone.s3.util.S3Utils;
@@ -755,7 +759,7 @@ protected S3ChunkInputStreamInfo getS3ChunkInputStreamInfo(
       if (hasUnsignedPayload(amzContentSha256Header)) {
         chunkInputStream = new UnsignedChunksInputStream(body);
       } else {
-        chunkInputStream = new SignedChunksInputStream(body);
+        chunkInputStream = new SignedChunksInputStream(body, keyPath);
       }
       effectiveLength = Long.parseLong(amzDecodedLength);
     } else {
@@ -776,7 +780,57 @@ protected S3ChunkInputStreamInfo getS3ChunkInputStreamInfo(
     }
     MultiDigestInputStream multiDigestInputStream =
         new MultiDigestInputStream(chunkInputStream, digests);
-    return new S3ChunkInputStreamInfo(multiDigestInputStream, effectiveLength);
+    // Header auth only: for a presigned (query) request the payload hash is 
not part of the signed
+    // canonical request, so its seed signature cannot start the chunk 
signature chain.
+    boolean verifyChunkSignature = signatureInfo.isSignPayload()
+        && STREAMING_AWS4_HMAC_SHA256_PAYLOAD.equals(amzContentSha256Header);
+    return new S3ChunkInputStreamInfo(multiDigestInputStream, effectiveLength, 
verifyChunkSignature);
+  }
+
+  /**
+   * Enable chunk-signature verification on a signed multi-chunk payload, using
+   * the signing key OM derived (HDDS-15140). Called after the key is opened
+   * (when the derived key is available) and before the payload is read.
+   *
+   * <p>In secure mode OM always returns the derived key for a signed upload, 
so
+   * a missing key is treated as a server-side anomaly and the request is
+   * rejected rather than stored unverified. In non-secure mode there is no
+   * secret to verify against, so verification is skipped.
+   */
+  protected void attachChunkValidator(S3ChunkInputStreamInfo info, String 
keyPath, ByteBuffer derivedKey)
+      throws OS3Exception {
+    if (!info.isChunkSignatureVerificationRequired()) {
+      return;
+    }
+    SignedChunksInputStream signed = 
SignedChunksInputStream.unwrap(info.getMultiDigestInputStream());
+    if (signed == null) {
+      // Unreachable today: verification is only required for a payload that 
getS3ChunkInputStreamInfo
+      // wrapped in a SignedChunksInputStream. Fail rather than store the 
payload unverified.
+      throw internalError(keyPath,
+          "chunk-signature verification requested but the body is not a signed 
chunked stream");
+    }
+    if (derivedKey == null) {
+      if (OzoneSecurityUtil.isSecurityEnabled(getOzoneConfiguration())) {
+        throw internalError(keyPath,
+            "chunk-signature verification requested but no derived key was 
returned");
+      }
+      return;
+    }
+    ByteBuffer key = derivedKey.duplicate();
+    byte[] signingKey = new byte[key.remaining()];
+    key.get(signingKey);
+    signed.attachValidator(new ChunksValidator(signingKey,
+        signatureInfo.getDateTime(), signatureInfo.getCredentialScope(),
+        signatureInfo.getSignature(), keyPath));
+  }
+
+  private static OS3Exception internalError(String keyPath, String message) {
+    // OS3Exception logs itself from its constructor, before setErrorMessage 
can replace the generic
+    // enum text, so the specific reason has to be logged here to reach the 
operator.
+    LOG.error("Internal Error for {}: {}", keyPath, message);
+    OS3Exception ex = newError(S3ErrorTable.INTERNAL_ERROR, keyPath);
+    ex.setErrorMessage(message);
+    return ex;
   }
 
   public boolean isDatastreamEnabled() {
@@ -856,10 +910,13 @@ protected int getIOBufferSize(long fileLength) {
   protected static final class S3ChunkInputStreamInfo {
     private final MultiDigestInputStream multiDigestInputStream;
     private final long effectiveLength;
+    private final boolean chunkSignatureVerificationRequired;
 
-    S3ChunkInputStreamInfo(MultiDigestInputStream multiDigestInputStream, long 
effectiveLength) {
+    S3ChunkInputStreamInfo(MultiDigestInputStream multiDigestInputStream, long 
effectiveLength,
+        boolean chunkSignatureVerificationRequired) {
       this.multiDigestInputStream = multiDigestInputStream;
       this.effectiveLength = effectiveLength;
+      this.chunkSignatureVerificationRequired = 
chunkSignatureVerificationRequired;
     }
 
     public MultiDigestInputStream getMultiDigestInputStream() {
@@ -869,5 +926,9 @@ public MultiDigestInputStream getMultiDigestInputStream() {
     public long getEffectiveLength() {
       return effectiveLength;
     }
+
+    public boolean isChunkSignatureVerificationRequired() {
+      return chunkSignatureVerificationRequired;
+    }
   }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
index 21be929e211..d2b85bd8ba8 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java
@@ -271,6 +271,7 @@ Response handlePutRequest(ObjectRequestContext context, 
String keyPath, InputStr
           length, amzDecodedLength, keyPath);
       multiDigestInputStream = 
chunkInputStreamInfo.getMultiDigestInputStream();
       length = chunkInputStreamInfo.getEffectiveLength();
+      final boolean wantDerivedKey = 
chunkInputStreamInfo.isChunkSignatureVerificationRequired();
 
       Map<String, String> customMetadata =
           getCustomMetadataFromHeaders(getHeaders().getRequestHeaders());
@@ -285,7 +286,9 @@ Response handlePutRequest(ObjectRequestContext context, 
String keyPath, InputStr
         Pair<String, Long> keyWriteResult = ObjectEndpointStreaming
             .put(bucket, keyPath, length, replicationConfig, getChunkSize(),
                 customMetadata, tags, multiDigestInputStream, getHeaders(),
-                signatureInfo.isSignPayload(), perf, writeConditions);
+                signatureInfo.isSignPayload(), perf, writeConditions,
+                wantDerivedKey,
+                derivedKey -> attachChunkValidator(chunkInputStreamInfo, 
keyPath, derivedKey));
         md5Hash = keyWriteResult.getKey();
         putLength = keyWriteResult.getValue();
       } else {
@@ -295,11 +298,12 @@ customMetadata, tags, multiDigestInputStream, 
getHeaders(),
         try (S3ObjectWriteGuard output =
             new S3ObjectWriteGuard(openKeyForPut(
                 volume.getName(), bucketName, keyPath, expectedLength,
-                replicationConfig, customMetadata, tags, writeConditions),
+                replicationConfig, customMetadata, tags, writeConditions, 
wantDerivedKey),
                 expectedLength, keyPath)) {
           long metadataLatencyNs =
               getMetrics().updatePutKeyMetadataStats(startNanos);
           perf.appendMetaLatencyNanos(metadataLatencyNs);
+          output.onKeyOpened(derivedKey -> 
attachChunkValidator(chunkInputStreamInfo, keyPath, derivedKey));
           putLength = output.copyFrom(multiDigestInputStream, 
getIOBufferSize(expectedLength));
           md5Hash = DatatypeConverter.printHexBinary(
                   
multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest())
@@ -916,6 +920,7 @@ private Response createMultipartKey(OzoneVolume volume, 
OzoneBucket ozoneBucket,
           body, length, amzDecodedLength, key);
       multiDigestInputStream = 
chunkInputStreamInfo.getMultiDigestInputStream();
       length = chunkInputStreamInfo.getEffectiveLength();
+      final boolean wantDerivedKey = 
chunkInputStreamInfo.isChunkSignatureVerificationRequired();
 
       copyHeader = getHeaders().getHeaderString(COPY_SOURCE_HEADER);
       ReplicationConfig replicationConfig = getReplicationConfig(ozoneBucket);
@@ -931,7 +936,9 @@ private Response createMultipartKey(OzoneVolume volume, 
OzoneBucket ozoneBucket,
         perf.appendStreamMode();
         return ObjectEndpointStreaming
             .createMultipartKey(ozoneBucket, key, length, partNumber,
-                uploadID, getChunkSize(), multiDigestInputStream, perf, 
getHeaders());
+                uploadID, getChunkSize(), multiDigestInputStream, perf, 
getHeaders(),
+                wantDerivedKey,
+                derivedKey -> attachChunkValidator(chunkInputStreamInfo, key, 
derivedKey));
       }
       // OmMultipartCommitUploadPartInfo can only be gotten after the
       // OzoneOutputStream is closed, so we need to save the OzoneOutputStream
@@ -1019,11 +1026,12 @@ private Response createMultipartKey(OzoneVolume volume, 
OzoneBucket ozoneBucket,
       } else {
         long putLength;
         final long expectedLength = length;
-        OzoneOutputStream ozoneOutputStream = getClientProtocol()
-            .createMultipartKey(volume.getName(), bucketName, key, 
expectedLength, partNumber, uploadID);
+        OzoneOutputStream ozoneOutputStream = 
getClientProtocol().createMultipartKey(
+            volume.getName(), bucketName, key, expectedLength, partNumber, 
uploadID, wantDerivedKey);
         try (S3ObjectWriteGuard writeGuard = new 
S3ObjectWriteGuard(ozoneOutputStream, expectedLength, key)) {
           metadataLatencyNs =
               getMetrics().updatePutKeyMetadataStats(startNanos);
+          writeGuard.onKeyOpened(derivedKey -> 
attachChunkValidator(chunkInputStreamInfo, key, derivedKey));
           putLength = writeGuard.copyFrom(multiDigestInputStream, 
getIOBufferSize(expectedLength));
           byte[] digest = 
multiDigestInputStream.getMessageDigest(OzoneConsts.MD5_HASH).digest();
           String md5Hash = 
DatatypeConverter.printHexBinary(digest).toLowerCase();
@@ -1110,7 +1118,7 @@ srcKeyLen > getDatastreamMinLength()) {
       final long expectedLength = srcKeyLen;
       try (S3ObjectWriteGuard dest = new S3ObjectWriteGuard(openKeyForPut(
           volume.getName(), destBucket, destKey, expectedLength,
-          replication, metadata, tags, writeConditions), expectedLength, 
destKey)) {
+          replication, metadata, tags, writeConditions, false), 
expectedLength, destKey)) {
         long metadataLatencyNs =
             getMetrics().updateCopyKeyMetadataStats(startNanos);
         perf.appendMetaLatencyNanos(metadataLatencyNs);
@@ -1263,27 +1271,28 @@ private CopyObjectResponse copyObject(OzoneVolume 
volume,
 
   /**
    * Opens a key for put, applying conditional write logic based on
-   * If-None-Match and If-Match headers.
+   * If-None-Match and If-Match headers. Only signed multi-chunk uploads ask 
OM to piggyback
+   * the derived signing key (HDDS-15140); every other PUT passes false.
    */
   @SuppressWarnings("checkstyle:ParameterNumber")
   private OzoneOutputStream openKeyForPut(String volumeName, String 
bucketName, String keyPath, long length,
       ReplicationConfig replicationConfig, Map<String, String> customMetadata,
       Map<String, String> tags,
-      S3ConditionalRequest.WriteConditions writeConditions)
+      S3ConditionalRequest.WriteConditions writeConditions, boolean 
derivedKeyPiggyBacking)
       throws IOException {
     if (writeConditions.hasIfNoneMatch()) {
       return getClientProtocol().createKeyIfNotExists(
           volumeName, bucketName, keyPath, length, replicationConfig,
-          customMetadata, tags);
+          customMetadata, tags, derivedKeyPiggyBacking);
     } else if (writeConditions.hasIfMatch()) {
       return getClientProtocol().rewriteKeyIfMatch(
           volumeName, bucketName, keyPath, length,
           writeConditions.getExpectedETag(),
-          replicationConfig, customMetadata, tags);
+          replicationConfig, customMetadata, tags, derivedKeyPiggyBacking);
     } else {
       return getClientProtocol().createKey(
           volumeName, bucketName, keyPath, length, replicationConfig,
-          customMetadata, tags);
+          customMetadata, tags, derivedKeyPiggyBacking);
     }
   }
 
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java
index 55420a1668c..a8cd8713801 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpointStreaming.java
@@ -24,9 +24,11 @@
 import static org.apache.hadoop.ozone.s3.util.S3Utils.wrapInQuotes;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
 import java.security.DigestInputStream;
 import java.security.MessageDigest;
 import java.util.Map;
+import java.util.function.Consumer;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.Response;
 import javax.xml.bind.DatatypeConverter;
@@ -68,13 +70,15 @@ public static Pair<String, Long> put(
       Map<String, String> tags, MultiDigestInputStream body,
       HttpHeaders headers, boolean isSignedPayload,
       PerformanceStringBuilder perf,
-      S3ConditionalRequest.WriteConditions writeConditions)
+      S3ConditionalRequest.WriteConditions writeConditions,
+      boolean derivedKeyPiggyBacking, Consumer<ByteBuffer> onKeyOpened)
       throws IOException, OS3Exception {
 
     try {
       return putKeyWithStream(bucket, keyPath,
           length, chunkSize, replicationConfig, keyMetadata, tags, body,
-          headers, isSignedPayload, perf, writeConditions);
+          headers, isSignedPayload, perf, writeConditions,
+          derivedKeyPiggyBacking, onKeyOpened);
     } catch (IOException ex) {
       LOG.error("Exception occurred in PutObject", ex);
       if (ex instanceof OMException) {
@@ -110,7 +114,8 @@ public static Pair<String, Long> putKeyWithStream(
       HttpHeaders headers,
       boolean isSignedPayload,
       PerformanceStringBuilder perf,
-      S3ConditionalRequest.WriteConditions writeConditions)
+      S3ConditionalRequest.WriteConditions writeConditions,
+      boolean derivedKeyPiggyBacking, Consumer<ByteBuffer> onKeyOpened)
       throws IOException, OS3Exception {
     long startNanos = Time.monotonicNowNanos();
     final String amzContentSha256Header = validateSignatureHeader(headers, 
keyPath, isSignedPayload);
@@ -119,8 +124,9 @@ public static Pair<String, Long> putKeyWithStream(
     try (S3ObjectStreamingWriteGuard writeGuard =
         new S3ObjectStreamingWriteGuard(openStreamKeyForPut(bucket,
             keyPath, length, replicationConfig, keyMetadata, tags,
-            writeConditions), length, keyPath)) {
+            writeConditions, derivedKeyPiggyBacking), length, keyPath)) {
       long metadataLatencyNs = METRICS.updatePutKeyMetadataStats(startNanos);
+      writeGuard.onKeyOpened(onKeyOpened);
       writeLen = writeGuard.copyFrom(body, bufferSize);
       md5Hash = 
DatatypeConverter.printHexBinary(body.getMessageDigest(OzoneConsts.MD5_HASH).digest())
           .toLowerCase();
@@ -155,18 +161,19 @@ public static Pair<String, Long> putKeyWithStream(
   private static OzoneDataStreamOutput openStreamKeyForPut(OzoneBucket bucket,
       String keyPath, long length, ReplicationConfig replicationConfig,
       Map<String, String> keyMetadata, Map<String, String> tags,
-      S3ConditionalRequest.WriteConditions writeConditions) throws IOException 
{
+      S3ConditionalRequest.WriteConditions writeConditions,
+      boolean derivedKeyPiggyBacking) throws IOException {
+    // Only signed multi-chunk uploads ask OM to piggyback the derived key; 
every
+    // other stream PUT passes false.
     if (writeConditions.hasIfNoneMatch()) {
-      return bucket.createStreamKeyIfNotExists(keyPath, length,
-          replicationConfig, keyMetadata, tags);
+      return bucket.createStreamKeyIfNotExists(keyPath, length, 
replicationConfig, keyMetadata, tags,
+          derivedKeyPiggyBacking);
     }
     if (writeConditions.hasIfMatch()) {
-      return bucket.rewriteStreamKeyIfMatch(keyPath, length,
-          writeConditions.getExpectedETag(), replicationConfig, keyMetadata,
-          tags);
+      return bucket.rewriteStreamKeyIfMatch(keyPath, length, 
writeConditions.getExpectedETag(),
+          replicationConfig, keyMetadata, tags, derivedKeyPiggyBacking);
     }
-    return bucket.createStreamKey(keyPath, length, replicationConfig,
-        keyMetadata, tags);
+    return bucket.createStreamKey(keyPath, length, replicationConfig, 
keyMetadata, tags, derivedKeyPiggyBacking);
   }
 
   @SuppressWarnings("checkstyle:ParameterNumber")
@@ -185,7 +192,7 @@ public static long copyKeyWithStream(
     try (S3ObjectStreamingWriteGuard writeGuard =
         new S3ObjectStreamingWriteGuard(openStreamKeyForPut(bucket,
             keyPath, length, replicationConfig, keyMetadata, tags,
-            writeConditions), length, keyPath)) {
+            writeConditions, false), length, keyPath)) {
       long metadataLatencyNs =
           METRICS.updateCopyKeyMetadataStats(startNanos);
       writeLen = writeGuard.copyFrom(body, bufferSize);
@@ -200,16 +207,18 @@ public static long copyKeyWithStream(
   @SuppressWarnings("checkstyle:ParameterNumber")
   public static Response createMultipartKey(OzoneBucket ozoneBucket, String 
key,
       long length, int partNumber, String uploadID, int chunkSize,
-      MultiDigestInputStream body, PerformanceStringBuilder perf, HttpHeaders 
headers)
+      MultiDigestInputStream body, PerformanceStringBuilder perf, HttpHeaders 
headers,
+      boolean derivedKeyPiggyBacking, Consumer<ByteBuffer> onKeyOpened)
       throws IOException, OS3Exception {
     long startNanos = Time.monotonicNowNanos();
     String eTag;
     try {
-      try (S3ObjectStreamingWriteGuard writeGuard =
-          new S3ObjectStreamingWriteGuard(ozoneBucket
-              .createMultipartStreamKey(key, length, partNumber, uploadID),
-              length, key)) {
+      // Only signed multi-chunk uploads ask OM to piggyback the derived key.
+      try (S3ObjectStreamingWriteGuard writeGuard = new 
S3ObjectStreamingWriteGuard(
+          ozoneBucket.createMultipartStreamKey(key, length, partNumber, 
uploadID, derivedKeyPiggyBacking),
+          length, key)) {
         long metadataLatencyNs = METRICS.updatePutKeyMetadataStats(startNanos);
+        writeGuard.onKeyOpened(onKeyOpened);
         long putLength = writeGuard.copyFrom(body, chunkSize);
         eTag = DatatypeConverter.printHexBinary(
             
body.getMessageDigest(OzoneConsts.MD5_HASH).digest()).toLowerCase();
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectStreamingWriteGuard.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectStreamingWriteGuard.java
index 31e862b9796..a9c92ec23c1 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectStreamingWriteGuard.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectStreamingWriteGuard.java
@@ -48,6 +48,11 @@ public Map<String, String> getMetadata() {
     return ((KeyMetadataAware) outputStream).getMetadata();
   }
 
+  @Override
+  public ByteBuffer getDerivedKey() {
+    return outputStream.getDerivedKey();
+  }
+
   @Override
   public void close() throws IOException {
     outputStream.close();
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectWriteGuard.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectWriteGuard.java
index ad3f9c2a484..b4974798770 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectWriteGuard.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/S3ObjectWriteGuard.java
@@ -20,10 +20,13 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 import org.apache.hadoop.ozone.client.io.OzoneOutputStream;
+import org.apache.hadoop.ozone.s3.SignedChunksInputStream;
 import org.apache.ratis.util.function.CheckedRunnable;
 
 /**
@@ -84,6 +87,15 @@ void addPreCommit(CheckedRunnable<IOException> preCommit) {
     preCommits.add(preCommit);
   }
 
+  void onKeyOpened(Consumer<ByteBuffer> action) {
+    try {
+      action.accept(getDerivedKey());
+    } catch (RuntimeException ex) {
+      recordTransferFailure(ex);
+      throw ex;
+    }
+  }
+
   long copyFrom(InputStream body, int bufferSize) throws IOException {
     byte[] buffer = new byte[bufferSize];
     while (writtenLength < expectedLength) {
@@ -107,9 +119,22 @@ long copyFrom(InputStream body, int bufferSize) throws 
IOException {
       }
       writtenLength += readLength;
     }
+    try {
+      verifySignedChunksComplete(body);
+    } catch (IOException | RuntimeException ex) {
+      recordTransferFailure(ex);
+      throw ex;
+    }
     return writtenLength;
   }
 
+  private static void verifySignedChunksComplete(InputStream body) throws 
IOException {
+    SignedChunksInputStream signed = SignedChunksInputStream.unwrap(body);
+    if (signed != null) {
+      signed.verifyComplete();
+    }
+  }
+
   protected void write(byte[] buffer, int offset, int length)
       throws IOException {
     outputStream.write(buffer, offset, length);
@@ -119,6 +144,11 @@ public Map<String, String> getMetadata() {
     return ((OzoneOutputStream) outputStream).getMetadata();
   }
 
+  /** @return the signing key OM derived for chunk verification, or null. */
+  public ByteBuffer getDerivedKey() {
+    return ((OzoneOutputStream) outputStream).getDerivedKey();
+  }
+
   @Override
   public void close() throws IOException {
     outputStream.close();
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java
index d59aa36db0e..c2768de56ed 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/S3ErrorTable.java
@@ -110,6 +110,11 @@ public enum S3ErrorTable {
       "AccessDenied", "User doesn't have the right to access this " +
       "resource.", HTTP_FORBIDDEN),
 
+  SIGNATURE_DOES_NOT_MATCH(
+      "SignatureDoesNotMatch", "The request signature we calculated does not " 
+
+      "match the signature you provided. Check your key and signing method.",
+      HTTP_FORBIDDEN),
+
   PRECOND_FAILED(
       "PreconditionFailed", "At least one of the pre-conditions you " +
       "specified did not hold", HTTP_PRECON_FAILED),
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/ChunksValidator.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/ChunksValidator.java
new file mode 100644
index 00000000000..70e731fd42b
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/signature/ChunksValidator.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.signature;
+
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.SIGNATURE_DOES_NOT_MATCH;
+import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.xml.bind.DatatypeConverter;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+
+/**
+ * Verifies the per-chunk signatures of a SigV4 chunked upload
+ * ({@code STREAMING-AWS4-HMAC-SHA256-PAYLOAD}).
+ * <p>
+ * Each chunk signature is {@code hex(HMAC-SHA256(signingKey, stringToSign))},
+ * where the string-to-sign is:
+ * <pre>
+ * AWS4-HMAC-SHA256-PAYLOAD\n
+ * &lt;date-time&gt;\n
+ * &lt;credential-scope&gt;\n
+ * &lt;previous-signature&gt;\n
+ * &lt;SHA-256("")&gt;\n
+ * &lt;SHA-256(chunk-payload)&gt;
+ * </pre>
+ * The signatures are chained: the first chunk uses the request (seed) 
signature
+ * as the previous signature, and each subsequent chunk uses the previous
+ * chunk's computed signature. The signing key is the SigV4 signing key derived
+ * from the caller's secret; it is provided by the caller so that the S3 
Gateway
+ * does not have to handle the secret directly (see HDDS-15140).
+ *
+ * @see <a 
href="https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html";>
+ *     Signature Calculation: Transfer Payload in Multiple Chunks</a>
+ */
+public class ChunksValidator {
+
+  private static final String CHUNK_STRING_TO_SIGN_ALGORITHM =
+      "AWS4-HMAC-SHA256-PAYLOAD";
+  private static final String HMAC_SHA256 = "HmacSHA256";
+  private static final String NEWLINE = "\n";
+
+  /** SHA-256 hex of the empty string (the hashed empty headers slot). */
+  private static final String EMPTY_STRING_SHA256 =
+      "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+
+  private static final ThreadLocal<Mac> HMAC = ThreadLocal.withInitial(() -> {
+    try {
+      return Mac.getInstance(HMAC_SHA256);
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException(HMAC_SHA256 + " not available", e);
+    }
+  });
+
+  /** The signing key is fixed for the request, so the key spec is built once 
instead of per chunk. */
+  private final SecretKeySpec signingKey;
+  private final String dateTime;
+  private final String credentialScope;
+  private final String resource;
+  private String previousSignature;
+
+  public ChunksValidator(byte[] signingKey, String dateTime,
+      String credentialScope, String seedSignature, String resource) {
+    this.signingKey = new SecretKeySpec(signingKey, HMAC_SHA256);
+    this.dateTime = dateTime;
+    this.credentialScope = credentialScope;
+    this.previousSignature = seedSignature;
+    this.resource = resource;
+  }
+
+  /**
+   * Verify one chunk and advance the signature chain.
+   *
+   * @param chunkSignature the signature parsed from the chunk header line. 
The chunk header pattern in
+   *        {@link org.apache.hadoop.ozone.s3.SignedChunksInputStream} 
guarantees 64 hex characters.
+   * @param payloadSha256Hex hex SHA-256 of the chunk payload
+   * @throws OS3Exception if the computed signature does not match
+   */
+  public void validateChunk(String chunkSignature, String payloadSha256Hex)
+      throws OS3Exception {
+    String stringToSign = String.join(NEWLINE,
+        CHUNK_STRING_TO_SIGN_ALGORITHM, dateTime, credentialScope,
+        previousSignature, EMPTY_STRING_SHA256, payloadSha256Hex);
+    byte[] expected = hmacSha256(stringToSign);
+    // Constant-time comparison to avoid leaking the signature via timing. 
Decoding the hex also
+    // makes the comparison case-insensitive, as the signature may be sent in 
either case.
+    if (!MessageDigest.isEqual(expected, 
DatatypeConverter.parseHexBinary(chunkSignature))) {
+      throw newError(SIGNATURE_DOES_NOT_MATCH, resource);
+    }
+    // The chain feeds this chunk's signature, in hex, into the next chunk's 
string-to-sign.
+    // chunkSignature equals expected (verified above), so reuse it normalized 
to lower-case.
+    previousSignature = chunkSignature.toLowerCase(Locale.ROOT);
+  }
+
+  private byte[] hmacSha256(String msg) {
+    try {
+      Mac mac = HMAC.get();
+      mac.init(signingKey);
+      return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
+    } catch (InvalidKeyException e) {
+      throw new IllegalStateException("Failed to compute " + HMAC_SHA256, e);
+    }
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
index a59aed4c7fd..715c7e62967 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
@@ -265,6 +265,15 @@ public OzoneOutputStream createKey(String volumeName, 
String bucketName,
         .createKey(keyName, size, replicationConfig, metadata, tags);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream createKey(String volumeName, String bucketName, 
String keyName, long size,
+      ReplicationConfig replicationConfig, Map<String, String> metadata, 
Map<String, String> tags,
+      boolean derivedKeyPiggyBacking) throws IOException {
+    return getBucket(volumeName, bucketName)
+        .createKey(keyName, size, replicationConfig, metadata, tags, 
derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneOutputStream rewriteKey(String volumeName, String bucketName, 
String keyName,
       long size, long existingKeyGeneration, ReplicationConfig 
replicationConfig,
@@ -282,6 +291,15 @@ public OzoneOutputStream createKeyIfNotExists(String 
volumeName,
         .createKeyIfNotExists(keyName, size, replicationConfig, metadata, 
tags);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream createKeyIfNotExists(String volumeName, String 
bucketName, String keyName, long size,
+      ReplicationConfig replicationConfig, Map<String, String> metadata, 
Map<String, String> tags,
+      boolean derivedKeyPiggyBacking) throws IOException {
+    return getBucket(volumeName, bucketName)
+        .createKeyIfNotExists(keyName, size, replicationConfig, metadata, 
tags, derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneOutputStream rewriteKeyIfMatch(String volumeName,
       String bucketName, String keyName, long size, String expectedETag,
@@ -292,6 +310,15 @@ public OzoneOutputStream rewriteKeyIfMatch(String 
volumeName,
             metadata, tags);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream rewriteKeyIfMatch(String volumeName, String 
bucketName, String keyName, long size,
+      String expectedETag, ReplicationConfig replicationConfig, Map<String, 
String> metadata,
+      Map<String, String> tags, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return getBucket(volumeName, bucketName).rewriteKeyIfMatch(keyName, size, 
expectedETag, replicationConfig,
+        metadata, tags, derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneDataStreamOutput createStreamKeyIfNotExists(String volumeName,
       String bucketName, String keyName, long size,
@@ -432,6 +459,14 @@ public OzoneOutputStream createMultipartKey(String 
volumeName,
         partNumber, uploadID);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream createMultipartKey(String volumeName, String 
bucketName, String keyName, long size,
+      int partNumber, String uploadID, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return getBucket(volumeName, bucketName)
+        .createMultipartKey(keyName, size, partNumber, uploadID, 
derivedKeyPiggyBacking);
+  }
+
   @Override
   public OmMultipartUploadCompleteInfo completeMultipartUpload(
       String volumeName, String bucketName, String keyName, String uploadID,
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java
index 5c8c7e0b448..d794eb0a528 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/OzoneBucketStub.java
@@ -89,6 +89,7 @@ public final class OzoneBucketStub extends OzoneBucket {
   private ArrayList<OzoneAcl> aclList = new ArrayList<>();
   private ReplicationConfig replicationConfig;
   private Map<String, OzoneLifecycleConfiguration> lifecyclesMap = new 
HashMap<>();
+  private byte[] derivedKey;
 
   public static Builder newBuilder() {
     return new Builder();
@@ -117,6 +118,24 @@ boolean isEmpty() {
     return keyDetails.isEmpty();
   }
 
+  public void setDerivedKey(byte[] key) {
+    derivedKey = key == null ? null : key.clone();
+  }
+
+  private OzoneOutputStream addDerivedKey(OzoneOutputStream output, boolean 
requested) {
+    if (requested && derivedKey != null) {
+      output.setDerivedKey(ByteBuffer.wrap(derivedKey.clone()));
+    }
+    return output;
+  }
+
+  private OzoneDataStreamOutput addDerivedKey(OzoneDataStreamOutput output, 
boolean requested) {
+    if (requested && derivedKey != null) {
+      output.setDerivedKey(ByteBuffer.wrap(derivedKey.clone()));
+    }
+    return output;
+  }
+
   @Override
   public OzoneOutputStream createKey(String key, long size) throws IOException 
{
     return createKey(key, size,
@@ -173,6 +192,13 @@ public void close() throws IOException {
     return new OzoneOutputStream(keyOutputStream, null);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream createKey(String key, long size, ReplicationConfig 
rConfig,
+      Map<String, String> metadata, Map<String, String> tags, boolean 
derivedKeyPiggyBacking) throws IOException {
+    return addDerivedKey(createKey(key, size, rConfig, metadata, tags), 
derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneOutputStream rewriteKey(String keyName, long size, long 
existingKeyGeneration,
       ReplicationConfig rConfig, Map<String, String> metadata) throws 
IOException {
@@ -217,6 +243,13 @@ public OzoneOutputStream createKeyIfNotExists(String 
keyName, long size,
     return createKey(keyName, size, rConfig, metadata, tags);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream createKeyIfNotExists(String keyName, long size, 
ReplicationConfig rConfig,
+      Map<String, String> metadata, Map<String, String> tags, boolean 
derivedKeyPiggyBacking) throws IOException {
+    return addDerivedKey(createKeyIfNotExists(keyName, size, rConfig, 
metadata, tags), derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneOutputStream rewriteKeyIfMatch(String keyName, long size,
       String expectedETag, ReplicationConfig rConfig,
@@ -238,6 +271,15 @@ public OzoneOutputStream rewriteKeyIfMatch(String keyName, 
long size,
     return createKey(keyName, size, rConfig, metadata, tags);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneOutputStream rewriteKeyIfMatch(String keyName, long size, String 
expectedETag,
+      ReplicationConfig rConfig, Map<String, String> metadata, Map<String, 
String> tags,
+      boolean derivedKeyPiggyBacking) throws IOException {
+    return addDerivedKey(rewriteKeyIfMatch(keyName, size, expectedETag, 
rConfig, metadata, tags),
+        derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneDataStreamOutput createStreamKey(String key, long size,
                                                ReplicationConfig rConfig,
@@ -293,6 +335,37 @@ public void flush() throws IOException {
     return new OzoneDataStreamOutputStub(byteBufferStreamOutput, key + size);
   }
 
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneDataStreamOutput createStreamKey(String key, long size,
+      ReplicationConfig rConfig, Map<String, String> keyMetadata,
+      Map<String, String> tags, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return addDerivedKey(createStreamKey(key, size, rConfig, keyMetadata, 
tags), derivedKeyPiggyBacking);
+  }
+
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneDataStreamOutput createStreamKeyIfNotExists(String key, long 
size,
+      ReplicationConfig rConfig, Map<String, String> keyMetadata,
+      Map<String, String> tags, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return addDerivedKey(createStreamKeyIfNotExists(key, size, rConfig, 
keyMetadata, tags), derivedKeyPiggyBacking);
+  }
+
+  @Override
+  @SuppressWarnings("checkstyle:ParameterNumber")
+  public OzoneDataStreamOutput rewriteStreamKeyIfMatch(String key, long size,
+      String expectedETag, ReplicationConfig rConfig, Map<String, String> 
keyMetadata,
+      Map<String, String> tags, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return addDerivedKey(rewriteStreamKeyIfMatch(key, size, expectedETag, 
rConfig, keyMetadata, tags),
+        derivedKeyPiggyBacking);
+  }
+
+  @Override
+  public OzoneDataStreamOutput createMultipartStreamKey(String key, long size,
+      int partNumber, String uploadID, boolean derivedKeyPiggyBacking) throws 
IOException {
+    return addDerivedKey(createMultipartStreamKey(key, size, partNumber, 
uploadID), derivedKeyPiggyBacking);
+  }
+
   @Override
   public OzoneDataStreamOutput createStreamKeyIfNotExists(String key, long 
size,
       ReplicationConfig rConfig, Map<String, String> keyMetadata,
@@ -639,6 +712,12 @@ public void close() throws IOException {
     }
   }
 
+  @Override
+  public OzoneOutputStream createMultipartKey(String key, long size, int 
partNumber, String uploadID,
+      boolean derivedKeyPiggyBacking) throws IOException {
+    return addDerivedKey(createMultipartKey(key, size, partNumber, uploadID), 
derivedKeyPiggyBacking);
+  }
+
   @Override
   public OmMultipartUploadCompleteInfo completeMultipartUpload(String key,
       String uploadID, Map<Integer, String> partsMap) throws IOException {
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestSignedChunksInputStream.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestSignedChunksInputStream.java
index 87068409886..f48a1b2a7b0 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestSignedChunksInputStream.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestSignedChunksInputStream.java
@@ -18,19 +18,47 @@
 package org.apache.hadoop.ozone.s3;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.Arrays;
 import org.apache.commons.io.IOUtils;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.apache.hadoop.ozone.s3.exception.S3ErrorTable;
+import org.apache.hadoop.ozone.s3.signature.ChunksValidator;
+import org.apache.hadoop.ozone.s3.signature.SignatureTestUtils;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 /**
  * Test {@link SignedChunksInputStream}.
  */
 public class TestSignedChunksInputStream {
 
+  // Canonical AWS SigV4 streaming example: 66560 bytes of 'a' in chunks of
+  // 65536 + 1024 + 0, secret wJalr..., us-east-1/s3, date 20130524.
+  private static final String SECRET_KEY =
+      "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
+  private static final String DATE_TIME = "20130524T000000Z";
+  private static final String SCOPE = "20130524/us-east-1/s3/aws4_request";
+  private static final String SEED_SIGNATURE =
+      "4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9";
+  private static final String CHUNK1_SIGNATURE =
+      "ad80c730a21e5b8d04586a2213dd63b9a0e99e0e2307b0ade35a65485a288648";
+  private static final String CHUNK2_SIGNATURE =
+      "0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497";
+  private static final String KEY_PATH = "key1";
+  private static final String FINAL_CHUNK_SIGNATURE =
+      "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9";
+
+  /** Well-formed but meaningless signature, for the tests that only strip the 
chunk format. */
+  private static final String FAKE_SIGNATURE = repeat('0', 64);
+
   @Test
   void testEmptyFile() throws IOException {
     try (InputStream is = wrapContent("0;chunk-signature"
@@ -92,7 +120,7 @@ void testSingleChunkWithTrailer() throws IOException {
     try (InputStream is = wrapContent("0A;chunk-signature"
         + 
"=23abb2bd920ddeeaac78a63ed808bc59fa6e7d3ef0e356474b82cdc2f8c93c40\r\n"
         + "1234567890\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       assertEquals("1234567890", IOUtils.toString(is, UTF_8));
@@ -102,7 +130,7 @@ void testSingleChunkWithTrailer() throws IOException {
     try (InputStream is = wrapContent("0A;chunk-signature"
         + 
"=23abb2bd920ddeeaac78a63ed808bc59fa6e7d3ef0e356474b82cdc2f8c93c40\r\n"
         + "1234567890\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       byte[] bytes = new byte[10];
@@ -114,7 +142,7 @@ void testSingleChunkWithTrailer() throws IOException {
     try (InputStream is = wrapContent("0A;chunk-signature"
         + 
"=23abb2bd920ddeeaac78a63ed808bc59fa6e7d3ef0e356474b82cdc2f8c93c40\r\n"
         + "1234567890\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       byte[] bytes = new byte[10];
@@ -154,32 +182,32 @@ void testSingleChunkWithoutEnd() throws IOException {
   @Test
   void testMultiChunks() throws IOException {
     //test simple read()
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n")) {
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n")) {
       String result = IOUtils.toString(is, UTF_8);
       assertEquals("1234567890abcde", result);
     }
 
     //test read(byte[],int,int)
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n")) {
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n")) {
       byte[] bytes = new byte[15];
       IOUtils.read(is, bytes, 0, 15);
       assertEquals("1234567890abcde", new String(bytes, UTF_8));
     }
 
     //test read(byte[],int,int) with length parameter larger than the payload
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n")) {
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n")) {
       byte[] bytes = new byte[20];
       int readLength = IOUtils.read(is, bytes, 0, 20);
       assertEquals(15, readLength);
@@ -190,11 +218,11 @@ void testMultiChunks() throws IOException {
   @Test
   void testMultiChunksWithTrailer() throws Exception {
     //test simple read()
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       String result = IOUtils.toString(is, UTF_8);
@@ -202,11 +230,11 @@ void testMultiChunksWithTrailer() throws Exception {
     }
 
     //test read(byte[],int,int)
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       byte[] bytes = new byte[15];
@@ -215,11 +243,11 @@ void testMultiChunksWithTrailer() throws Exception {
     }
 
     //test read(byte[],int,int) with length parameter larger than the payload
-    try (InputStream is = wrapContent("0a;chunk-signature=signature\r\n"
+    try (InputStream is = wrapContent("0a;chunk-signature=" + FAKE_SIGNATURE + 
"\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n"
-        + "0;chunk-signature=signature\r\n"
+        + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "x-amz-checksum-crc32c:sOO8/Q==\r\n"
         + 
"x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n"))
 {
       byte[] bytes = new byte[20];
@@ -229,8 +257,117 @@ void testMultiChunksWithTrailer() throws Exception {
     }
   }
 
+  @Test
+  void attachValidatorEnablesVerification() throws IOException {
+    // The signing key is only known after the key is opened, so the validator
+    // is attached to an already-constructed stream (HDDS-15140/15141).
+    String content = signedChunkedBody('a');
+    try (SignedChunksInputStream is = new SignedChunksInputStream(
+        new ByteArrayInputStream(content.getBytes(UTF_8)), KEY_PATH)) {
+      is.attachValidator(newValidator());
+      assertEquals(repeat('a', 66560), IOUtils.toString(is, UTF_8));
+    }
+  }
+
+  @Test
+  void attachedValidatorRejectsTamperedChunkPayload() {
+    String content = signedChunkedBody('b');
+    SignedChunksInputStream is = new SignedChunksInputStream(
+        new ByteArrayInputStream(content.getBytes(UTF_8)), KEY_PATH);
+    is.attachValidator(newValidator());
+    assertSignatureMismatch(is);
+  }
+
+  @Test
+  void rejectsBodyMissingFinalZeroChunk() throws Exception {
+    String body = "10000;chunk-signature=" + CHUNK1_SIGNATURE + "\r\n"
+        + repeat('a', 65536) + "\r\n"
+        + "400;chunk-signature=" + CHUNK2_SIGNATURE + "\r\n"
+        + repeat('a', 1024) + "\r\n";
+    InputStream is = verifiedStream(body);
+    assertInvalidBody(is, "terminating 0-byte chunk");
+  }
+
+  @Test
+  void rejectsFinalChunkMissingDataTerminator() {
+    String body = signedChunkedBody('a');
+    body = body.substring(0, body.length() - 2);
+    InputStream is = verifiedStream(body);
+    assertInvalidBody(is, "Invalid chunk data terminator");
+  }
+
+  @Test
+  void rejectsBodyTruncatedMidPayload() throws Exception {
+    String body = "10000;chunk-signature=" + CHUNK1_SIGNATURE + "\r\n" + 
repeat('a', 100);
+    InputStream is = verifiedStream(body);
+    assertInvalidBody(is, "terminating 0-byte chunk");
+  }
+
+  @Test
+  void rejectsTamperedChunkSignatureHeader() throws IOException {
+    String tamperedSig = CHUNK1_SIGNATURE.substring(0, 1)
+        + (CHUNK1_SIGNATURE.charAt(1) == 'a' ? 'b' : 'a') + 
CHUNK1_SIGNATURE.substring(2);
+    String tamperedBody = signedChunkedBody('a').replace(CHUNK1_SIGNATURE, 
tamperedSig);
+    InputStream is = verifiedStream(tamperedBody);
+    assertSignatureMismatch(is);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "",                                                                  // 
missing
+      "not-hex-not-hex-not-hex-not-hex-not-hex-not-hex-not-hex-not-hex!",  // 
right length, not hex
+      "23abb2bd920ddeeaac78a63ed808bc59fa6e7d3ef0e356474b82cdc2f8c93c",    // 
too short
+      "23abb2bd920ddeeaac78a63ed808bc59fa6e7d3ef0e356474b82cdc2f8c93c4000" // 
too long
+  })
+  void rejectsChunkSignatureThatIsNotHmacSha256(String signature) {
+    InputStream is = wrapContent("0A;chunk-signature=" + signature + 
"\r\n1234567890\r\n");
+    assertInvalidBody(is, "Invalid signature line");
+  }
+
+  /** A chunk that fails verification must surface as SignatureDoesNotMatch 
(HTTP 403), not any other error. */
+  private static void assertSignatureMismatch(InputStream is) {
+    OS3Exception ex = assertThrows(OS3Exception.class, () -> 
IOUtils.toString(is, UTF_8));
+    assertEquals(S3ErrorTable.SIGNATURE_DOES_NOT_MATCH.getCode(), 
ex.getCode());
+    assertEquals(S3ErrorTable.SIGNATURE_DOES_NOT_MATCH.getHttpCode(), 
ex.getHttpCode());
+  }
+
+  /** A malformed body must surface as InvalidRequest (HTTP 400), not as an 
InternalError. */
+  private static void assertInvalidBody(InputStream is, String 
expectedMessage) {
+    OS3Exception ex = assertThrows(OS3Exception.class, () -> 
IOUtils.toString(is, UTF_8));
+    assertEquals(S3ErrorTable.INVALID_REQUEST.getCode(), ex.getCode());
+    assertEquals(S3ErrorTable.INVALID_REQUEST.getHttpCode(), ex.getHttpCode());
+    assertThat(ex.getErrorMessage()).contains(expectedMessage);
+  }
+
+  private static String signedChunkedBody(char payloadChar) {
+    return "10000;chunk-signature=" + CHUNK1_SIGNATURE + "\r\n"
+        + repeat(payloadChar, 65536) + "\r\n"
+        + "400;chunk-signature=" + CHUNK2_SIGNATURE + "\r\n"
+        + repeat(payloadChar, 1024) + "\r\n"
+        + "0;chunk-signature=" + FINAL_CHUNK_SIGNATURE + "\r\n\r\n";
+  }
+
+  private static ChunksValidator newValidator() {
+    return new ChunksValidator(
+        SignatureTestUtils.signingKey(SECRET_KEY, "20130524", "us-east-1", 
"s3"),
+        DATE_TIME, SCOPE, SEED_SIGNATURE, KEY_PATH);
+  }
+
+  private static SignedChunksInputStream verifiedStream(String body) {
+    SignedChunksInputStream stream =
+        new SignedChunksInputStream(new 
ByteArrayInputStream(body.getBytes(UTF_8)), KEY_PATH);
+    stream.attachValidator(newValidator());
+    return stream;
+  }
+
+  private static String repeat(char c, int count) {
+    char[] chars = new char[count];
+    Arrays.fill(chars, c);
+    return new String(chars);
+  }
+
   private InputStream wrapContent(String content) {
     return new SignedChunksInputStream(
-        new ByteArrayInputStream(content.getBytes(UTF_8)));
+        new ByteArrayInputStream(content.getBytes(UTF_8)), KEY_PATH);
   }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
index 3db0722bc31..9a51f4a3e06 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
@@ -27,12 +27,20 @@
 import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_REQUEST;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_TAG;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.NO_SUCH_BUCKET;
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.SIGNATURE_DOES_NOT_MATCH;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signatureInfo;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signedChunkedBody;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signingKey;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_COPY_DIRECTIVE_HEADER;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.DECODED_CONTENT_LENGTH_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_ECDSA_P256_SHA256_PAYLOAD;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_ECDSA_P256_SHA256_PAYLOAD_TRAILER;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_HMAC_SHA256_PAYLOAD;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_UNSIGNED_PAYLOAD_TRAILER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_DIRECTIVE_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_KEY_LENGTH_LIMIT;
@@ -42,6 +50,7 @@
 import static org.apache.hadoop.ozone.s3.util.S3Utils.parseETag;
 import static org.apache.hadoop.ozone.s3.util.S3Utils.urlEncode;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -57,6 +66,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableMap;
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
@@ -79,6 +89,7 @@
 import org.apache.hadoop.ozone.OzoneConsts;
 import org.apache.hadoop.ozone.client.BucketArgs;
 import org.apache.hadoop.ozone.client.OzoneBucket;
+import org.apache.hadoop.ozone.client.OzoneBucketStub;
 import org.apache.hadoop.ozone.client.OzoneClient;
 import org.apache.hadoop.ozone.client.OzoneKeyDetails;
 import org.apache.hadoop.ozone.client.OzoneVolume;
@@ -105,6 +116,8 @@ class TestObjectPut {
   private static final String DEST_BUCKET_NAME = "b2";
   private static final String DEST_KEY = "key=value/2";
   private static final String NONEXISTENT_BUCKET = "nonexist";
+  /** Well-formed but meaningless signature, for the path that only strips the 
chunk framing. */
+  private static final String FAKE_SIGNATURE = StringUtils.repeat('0', 64);
 
   private ObjectEndpoint objectEndpoint;
   private HttpHeaders headers;
@@ -126,7 +139,10 @@ static Stream<Arguments> argumentsForPutObject() {
   @BeforeEach
   void setup() throws IOException {
     headers = newMockHttpHeaders();
-    objectEndpoint = 
spy(EndpointBuilder.newObjectEndpointBuilder().setHeaders(headers).build());
+    objectEndpoint = spy(EndpointBuilder.newObjectEndpointBuilder()
+        .setHeaders(headers)
+        .setSignatureInfo(signatureInfo())
+        .build());
 
     // Create buckets
     OzoneClient clientStub = objectEndpoint.getClient();
@@ -241,26 +257,160 @@ public void testPutObjectWithTooManyTags() {
   }
 
   @Test
-  void testPutObjectWithSignedChunks() throws Exception {
-    //GIVEN
-    String chunkedContent = "0a;chunk-signature=signature\r\n"
+  void testPutObjectWithValidSignedChunks() throws Exception {
+    configureSignedChunks(CONTENT.length());
+
+    assertSucceeds(() -> putObject(signedChunkedBody(CONTENT)));
+
+    OzoneKeyDetails keyDetails = assertKeyContent(bucket, KEY_NAME, CONTENT);
+    assertNotNull(keyDetails.getMetadata());
+    assertThat(keyDetails.getMetadata().get(OzoneConsts.ETAG)).isNotEmpty();
+    assertThat(keyDetails.getDataSize()).isEqualTo(CONTENT.length());
+  }
+
+  @Test
+  void testPutObjectWithUnverifiedSignedChunks() throws Exception {
+    // Only STREAMING-AWS4-HMAC-SHA256-PAYLOAD opts into verification; the 
-TRAILER variant is
+    // HDDS-15142. Here the stream just strips the chunk framing, so the 
signatures are not checked
+    // and a body without the terminating zero-byte chunk is still accepted, 
as before this change.
+    String chunkedContent = "0a;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
+        + "05;chunk-signature=" + FAKE_SIGNATURE + "\r\n"
         + "abcde\r\n";
-
     when(headers.getHeaderString(X_AMZ_CONTENT_SHA256))
-        .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD);
-    when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER))
-        .thenReturn("15");
+        .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER);
+    
when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn("15");
 
-    //WHEN
     assertSucceeds(() -> putObject(chunkedContent));
 
-    //THEN
-    OzoneKeyDetails keyDetails = assertKeyContent(bucket, KEY_NAME, 
"1234567890abcde");
-    assertNotNull(keyDetails.getMetadata());
-    assertThat(keyDetails.getMetadata().get(OzoneConsts.ETAG)).isNotEmpty();
-    assertEquals(15, keyDetails.getDataSize());
+    assertKeyContent(bucket, KEY_NAME, "1234567890abcde");
+  }
+
+  @Test
+  void testPutObjectRejectsTamperedSignedChunk() {
+    configureSignedChunks(CONTENT.length());
+
+    assertErrorResponse(SIGNATURE_DOES_NOT_MATCH,
+        () -> putObject(signedChunkedBody(CONTENT).replace(CONTENT, 
"1123456789")));
+
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsTamperedFinalChunkSignature() {
+    configureSignedChunks(CONTENT.length());
+
+    assertErrorResponse(SIGNATURE_DOES_NOT_MATCH,
+        () -> putObject(tamperFinalSignature(signedChunkedBody(CONTENT))));
+
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsMissingFinalChunk() {
+    configureSignedChunks(CONTENT.length());
+
+    OS3Exception ex = assertErrorResponse(S3ErrorTable.INVALID_REQUEST,
+        () -> putObject(withoutFinalChunk(signedChunkedBody(CONTENT))));
+    assertThat(ex.getErrorMessage()).contains("terminating 0-byte chunk");
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectVerifiesEmptyFinalChunk() throws Exception {
+    configureSignedChunks(0);
+
+    assertSucceeds(() -> putObject(signedChunkedBody("")));
+
+    assertKeyContent(bucket, KEY_NAME, "");
+  }
+
+  @Test
+  void testPutObjectRejectsTamperedEmptyFinalChunkSignature() {
+    configureSignedChunks(0);
+
+    assertErrorResponse(SIGNATURE_DOES_NOT_MATCH,
+        () -> putObject(tamperFinalSignature(signedChunkedBody(""))));
+
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsMissingDerivedKeyInSecureMode() {
+    
objectEndpoint.getOzoneConfiguration().setBoolean(OzoneConfigKeys.OZONE_SECURITY_ENABLED_KEY,
 true);
+    configureSignedChunkHeaders(0);
+
+    assertErrorResponse(S3ErrorTable.INTERNAL_ERROR, () -> 
putObject(signedChunkedBody("")));
+
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsInvalidSignedChunkTerminator() {
+    configureSignedChunks(CONTENT.length());
+    String body = signedChunkedBody(CONTENT)
+        .replace(CONTENT + "\r\n", CONTENT + "\n\n");
+
+    OS3Exception ex = assertErrorResponse(S3ErrorTable.INVALID_REQUEST, () -> 
putObject(body));
+    assertThat(ex.getErrorMessage()).contains("Invalid chunk data terminator");
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsMissingFinalChunkTerminator() {
+    configureSignedChunks(CONTENT.length());
+    String completeBody = signedChunkedBody(CONTENT);
+    String body = completeBody.substring(0, completeBody.length() - 2);
+
+    OS3Exception ex = assertErrorResponse(S3ErrorTable.INVALID_REQUEST, () -> 
putObject(body));
+    assertThat(ex.getErrorMessage()).contains("Invalid chunk data terminator");
+    assertKeyWasNotCommitted();
+  }
+
+  @Test
+  void testPutObjectRejectsDecodedLengthEndingMidChunk() {
+    configureSignedChunks(CONTENT.length() - 1);
+
+    OS3Exception ex = assertErrorResponse(S3ErrorTable.INVALID_REQUEST,
+        () -> putObject(signedChunkedBody(CONTENT)));
+    assertThat(ex.getErrorMessage()).contains("Decoded content length ended 
before the chunked stream");
+    assertKeyWasNotCommitted();
+  }
+
+  static Stream<Arguments> chunkSignatureVerificationCases() {
+    return Stream.of(
+        Arguments.of(STREAMING_AWS4_HMAC_SHA256_PAYLOAD, true),
+        Arguments.of(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER, false),
+        Arguments.of(STREAMING_AWS4_ECDSA_P256_SHA256_PAYLOAD, false),
+        Arguments.of(STREAMING_AWS4_ECDSA_P256_SHA256_PAYLOAD_TRAILER, false),
+        Arguments.of(STREAMING_UNSIGNED_PAYLOAD_TRAILER, false));
+  }
+
+  @ParameterizedTest
+  @MethodSource("chunkSignatureVerificationCases")
+  void testChunkSignatureVerificationSelection(String algorithm, boolean 
expected) throws Exception {
+    when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(algorithm);
+
+    EndpointBase.S3ChunkInputStreamInfo info = 
objectEndpoint.getS3ChunkInputStreamInfo(
+        new ByteArrayInputStream(new byte[0]), 0, "0", KEY_NAME);
+
+    
assertThat(info.isChunkSignatureVerificationRequired()).isEqualTo(expected);
+  }
+
+  @Test
+  void testPresignedRequestDoesNotVerifyChunkSignatures() throws Exception {
+    // A presigned (query auth) request does not sign the payload hash, so its 
seed signature cannot
+    // start the chunk signature chain and the chunks must not be verified 
against it.
+    
when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD);
+    ObjectEndpoint presigned = EndpointBuilder.newObjectEndpointBuilder()
+        .setHeaders(headers)
+        .setSignatureInfo(signatureInfo(false))
+        .build();
+
+    EndpointBase.S3ChunkInputStreamInfo info = 
presigned.getS3ChunkInputStreamInfo(
+        new ByteArrayInputStream(new byte[0]), 0, "0", KEY_NAME);
+
+    assertThat(info.isChunkSignatureVerificationRequired()).isFalse();
   }
 
   @Test
@@ -825,6 +975,31 @@ private HttpHeaders newMockHttpHeaders() {
     return httpHeaders;
   }
 
+  private void configureSignedChunks(long decodedLength) {
+    byte[] signingKey = signingKey();
+    ((OzoneBucketStub) bucket).setDerivedKey(signingKey);
+    configureSignedChunkHeaders(decodedLength);
+  }
+
+  private void configureSignedChunkHeaders(long decodedLength) {
+    
when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD);
+    
when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn(String.valueOf(decodedLength));
+  }
+
+  private static String tamperFinalSignature(String body) {
+    int signatureOffset = body.lastIndexOf("chunk-signature=") + 
"chunk-signature=".length();
+    char replacement = body.charAt(signatureOffset) == '0' ? '1' : '0';
+    return body.substring(0, signatureOffset) + replacement + 
body.substring(signatureOffset + 1);
+  }
+
+  private static String withoutFinalChunk(String body) {
+    return body.substring(0, body.lastIndexOf("0;chunk-signature="));
+  }
+
+  private void assertKeyWasNotCommitted() {
+    assertThatThrownBy(() -> 
bucket.getKey(KEY_NAME)).isInstanceOf(IOException.class);
+  }
+
   @Test
   void testIfNoneMatchKeyDoesNotExistSuccess() throws Exception {
     when(headers.getHeaderString("If-None-Match")).thenReturn("*");
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPartUpload.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPartUpload.java
index 49adfa06511..a68a02f0c08 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPartUpload.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPartUpload.java
@@ -22,9 +22,14 @@
 import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertSucceeds;
 import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.initiateMultipartUpload;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signatureInfo;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signedChunkedBody;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signingKey;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.DECODED_CONTENT_LENGTH_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_HMAC_SHA256_PAYLOAD;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -51,6 +56,7 @@
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.ozone.OzoneConfigKeys;
 import org.apache.hadoop.ozone.OzoneConsts;
+import org.apache.hadoop.ozone.client.OzoneBucketStub;
 import org.apache.hadoop.ozone.client.OzoneClient;
 import org.apache.hadoop.ozone.client.OzoneClientStub;
 import org.apache.hadoop.ozone.client.OzoneMultipartUploadPartListParts;
@@ -101,6 +107,7 @@ public void setUp() throws Exception {
         .setHeaders(headers)
         .setClient(client)
         .setConfig(conf)
+        .setSignatureInfo(signatureInfo())
         .build());
     assertEquals(enableDataStream, rest.isDatastreamEnabled());
   }
@@ -167,20 +174,55 @@ public void testPartUploadStreamContentLength()
       throws IOException, OS3Exception {
     String keyName = UUID.randomUUID().toString();
 
-    int contentLength = 15;
-    String chunkedContent = "0a;chunk-signature=signature\r\n"
-        + "1234567890\r\n"
-        + "05;chunk-signature=signature\r\n"
-        + "abcde\r\n";
-    when(headers.getHeaderString(X_AMZ_CONTENT_SHA256))
-        .thenReturn("STREAMING-AWS4-HMAC-SHA256-PAYLOAD");
-    when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER))
-        .thenReturn(String.valueOf(contentLength));
+    String content = "1234567890abcde";
+    String chunkedContent = signedChunkedBody(content);
+    configureSignedChunks(content.length());
 
     String uploadID = initiateMultipartUpload(rest, OzoneConsts.S3_BUCKET, 
keyName);
 
     assertSucceeds(() -> put(rest, OzoneConsts.S3_BUCKET, keyName, 1, 
uploadID, chunkedContent));
-    assertContentLength(uploadID, keyName, contentLength);
+    assertContentLength(uploadID, keyName, content.length());
+  }
+
+  @Test
+  public void testPartUploadRejectsTamperedSignedChunk() throws Exception {
+    String keyName = UUID.randomUUID().toString();
+    String content = "1234567890abcde";
+    String chunkedContent = signedChunkedBody(content).replace(content, 
"1234567890abXde");
+    configureSignedChunks(content.length());
+
+    String uploadID = initiateMultipartUpload(rest, OzoneConsts.S3_BUCKET, 
keyName);
+
+    assertErrorResponse(S3ErrorTable.SIGNATURE_DOES_NOT_MATCH,
+        () -> put(rest, OzoneConsts.S3_BUCKET, keyName, 1, uploadID, 
chunkedContent));
+    assertNoParts(uploadID, keyName);
+  }
+
+  @Test
+  public void testPartUploadRejectsMissingDerivedKeyInSecureMode() throws 
Exception {
+    String keyName = UUID.randomUUID().toString();
+    configureSignedChunkHeaders(0);
+    
rest.getOzoneConfiguration().setBoolean(OzoneConfigKeys.OZONE_SECURITY_ENABLED_KEY,
 true);
+
+    String uploadID = initiateMultipartUpload(rest, OzoneConsts.S3_BUCKET, 
keyName);
+
+    assertErrorResponse(S3ErrorTable.INTERNAL_ERROR,
+        () -> put(rest, OzoneConsts.S3_BUCKET, keyName, 1, uploadID, 
signedChunkedBody("")));
+    assertNoParts(uploadID, keyName);
+  }
+
+  private void configureSignedChunks(int contentLength) throws IOException {
+    OzoneBucketStub bucket = (OzoneBucketStub) client.getObjectStore()
+        .getS3Bucket(OzoneConsts.S3_BUCKET);
+    bucket.setDerivedKey(signingKey());
+    configureSignedChunkHeaders(contentLength);
+  }
+
+  private void configureSignedChunkHeaders(int contentLength) {
+    when(headers.getHeaderString(X_AMZ_CONTENT_SHA256))
+        .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD);
+    when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER))
+        .thenReturn(String.valueOf(contentLength));
   }
 
   @Test
@@ -281,6 +323,13 @@ private void assertContentLength(String uploadID, String 
key,
         parts.getPartInfoList().get(0).getSize());
   }
 
+  private void assertNoParts(String uploadID, String key) throws IOException {
+    OzoneMultipartUploadPartListParts parts = client.getObjectStore()
+        .getS3Bucket(OzoneConsts.S3_BUCKET)
+        .listParts(key, uploadID, 0, 100);
+    assertThat(parts.getPartInfoList()).isEmpty();
+  }
+
   private static Response putPart(ObjectEndpoint subject, String bucket,
       String key, int partNumber, String uploadID, long contentLength,
       InputStream body) throws IOException, OS3Exception {
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java
index bc9e19db1b6..b6add6040fd 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestPermissionCheck.java
@@ -263,7 +263,7 @@ public void testPutKey() throws IOException {
     when(objectStore.getS3Volume()).thenReturn(volume);
     when(volume.getBucket("bucketName")).thenReturn(bucket);
     doThrow(exception).when(clientProtocol).createKey(
-            anyString(), anyString(), anyString(), anyLong(), any(), anyMap(), 
anyMap());
+            anyString(), anyString(), anyString(), anyLong(), any(), anyMap(), 
anyMap(), anyBoolean());
     ObjectEndpoint objectEndpoint = EndpointBuilder.newObjectEndpointBuilder()
         .setClient(client)
         .setHeaders(headers)
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ObjectWriteGuard.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ObjectWriteGuard.java
index bd0c6b96ad4..501fd5603f1 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ObjectWriteGuard.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestS3ObjectWriteGuard.java
@@ -120,6 +120,37 @@ void datastreamWriteIOExceptionBlocksCommitWithCause() {
     assertCommitBlockedBy(guard, failure);
   }
 
+  @Test
+  void keyOpenedFailureBlocksEmptyCommit() {
+    RuntimeException failure = new IllegalStateException("key-open callback 
failed");
+    S3ObjectWriteGuard guard = newGuard(0);
+
+    RuntimeException thrown = assertThrows(RuntimeException.class,
+        () -> guard.onKeyOpened(key -> {
+          throw failure;
+        }));
+
+    assertSame(failure, thrown);
+    assertCommitBlockedBy(guard, failure);
+  }
+
+  @Test
+  void datastreamKeyOpenedFailureBlocksEmptyCommit() {
+    RuntimeException failure = new IllegalStateException("key-open callback 
failed");
+    S3ObjectStreamingWriteGuard guard = new S3ObjectStreamingWriteGuard(
+        new OzoneDataStreamOutput(
+            new 
KeyMetadataAwareByteBufferStreamOutput(Collections.emptyMap()), null),
+        0, KEY_PATH);
+
+    RuntimeException thrown = assertThrows(RuntimeException.class,
+        () -> guard.onKeyOpened(key -> {
+          throw failure;
+        }));
+
+    assertSame(failure, thrown);
+    assertCommitBlockedBy(guard, failure);
+  }
+
   private static S3ObjectWriteGuard newGuard(long expectedLength) {
     KeyMetadataAwareOutputStream keyOutputStream =
         new KeyMetadataAwareOutputStream(Collections.emptyMap());
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestUploadWithStream.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestUploadWithStream.java
index ddbfeb2b866..27826401b7c 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestUploadWithStream.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestUploadWithStream.java
@@ -19,12 +19,20 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static 
org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_FS_DATASTREAM_AUTO_THRESHOLD;
+import static 
org.apache.hadoop.ozone.client.OzoneClientTestUtils.assertKeyContent;
 import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.FailingInputStream;
+import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse;
 import static 
org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertSucceeds;
 import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signatureInfo;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signedChunkedBody;
+import static 
org.apache.hadoop.ozone.s3.signature.SignatureTestUtils.signingKey;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.COPY_SOURCE_HEADER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.DECODED_CONTENT_LENGTH_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.STREAMING_AWS4_HMAC_SHA256_PAYLOAD;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -47,9 +55,11 @@
 import org.apache.hadoop.ozone.OzoneConsts;
 import org.apache.hadoop.ozone.audit.AuditLogger;
 import org.apache.hadoop.ozone.client.OzoneBucket;
+import org.apache.hadoop.ozone.client.OzoneBucketStub;
 import org.apache.hadoop.ozone.client.OzoneClient;
 import org.apache.hadoop.ozone.client.OzoneClientStub;
 import org.apache.hadoop.ozone.s3.MultiDigestInputStream;
+import org.apache.hadoop.ozone.s3.exception.S3ErrorTable;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -67,13 +77,14 @@ public class TestUploadWithStream {
   private ObjectEndpoint rest;
 
   private OzoneClient client;
+  private HttpHeaders headers;
 
   @BeforeEach
   public void setUp() throws Exception {
     client = new OzoneClientStub();
     client.getObjectStore().createS3Bucket(S3BUCKET);
 
-    HttpHeaders headers = mock(HttpHeaders.class);
+    headers = mock(HttpHeaders.class);
     
when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn("UNSIGNED-PAYLOAD");
     when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn("STANDARD");
 
@@ -87,6 +98,7 @@ public void setUp() throws Exception {
         .setClient(client)
         .setHeaders(headers)
         .setConfig(conf)
+        .setSignatureInfo(signatureInfo())
         .build();
   }
 
@@ -100,6 +112,26 @@ public void testUpload() throws Exception {
     assertSucceeds(() -> put(rest, S3BUCKET, S3KEY, 
S3_COPY_EXISTING_KEY_CONTENT));
   }
 
+  @Test
+  public void testUploadWithValidSignedChunks() throws Exception {
+    OzoneBucket bucket = 
configureSignedChunks(S3_COPY_EXISTING_KEY_CONTENT.length());
+
+    assertSucceeds(() -> put(rest, S3BUCKET, S3KEY, 
signedChunkedBody(S3_COPY_EXISTING_KEY_CONTENT)));
+
+    assertKeyContent(bucket, S3KEY, S3_COPY_EXISTING_KEY_CONTENT);
+  }
+
+  @Test
+  public void testUploadRejectsTamperedSignedChunk() throws Exception {
+    OzoneBucket bucket = 
configureSignedChunks(S3_COPY_EXISTING_KEY_CONTENT.length());
+    String tamperedBody = signedChunkedBody(S3_COPY_EXISTING_KEY_CONTENT)
+        .replace(S3_COPY_EXISTING_KEY_CONTENT, "X" + 
S3_COPY_EXISTING_KEY_CONTENT.substring(1));
+
+    assertErrorResponse(S3ErrorTable.SIGNATURE_DOES_NOT_MATCH,
+        () -> put(rest, S3BUCKET, S3KEY, tamperedBody));
+    assertThatThrownBy(() -> 
bucket.getKey(S3KEY)).isInstanceOf(IOException.class);
+  }
+
   @Test
   public void testUploadDoesNotCommitWhenBodyReadFails() throws Exception {
     OzoneBucket bucket = client.getObjectStore().getS3Bucket(S3BUCKET);
@@ -116,7 +148,8 @@ public void testUploadDoesNotCommitWhenBodyReadFails() 
throws Exception {
               new HashMap<>(), new HashMap<>(), body, rest.getHeaders(), true,
               new AuditLogger.PerformanceStringBuilder(),
               S3ConditionalRequest.parseWriteConditions(rest.getHeaders(),
-                  S3KEY)));
+                  S3KEY),
+              false, derivedKey -> { }));
       assertEquals("upload interrupted", ex.getMessage());
       assertThrows(IOException.class, () -> bucket.getKey(S3KEY));
     }
@@ -143,17 +176,25 @@ public void testUploadWithCopy() throws Exception {
     additionalHeaders
         .put(COPY_SOURCE_HEADER, S3BUCKET + "/" + S3_COPY_EXISTING_KEY);
 
-    HttpHeaders headers = mock(HttpHeaders.class);
-    when(headers.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn(
+    HttpHeaders copyHeaders = mock(HttpHeaders.class);
+    when(copyHeaders.getHeaderString(STORAGE_CLASS_HEADER)).thenReturn(
         "STANDARD");
 
     additionalHeaders
-        .forEach((k, v) -> when(headers.getHeaderString(k)).thenReturn(v));
-    rest.setHeaders(headers);
+        .forEach((k, v) -> when(copyHeaders.getHeaderString(k)).thenReturn(v));
+    rest.setHeaders(copyHeaders);
 
     assertSucceeds(() -> put(rest, S3BUCKET, S3KEY, null));
 
     final long newDataSize = bucket.getKey(S3KEY).getDataSize();
     assertEquals(dataSize, newDataSize);
   }
+
+  private OzoneBucket configureSignedChunks(int decodedLength) throws 
IOException {
+    OzoneBucket bucket = client.getObjectStore().getS3Bucket(S3BUCKET);
+    ((OzoneBucketStub) bucket).setDerivedKey(signingKey());
+    
when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD);
+    
when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn(String.valueOf(decodedLength));
+    return bucket;
+  }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/SignatureTestUtils.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/SignatureTestUtils.java
new file mode 100644
index 00000000000..3a276c3a3d1
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/SignatureTestUtils.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.signature;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.xml.bind.DatatypeConverter;
+
+/**
+ * Shared AWS Signature Version 4 helpers for tests.
+ */
+public final class SignatureTestUtils {
+
+  private static final String EMPTY_STRING_SHA256 =
+      "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+  private static final String DATE = "20260827";
+  private static final String DATE_TIME = DATE + "T010203Z";
+  private static final String CREDENTIAL_SCOPE = DATE + 
"/us-east-1/s3/aws4_request";
+  private static final String SEED_SIGNATURE =
+      "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+  private static final byte[] SIGNING_KEY = signingKey("secret", DATE, 
"us-east-1", "s3");
+
+  private SignatureTestUtils() {
+  }
+
+  /**
+   * Derive the SigV4 signing key: {@code HMAC(HMAC(HMAC(HMAC("AWS4"+secret,
+   * date), region), service), "aws4_request")}.
+   */
+  public static byte[] signingKey(String secretKey, String date, String region,
+      String service) {
+    byte[] key = hmac(("AWS4" + secretKey).getBytes(UTF_8), date);
+    key = hmac(key, region);
+    key = hmac(key, service);
+    return hmac(key, "aws4_request");
+  }
+
+  public static byte[] signingKey() {
+    return SIGNING_KEY.clone();
+  }
+
+  public static SignatureInfo signatureInfo() {
+    return signatureInfo(true);
+  }
+
+  /** @param signPayload false models a presigned (query auth) request, which 
does not sign the payload hash. */
+  public static SignatureInfo signatureInfo(boolean signPayload) {
+    return new SignatureInfo.Builder(SignatureInfo.Version.V4)
+        .setDate(DATE)
+        .setDateTime(DATE_TIME)
+        .setCredentialScope(CREDENTIAL_SCOPE)
+        .setSignature(SEED_SIGNATURE)
+        .setSignPayload(signPayload)
+        .build();
+  }
+
+  /** @return {@code HMAC-SHA256(key, msg)}. */
+  public static byte[] hmac(byte[] key, String msg) {
+    try {
+      Mac mac = Mac.getInstance("HmacSHA256");
+      mac.init(new SecretKeySpec(key, "HmacSHA256"));
+      return mac.doFinal(msg.getBytes(UTF_8));
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /** @return hex SHA-256 of {@code data[off, off+len)}. */
+  public static String sha256Hex(byte[] data, int off, int len) {
+    try {
+      MessageDigest digest = MessageDigest.getInstance("SHA-256");
+      digest.update(data, off, len);
+      return 
DatatypeConverter.printHexBinary(digest.digest()).toLowerCase(Locale.ROOT);
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /** Compute one SigV4 streaming chunk signature. */
+  public static String chunkSignature(byte[] signingKey, String dateTime, 
String credentialScope,
+      String previousSignature, byte[] payload) {
+    String stringToSign = String.join("\n", "AWS4-HMAC-SHA256-PAYLOAD", 
dateTime, credentialScope,
+        previousSignature, EMPTY_STRING_SHA256, sha256Hex(payload, 0, 
payload.length));
+    return DatatypeConverter.printHexBinary(hmac(signingKey, 
stringToSign)).toLowerCase(Locale.ROOT);
+  }
+
+  /** Build a one-data-chunk SigV4 streaming body, including the terminating 
zero-byte chunk. */
+  public static String signedChunkedBody(byte[] signingKey, String dateTime, 
String credentialScope,
+      String seedSignature, String content) {
+    byte[] payload = content.getBytes(UTF_8);
+    String previousSignature = seedSignature;
+    StringBuilder body = new StringBuilder();
+    if (payload.length > 0) {
+      previousSignature = chunkSignature(
+          signingKey, dateTime, credentialScope, seedSignature, payload);
+      body.append(Integer.toHexString(payload.length))
+          .append(";chunk-signature=").append(previousSignature).append("\r\n")
+          .append(content).append("\r\n");
+    }
+    String finalSignature = chunkSignature(
+        signingKey, dateTime, credentialScope, previousSignature, new byte[0]);
+    return 
body.append("0;chunk-signature=").append(finalSignature).append("\r\n\r\n").toString();
+  }
+
+  public static String signedChunkedBody(String content) {
+    return signedChunkedBody(SIGNING_KEY, DATE_TIME, CREDENTIAL_SCOPE, 
SEED_SIGNATURE, content);
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestChunksValidator.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestChunksValidator.java
new file mode 100644
index 00000000000..2b28c6f45a4
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestChunksValidator.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.signature;
+
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.SIGNATURE_DOES_NOT_MATCH;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+import java.util.Locale;
+import org.apache.hadoop.ozone.s3.exception.OS3Exception;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+/**
+ * Verifies {@link ChunksValidator} against the canonical AWS SigV4 streaming
+ * example (secret {@code wJalr...}, region us-east-1, service s3, date
+ * 20130524, a 66560-byte payload of 'a' in chunks of 65536 + 1024 + 0).
+ *
+ * @see <a 
href="https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html";>
+ *     Signature Calculation: Transfer Payload in Multiple Chunks</a>
+ */
+class TestChunksValidator {
+
+  private static final String SECRET_KEY =
+      "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
+  private static final String DATE_TIME = "20130524T000000Z";
+  private static final String SCOPE = "20130524/us-east-1/s3/aws4_request";
+  private static final String SEED_SIGNATURE =
+      "4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9";
+
+  private static final String KEY_PATH = "key1";
+  private static final String CHUNK1_SIGNATURE =
+      "ad80c730a21e5b8d04586a2213dd63b9a0e99e0e2307b0ade35a65485a288648";
+  private static final String CHUNK2_SIGNATURE =
+      "0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497";
+  private static final String FINAL_CHUNK_SIGNATURE =
+      "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9";
+
+  /** A chunk that fails verification must surface as SignatureDoesNotMatch 
(HTTP 403), not any other error. */
+  private static void assertSignatureMismatch(Executable call) {
+    OS3Exception ex = assertThrows(OS3Exception.class, call);
+    assertEquals(SIGNATURE_DOES_NOT_MATCH.getCode(), ex.getCode());
+    assertEquals(SIGNATURE_DOES_NOT_MATCH.getHttpCode(), ex.getHttpCode());
+  }
+
+  private ChunksValidator newValidator() {
+    return new ChunksValidator(
+        SignatureTestUtils.signingKey(SECRET_KEY, "20130524", "us-east-1", 
"s3"),
+        DATE_TIME, SCOPE, SEED_SIGNATURE, KEY_PATH);
+  }
+
+  @Test
+  void acceptsMatchingChunkSignatures() {
+    ChunksValidator validator = newValidator();
+
+    byte[] chunk1 = repeat('a', 65536);
+    byte[] chunk2 = repeat('a', 1024);
+    byte[] finalChunk = new byte[0];
+
+    assertDoesNotThrow(() -> validator.validateChunk(CHUNK1_SIGNATURE,
+        SignatureTestUtils.sha256Hex(chunk1, 0, chunk1.length)));
+    assertDoesNotThrow(() -> validator.validateChunk(CHUNK2_SIGNATURE,
+        SignatureTestUtils.sha256Hex(chunk2, 0, chunk2.length)));
+    assertDoesNotThrow(() -> validator.validateChunk(FINAL_CHUNK_SIGNATURE,
+        SignatureTestUtils.sha256Hex(finalChunk, 0, finalChunk.length)));
+  }
+
+  @Test
+  void acceptsUppercaseChunkSignature() {
+    byte[] chunk = repeat('a', 65536);
+
+    assertThatCode(() -> 
newValidator().validateChunk(CHUNK1_SIGNATURE.toUpperCase(Locale.ROOT),
+        SignatureTestUtils.sha256Hex(chunk, 0, 
chunk.length))).doesNotThrowAnyException();
+  }
+
+  @Test
+  void rejectsTamperedChunkSignature() {
+    ChunksValidator validator = newValidator();
+    byte[] chunk1 = repeat('a', 65536);
+
+    // Wrong signature for the first chunk.
+    assertSignatureMismatch(() -> validator.validateChunk(
+        CHUNK2_SIGNATURE, SignatureTestUtils.sha256Hex(chunk1, 0, 
chunk1.length)));
+  }
+
+  @Test
+  void rejectsTamperedChunkPayload() {
+    ChunksValidator validator = newValidator();
+    byte[] tampered = repeat('b', 65536);
+
+    // Correct signature but the payload was modified.
+    assertSignatureMismatch(() -> validator.validateChunk(
+        CHUNK1_SIGNATURE,
+        SignatureTestUtils.sha256Hex(tampered, 0, tampered.length)));
+  }
+
+  @Test
+  void interleavedValidatorsWithDifferentKeysDoNotCrossContaminate() {
+    // The Mac is a shared ThreadLocal re-init'd with each validator's key per 
call. Interleaving a
+    // wrong-key and a correct-key validator on the same thread must not leak 
the key between them.
+    ChunksValidator correct = newValidator();
+    ChunksValidator wrongKey = new ChunksValidator(
+        SignatureTestUtils.signingKey("wrong-secret", "20130524", "us-east-1", 
"s3"),
+        DATE_TIME, SCOPE, SEED_SIGNATURE, KEY_PATH);
+    String sha65536 = SignatureTestUtils.sha256Hex(repeat('a', 65536), 0, 
65536);
+    String sha1024 = SignatureTestUtils.sha256Hex(repeat('a', 1024), 0, 1024);
+
+    assertSignatureMismatch(() -> wrongKey.validateChunk(CHUNK1_SIGNATURE, 
sha65536));
+    // If the shared Mac were not re-keyed, this would still hold the wrong 
key and fail.
+    assertDoesNotThrow(() -> correct.validateChunk(CHUNK1_SIGNATURE, 
sha65536));
+    assertSignatureMismatch(() -> wrongKey.validateChunk(CHUNK1_SIGNATURE, 
sha65536));
+    assertDoesNotThrow(() -> correct.validateChunk(CHUNK2_SIGNATURE, sha1024));
+  }
+
+  private static byte[] repeat(char c, int count) {
+    byte[] bytes = new byte[count];
+    Arrays.fill(bytes, (byte) c);
+    return bytes;
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to