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
+ * <date-time>\n
+ * <credential-scope>\n
+ * <previous-signature>\n
+ * <SHA-256("")>\n
+ * <SHA-256(chunk-payload)>
+ * </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]