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 587cbec0516a..1f35aaaa16c5 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 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 f3c825db4e63..098111222d5e 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 @@ *

* *

- * 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. *

* * 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 854dcf80e270..8315b337bb50 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. + * + *

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 21be929e211f..d2b85bd8ba8d 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 customMetadata = getCustomMetadataFromHeaders(getHeaders().getRequestHeaders()); @@ -285,7 +286,9 @@ Response handlePutRequest(ObjectRequestContext context, String keyPath, InputStr Pair 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 customMetadata, Map 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 55420a1668ca..a8cd87138018 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 put( Map tags, MultiDigestInputStream body, HttpHeaders headers, boolean isSignedPayload, PerformanceStringBuilder perf, - S3ConditionalRequest.WriteConditions writeConditions) + S3ConditionalRequest.WriteConditions writeConditions, + boolean derivedKeyPiggyBacking, Consumer 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 putKeyWithStream( HttpHeaders headers, boolean isSignedPayload, PerformanceStringBuilder perf, - S3ConditionalRequest.WriteConditions writeConditions) + S3ConditionalRequest.WriteConditions writeConditions, + boolean derivedKeyPiggyBacking, Consumer onKeyOpened) throws IOException, OS3Exception { long startNanos = Time.monotonicNowNanos(); final String amzContentSha256Header = validateSignatureHeader(headers, keyPath, isSignedPayload); @@ -119,8 +124,9 @@ public static Pair 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 putKeyWithStream( private static OzoneDataStreamOutput openStreamKeyForPut(OzoneBucket bucket, String keyPath, long length, ReplicationConfig replicationConfig, Map keyMetadata, Map 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 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 31e862b9796e..a9c92ec23c1d 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 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 ad3f9c2a4845..b4974798770f 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 preCommit) { preCommits.add(preCommit); } + void onKeyOpened(Consumer 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 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 d59aa36db0ed..c2768de56edf 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 000000000000..70e731fd42ba --- /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}). + *

+ * Each chunk signature is {@code hex(HMAC-SHA256(signingKey, stringToSign))}, + * where the string-to-sign is: + *

+ * AWS4-HMAC-SHA256-PAYLOAD\n
+ * <date-time>\n
+ * <credential-scope>\n
+ * <previous-signature>\n
+ * <SHA-256("")>\n
+ * <SHA-256(chunk-payload)>
+ * 
+ * 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 + * Signature Calculation: Transfer Payload in Multiple Chunks + */ +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 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 122158262fa7..a670d4961701 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 @@ -254,6 +254,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 metadata, Map 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, @@ -271,6 +280,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 metadata, Map 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, @@ -281,6 +299,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 metadata, + Map 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, @@ -421,6 +448,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 09ae82564a5a..b04fc41237a6 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 @@ -88,6 +88,7 @@ public final class OzoneBucketStub extends OzoneBucket { private ArrayList aclList = new ArrayList<>(); private ReplicationConfig replicationConfig; private Map lifecyclesMap = new HashMap<>(); + private byte[] derivedKey; public static Builder newBuilder() { return new Builder(); @@ -116,6 +117,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, @@ -172,6 +191,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 metadata, Map 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 metadata) throws IOException { @@ -216,6 +242,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 metadata, Map 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, @@ -237,6 +270,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 metadata, Map 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, @@ -292,6 +334,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 keyMetadata, + Map 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 keyMetadata, + Map 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 keyMetadata, + Map 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 keyMetadata, @@ -579,6 +652,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 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 870684098863..f48a1b2a7b05 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 3db0722bc319..9a51f4a3e066 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 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 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 49adfa065117..a68a02f0c087 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 bc9e19db1b6d..b6add6040fd8 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 bd0c6b96ad49..501fd5603f1d 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 ddbfeb2b8669..27826401b7c7 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 000000000000..3a276c3a3d19 --- /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 000000000000..2b28c6f45a40 --- /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 + * Signature Calculation: Transfer Payload in Multiple Chunks + */ +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; + } +}