diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java index 1960e1fc3bdc..856e1c25814d 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java @@ -5535,9 +5535,21 @@ public void startQuotaRepair(List buckets) throws IOException { new QuotaRepairTask(this).repair(buckets); } - public byte[] getS3DerivedKey(String accessId, String signingKey) throws IOException { - String awsSecretKey = s3SecretManager.getSecretString(accessId); - return AWSV4AuthValidator.getSigningKey(awsSecretKey, signingKey); + /** Derives the signing key on the RPC thread after S3 authentication has succeeded. */ + public byte[] getS3DerivedKey(S3Authentication s3Auth) throws IOException { + final String awsSecretKey; + if (StringUtils.isNotEmpty(s3Auth.getSessionToken())) { + STSTokenIdentifier stsToken = getStsTokenIdentifier(); + if (stsToken == null || !s3Auth.getAccessId().equals(stsToken.getTempAccessKeyId()) + || StringUtils.isEmpty(stsToken.getSecretAccessKey())) { + throw new OMException("Missing authenticated STS credentials for signing key derivation", + OMException.ResultCodes.INVALID_TOKEN); + } + awsSecretKey = stsToken.getSecretAccessKey(); + } else { + awsSecretKey = getS3SecretManager().getSecretString(s3Auth.getAccessId()); + } + return AWSV4AuthValidator.getSigningKey(awsSecretKey, s3Auth.getStringToSign()); } @Override diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java index 71d49df7bfcd..24bd3116b7fd 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequest.java @@ -22,7 +22,6 @@ import static org.apache.hadoop.ozone.om.request.file.OMFileRequest.OMDirectoryResult.FILE_EXISTS_IN_GIVENPATH; import static org.apache.hadoop.ozone.util.MetricUtil.captureLatencyNs; -import com.google.protobuf.ByteString; import java.io.IOException; import java.nio.file.InvalidPathException; import java.nio.file.Paths; @@ -65,12 +64,9 @@ import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; -import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UserInfo; import org.apache.hadoop.ozone.request.validation.RequestProcessingPhase; -import org.apache.hadoop.ozone.security.OzoneTokenIdentifier; -import org.apache.hadoop.ozone.security.S3SecurityUtil; import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.util.Time; import org.slf4j.Logger; @@ -322,8 +318,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut checkBucketQuotaInBytes(omMetadataManager, bucketInfo, preAllocatedSpace); checkBucketQuotaInNamespace(bucketInfo, numMissingParents + 1L); - CreateKeyResponse.Builder builder = - getResponseBuilderWithDerivedKey(getOmRequest(), ozoneManager, createKeyRequest); + CreateKeyResponse.Builder builder = CreateKeyResponse.newBuilder(); perfMetrics.addCreateKeyQuotaCheckLatencyNs(Time.monotonicNowNanos() - quotaCheckStartTime); bucketInfo.incrUsedNamespace(numMissingParents); @@ -465,27 +460,4 @@ public static OMRequest blockCreateKeyWithBucketLayoutFromOldClient( } return req; } - - protected CreateKeyResponse.Builder getResponseBuilderWithDerivedKey( - OMRequest omRequest, OzoneManager ozoneManager, - CreateKeyRequest createKeyRequest) throws IOException { - CreateKeyResponse.Builder builder = CreateKeyResponse.newBuilder(); - if (omRequest.hasS3Authentication() && ozoneManager.isSecurityEnabled() - && createKeyRequest.hasDerivedKeyPiggyBacking() - && createKeyRequest.getDerivedKeyPiggyBacking() - ) { - OzoneTokenIdentifier s3Token = S3SecurityUtil.constructS3Token(omRequest); - if (!s3Token.getTokenType().equals(OMTokenProto.Type.S3AUTHINFO)) { - // Piggyback was requested but this token type cannot produce a derived key. - // S3 Gateway should only set this flag for S3AUTHINFO tokens. - LOG.warn("Derived key piggyback requested but token type is {}, " + - "not S3AUTHINFO. Derived key will not be returned.", - s3Token.getTokenType()); - return builder; - } - byte[] derivedKey = ozoneManager.getS3DerivedKey(s3Token.getAwsAccessId(), s3Token.getStrToSign()); - builder.setDerivedKey(ByteString.copyFrom(derivedKey)); - } - return builder; - } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequestWithFSO.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequestWithFSO.java index 3642f96c7fcf..d59b2256fdff 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequestWithFSO.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/key/OMKeyCreateRequestWithFSO.java @@ -184,8 +184,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut checkBucketQuotaInBytes(omMetadataManager, omBucketInfo, preAllocatedSpace); checkBucketQuotaInNamespace(omBucketInfo, numKeysCreated + 1L); - CreateKeyResponse.Builder createKeyResponseBuilder = - getResponseBuilderWithDerivedKey(getOmRequest(), ozoneManager, createKeyRequest); + CreateKeyResponse.Builder createKeyResponseBuilder = CreateKeyResponse.newBuilder(); perfMetrics.addCreateKeyQuotaCheckLatencyNs(Time.monotonicNowNanos() - quotaCheckStartTime); omBucketInfo.incrUsedNamespace(numKeysCreated); diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java index b5d46fcdb172..4693adbdcf88 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/protocolPB/OzoneManagerProtocolServerSideTranslatorPB.java @@ -24,6 +24,7 @@ import static org.apache.hadoop.ozone.util.MetricUtil.captureLatencyNs; import com.google.common.annotations.VisibleForTesting; +import com.google.protobuf.ByteString; import com.google.protobuf.RpcController; import com.google.protobuf.ServiceException; import java.io.IOException; @@ -176,6 +177,7 @@ public void logLargeResponseIfNeeded(OMResponse response) { private OMResponse internalProcessRequest(OMRequest request) throws ServiceException { boolean s3Auth = false; + ByteString derivedKey = null; try { if (request.hasS3Authentication()) { @@ -185,6 +187,13 @@ private OMResponse internalProcessRequest(OMRequest request) throws ServiceExcep // If request has S3Authentication, validate S3 credentials. // If current OM is leader and then proceed with the request. S3SecurityUtil.validateS3Credential(request, ozoneManager); + if (ozoneManager.isSecurityEnabled() && request.getCmdType() == OzoneManagerProtocolProtos.Type.CreateKey + && request.getCreateKeyRequest().getDerivedKeyPiggyBacking()) { + derivedKey = ByteString.copyFrom(ozoneManager.getS3DerivedKey(request.getS3Authentication())); + // Only the RPC response needs this key. Older followers must not derive it during apply either. + request = request.toBuilder().setCreateKeyRequest(request.getCreateKeyRequest().toBuilder() + .clearDerivedKeyPiggyBacking()).build(); + } } catch (IOException ex) { return createErrorResponse(request, ex); } @@ -201,13 +210,16 @@ private OMResponse internalProcessRequest(OMRequest request) throws ServiceExcep } // check retry cache - final OMResponse cached = omRatisServer.checkRetryCache(); - if (cached != null) { - return cached; + OMResponse response = omRatisServer.checkRetryCache(); + if (response == null) { + this.lastRequestToSubmit = request; + response = ozoneManager.getOmExecutionFlow().submit(request, true); } - - this.lastRequestToSubmit = request; - return ozoneManager.getOmExecutionFlow().submit(request, true); + if (derivedKey != null && response.getSuccess() && response.hasCreateKeyResponse()) { + return response.toBuilder().setCreateKeyResponse(response.getCreateKeyResponse().toBuilder() + .setDerivedKey(derivedKey)).build(); + } + return response; } finally { OzoneManager.setS3Auth(null); OzoneManager.setStsTokenIdentifier(null); diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyCreateRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyCreateRequest.java index f00276040dfa..c317ec622aa7 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyCreateRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/key/TestOMKeyCreateRequest.java @@ -1519,12 +1519,11 @@ protected OmKeyInfo checkCreatedPaths( } @Test - public void testCreateKeyWithS3DerivedKey() throws Exception { + public void testCreateKeyDoesNotDeriveSigningKeyDuringApply() throws Exception { when(ozoneManager.getOzoneLockProvider()).thenReturn( new OzoneLockProvider(true, true)); when(ozoneManager.isSecurityEnabled()).thenReturn(true); - byte[] expectedDerivedKey = new byte[] {9, 8, 7, 6}; - when(ozoneManager.getS3DerivedKey(anyString(), anyString())).thenReturn(expectedDerivedKey); + when(ozoneManager.getS3DerivedKey(any())).thenThrow(new IOException("Credentials unavailable during apply")); KeyArgs.Builder keyArgs = KeyArgs.newBuilder() .setVolumeName(volumeName) @@ -1568,8 +1567,8 @@ public void testCreateKeyWithS3DerivedKey() throws Exception { OzoneManagerProtocolProtos.CreateKeyResponse createKeyResponse = response.getOMResponse().getCreateKeyResponse(); assertNotNull(createKeyResponse); - assertTrue(createKeyResponse.hasDerivedKey()); - assertEquals(com.google.protobuf.ByteString.copyFrom(expectedDerivedKey), createKeyResponse.getDerivedKey()); + assertThat(createKeyResponse.hasDerivedKey()).isFalse(); + verify(ozoneManager, never()).getS3DerivedKey(any()); } @Test diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestS3DerivedKey.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestS3DerivedKey.java new file mode 100644 index 000000000000..25ed1585d1e8 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestS3DerivedKey.java @@ -0,0 +1,239 @@ +/* + * 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.om.response; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Instant; +import org.apache.commons.codec.digest.HmacUtils; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.utils.ProtocolMessageMetrics; +import org.apache.hadoop.hdds.utils.db.InMemoryTestTable; +import org.apache.hadoop.ozone.om.AWSV4AuthValidator; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.S3SecretManager; +import org.apache.hadoop.ozone.om.execution.OMExecutionFlow; +import org.apache.hadoop.ozone.om.ratis.OzoneManagerRatisServer; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.KeyArgs; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.ozone.protocolPB.OzoneManagerProtocolServerSideTranslatorPB; +import org.apache.hadoop.ozone.security.OzoneDelegationTokenSecretManager; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.SecretKeyTestClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +/** Tests derived-key responses after S3 authentication, outside Ratis apply. */ +class TestS3DerivedKey { + private static final String ACCESS_ID = "permanent-access-id"; + private static final String TEMP_ACCESS_ID = "temporary-access-id"; + private static final String SECRET = "permanent-secret"; + private static final String TEMP_SECRET = "temporary-secret"; + private static final String STRING_TO_SIGN = "AWS4-HMAC-SHA256\n20260912T010203Z\n" + + "20260912/us-east-1/s3/aws4_request\n" + + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + private final OzoneManager om = mock(OzoneManager.class, CALLS_REAL_METHODS); + private final S3SecretManager secrets = mock(S3SecretManager.class); + private final OMExecutionFlow execution = mock(OMExecutionFlow.class); + private final OzoneManagerRatisServer ratis = mock(OzoneManagerRatisServer.class); + private final SecretKeyTestClient secretKeyClient = new SecretKeyTestClient(); + private final InMemoryTestTable revocations = new InMemoryTestTable<>(); + private OzoneManagerProtocolServerSideTranslatorPB translator; + + @BeforeEach + void setup() throws Exception { + when(om.getConfiguration()).thenReturn(new OzoneConfiguration()); + when(om.isSecurityEnabled()).thenReturn(true); + when(om.getS3SecretManager()).thenReturn(secrets); + when(om.getSecretKeyClient()).thenReturn(secretKeyClient); + when(om.getDelegationTokenMgr()).thenReturn(mock(OzoneDelegationTokenSecretManager.class)); + when(om.getOmExecutionFlow()).thenReturn(execution); + OMMetadataManager metadata = mock(OMMetadataManager.class); + when(om.getMetadataManager()).thenReturn(metadata); + when(metadata.getS3RevokedStsTokenTable()).thenReturn(revocations); + when(secrets.hasS3Secret(ACCESS_ID)).thenReturn(true); + when(secrets.getSecretString(ACCESS_ID)).thenReturn(SECRET); + translator = new OzoneManagerProtocolServerSideTranslatorPB(om, ratis, mock(ProtocolMessageMetrics.class)); + when(execution.submit(any(), anyBoolean())).thenReturn(response(Status.OK)); + } + + @AfterEach + void cleanup() { + assertThat(OzoneManager.getS3Auth()).isNull(); + assertThat(OzoneManager.getStsTokenIdentifier()).isNull(); + } + + @ParameterizedTest + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void returnsDerivedKey(boolean sts, boolean cacheHit) throws Exception { + OMRequest request = request(sts, 3600); + OMResponse original = response(Status.OK); + when(ratis.checkRetryCache()).thenReturn(cacheHit ? original : null); + when(execution.submit(any(), anyBoolean())).thenReturn(original); + + OMResponse result = translator.processRequest(request); + + assertThat(result.getStatus()).isEqualTo(Status.OK); + assertThat(result.getCreateKeyResponse().getDerivedKey().toByteArray()) + .isEqualTo(AWSV4AuthValidator.getSigningKey(sts ? TEMP_SECRET : SECRET, STRING_TO_SIGN)); + assertThat(original.getCreateKeyResponse().hasDerivedKey()).isFalse(); + if (cacheHit) { + verify(execution, never()).submit(any(), anyBoolean()); + } else { + ArgumentCaptor submitted = ArgumentCaptor.forClass(OMRequest.class); + verify(execution).submit(submitted.capture(), anyBoolean()); + // The response-only hint must not make older followers resolve credentials during apply. + assertThat(submitted.getValue().getCreateKeyRequest().getDerivedKeyPiggyBacking()).isFalse(); + assertThat(request.getCreateKeyRequest().getDerivedKeyPiggyBacking()).isTrue(); + } + if (sts) { + verify(secrets, never()).getSecretString(anyString()); + } + } + + @Test + void doesNotAttachKeyToFailedResponse() throws Exception { + when(execution.submit(any(), anyBoolean())).thenReturn(response(Status.ACCESS_DENIED)); + + OMResponse result = translator.processRequest(request(true, 3600)); + + assertThat(result.getStatus()).isEqualTo(Status.ACCESS_DENIED); + assertThat(result.getCreateKeyResponse().hasDerivedKey()).isFalse(); + } + + @Test + void doesNotDeriveKeyUnlessRequested() throws Exception { + OMRequest request = request(true, 3600); + request = request.toBuilder().setCreateKeyRequest(request.getCreateKeyRequest().toBuilder() + .clearDerivedKeyPiggyBacking()).build(); + + OMResponse result = translator.processRequest(request); + + assertThat(result.getStatus()).isEqualTo(Status.OK); + assertThat(result.getCreateKeyResponse().hasDerivedKey()).isFalse(); + verify(secrets, never()).getSecretString(anyString()); + } + + @Test + void doesNotDeriveKeyInInsecureMode() throws Exception { + when(om.isSecurityEnabled()).thenReturn(false); + + OMResponse result = translator.processRequest(request(false, 3600)); + + assertThat(result.getStatus()).isEqualTo(Status.OK); + assertThat(result.getCreateKeyResponse().hasDerivedKey()).isFalse(); + verify(secrets, never()).getSecretString(anyString()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rejectsExpiredTokenBeforeSubmissionOrCacheLookup(boolean cacheHit) throws Exception { + when(ratis.checkRetryCache()).thenReturn(cacheHit ? response(Status.OK) : null); + + OMResponse result = translator.processRequest(request(true, -1)); + + assertThat(result.getStatus()).isEqualTo(Status.TOKEN_EXPIRED); + verify(execution, never()).submit(any(), anyBoolean()); + verify(ratis, never()).checkRetryCache(); + } + + @Test + void rejectsRevokedTokenBeforeSubmission() throws Exception { + revocations.put(ACCESS_ID, Instant.now().plusSeconds(60).toEpochMilli()); + + OMResponse result = translator.processRequest(request(true, 3600)); + + assertThat(result.getStatus()).isEqualTo(Status.REVOKED_TOKEN); + verify(execution, never()).submit(any(), anyBoolean()); + } + + @Test + void rejectsInvalidSignatureBeforeSubmission() throws Exception { + OMRequest request = request(true, 3600); + request = request.toBuilder().setS3Authentication(request.getS3Authentication().toBuilder() + .setSignature("invalid")).build(); + + OMResponse result = translator.processRequest(request); + + assertThat(result.getStatus()).isEqualTo(Status.INVALID_TOKEN); + verify(execution, never()).submit(any(), anyBoolean()); + } + + @Test + void handlesSecretLookupFailureBeforeSubmission() throws Exception { + when(secrets.getSecretString(ACCESS_ID)).thenThrow(new IOException("secret unavailable")); + + OMResponse result = translator.processRequest(request(false, 3600)); + + assertThat(result.getSuccess()).isFalse(); + assertThat(result.getCreateKeyResponse().hasDerivedKey()).isFalse(); + verify(execution, never()).submit(any(), anyBoolean()); + } + + private OMRequest request(boolean sts, int durationSeconds) throws Exception { + byte[] signingKey = AWSV4AuthValidator.getSigningKey(sts ? TEMP_SECRET : SECRET, STRING_TO_SIGN); + S3Authentication.Builder auth = S3Authentication.newBuilder() + .setAccessId(sts ? TEMP_ACCESS_ID : ACCESS_ID) + .setStringToSign(STRING_TO_SIGN) + .setSignature(new HmacUtils("HmacSHA256", signingKey).hmacHex(STRING_TO_SIGN)); + if (sts) { + auth.setSessionToken(new STSTokenSecretManager(secretKeyClient).createSTSTokenString( + STSTokenSecretManager.CreateSTSTokenParams.newBuilder() + .setTempAccessKeyId(TEMP_ACCESS_ID) + .setOriginalAccessKeyId(ACCESS_ID) + .setSecretAccessKey(TEMP_SECRET) + .setRoleArn("arn:aws:iam::123456789012:role/test") + .setCreationTime(Instant.now()) + .setDurationSeconds(durationSeconds) + .setSessionPolicy("") + .build())); + } + return OMRequest.newBuilder().setCmdType(Type.CreateKey).setClientId("client") + .setS3Authentication(auth) + .setCreateKeyRequest(CreateKeyRequest.newBuilder().setDerivedKeyPiggyBacking(true) + .setKeyArgs(KeyArgs.newBuilder().setVolumeName("volume").setBucketName("bucket").setKeyName("key"))) + .build(); + } + + private OMResponse response(Status status) { + return OMResponse.newBuilder().setCmdType(Type.CreateKey).setStatus(status) + .setSuccess(status == Status.OK).setCreateKeyResponse(CreateKeyResponse.newBuilder()).build(); + } +} 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 098111222d5e..690cee0a1bf1 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 @@ -28,6 +28,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.bind.DatatypeConverter; +import org.apache.commons.codec.digest.DigestUtils; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; import org.apache.hadoop.ozone.s3.signature.ChunksValidator; @@ -46,6 +47,7 @@ * 0;chunk-signature=b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9\r\n * x-amz-checksum-crc32c:sOO8/Q==\r\n * x-amz-trailer-signature:63bddb248ad2590c92712055f51b8e78ab024eead08276b24f010b0efd74843f\r\n + * \r\n * *

* For the first chunk 10000 will be read and decoded from base-16 representation to 65536, which is the size of @@ -85,6 +87,11 @@ public class SignedChunksInputStream extends InputStream { */ private static final Pattern SIGNATURE_LINE_PATTERN = Pattern.compile("([0-9A-Fa-f]+);chunk-signature=([0-9A-Fa-f]{64})"); + private static final Pattern TRAILER_SIGNATURE_PATTERN = + Pattern.compile("x-amz-trailer-signature:([0-9A-Fa-f]{64})", Pattern.CASE_INSENSITIVE); + private static final Pattern CHECKSUM_TRAILER_PATTERN = + Pattern.compile("x-amz-checksum-(crc32|crc32c|crc64nvme|sha1|sha256)"); + private static final int MAX_LINE_LENGTH = 8 * 1024; private final InputStream originalStream; @@ -100,6 +107,9 @@ public class SignedChunksInputStream extends InputStream { /** Signature parsed from the current chunk header line. */ private String chunkSignature; + /** Checksum header declared by x-amz-trailer, or null for a regular signed stream. */ + private final String trailerHeader; + /** * Size of the chunk payload. If zero, the signature line should be parsed to * retrieve the subsequent chunk payload size. @@ -113,8 +123,23 @@ public class SignedChunksInputStream extends InputStream { private boolean isFinalChunkEncountered = false; public SignedChunksInputStream(InputStream inputStream, String keyPath) { + this(inputStream, keyPath, null); + } + + /** + * Creates a signed chunk stream. Supports one checksum trailer; comma-separated trailer names are not supported. + * + * @param inputStream the encoded request body + * @param keyPath resource used in S3 errors + * @param trailerHeader a single supported x-amz-checksum-* header name, or null when no trailer is expected + */ + public SignedChunksInputStream(InputStream inputStream, String keyPath, String trailerHeader) { originalStream = inputStream; this.keyPath = keyPath; + this.trailerHeader = trailerHeader == null ? null : trailerHeader.trim().toLowerCase(Locale.ROOT); + if (this.trailerHeader != null && !CHECKSUM_TRAILER_PATTERN.matcher(this.trailerHeader).matches()) { + throw invalidBody("Invalid x-amz-trailer header"); + } } /** @@ -200,11 +225,14 @@ private boolean ensureChunkPayload() throws IOException { return true; } if (remainingData == 0) { - // final zero-byte chunk: verify it (empty payload) and stop reading - if (validator != null) { + // The final zero-byte chunk has no payload terminator when trailing headers follow it. + if (trailerHeader == null) { readChunkTerminator(); } validateChunk(); + if (trailerHeader != null) { + validateTrailer(); + } isFinalChunkEncountered = true; } else { stopAtUnexpectedEof(); @@ -268,21 +296,9 @@ private void readChunkTerminator() throws IOException { } private int readContentLengthFromHeader() throws IOException { - int prev = -1; - int curr = 0; - StringBuilder buf = new StringBuilder(); - - //read everything until the next \r\n - while (!eol(prev, curr) && curr != -1) { - int next = originalStream.read(); - if (next != -1) { - buf.append((char) next); - } - prev = curr; - curr = next; - } - if (!eol(prev, curr)) { - checkNotTruncated(); + String signatureLine = readLine(false); + if (signatureLine == null) { + return -1; } // Example of a single chunk data: // 10000;chunk-signature=b474d8862b1487a5145d686f57f013e54db672cee1c953b3010fb58501ef5aa2\r\n @@ -290,7 +306,7 @@ private int readContentLengthFromHeader() throws IOException { // // 10000 will be read and decoded from base-16 representation to 65536, which is the size of // the subsequent chunk payload. - String signatureLine = buf.toString().trim(); + signatureLine = signatureLine.trim(); if (signatureLine.isEmpty()) { return -1; } @@ -304,6 +320,67 @@ private int readContentLengthFromHeader() throws IOException { return Integer.parseInt(matcher.group(1), 16); } + private void validateTrailer() throws IOException { + String trailerLine = readLine(true); + if (trailerLine == null || trailerLine.isEmpty()) { + throw invalidBody("Missing trailing checksum header"); + } + int separator = trailerLine.indexOf(':'); + if (separator <= 0) { + throw invalidBody("Invalid trailing header: " + trailerLine); + } + String name = trailerLine.substring(0, separator).trim().toLowerCase(Locale.ROOT); + String value = trailerLine.substring(separator + 1).trim(); + if (!trailerHeader.equals(name)) { + throw invalidBody("Unexpected trailing header: " + name); + } + + String signatureLine = readLine(true); + if (signatureLine == null) { + throw invalidBody("Missing trailing signature"); + } + Matcher matcher = TRAILER_SIGNATURE_PATTERN.matcher(signatureLine.trim()); + if (!matcher.matches()) { + throw invalidBody("Invalid trailing signature"); + } + if (validator != null) { + validator.validateTrailer(matcher.group(1), DigestUtils.sha256Hex(name + ":" + value + "\n")); + } + // AWS SDKs terminate the trailer section with a blank line after the signature. + if (!"".equals(readLine(true))) { + throw invalidBody("Invalid trailer terminator"); + } + if (originalStream.read() != -1) { + throw invalidBody("Unexpected data after trailing signature"); + } + } + + private String readLine(boolean trailingHeader) throws IOException { + StringBuilder line = new StringBuilder(); + int previous = -1; + while (true) { + int current = originalStream.read(); + if (current == -1) { + if (trailingHeader && line.length() > 0) { + throw invalidBody("Truncated trailing header"); + } + if (!trailingHeader) { + checkNotTruncated(); + } + return line.length() == 0 ? null : line.toString(); + } + line.append((char) current); + if (line.length() > MAX_LINE_LENGTH) { + throw invalidBody("Chunk or trailing header line is too long"); + } + if (eol(previous, current)) { + line.setLength(line.length() - 2); + return line.toString(); + } + previous = current; + } + } + private void updateDigest(byte b) { if (chunkDigest != null) { chunkDigest.update(b); 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 d237f577bbeb..0bed7482b858 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 @@ -45,11 +45,13 @@ 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.STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER; 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; import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_REGEX_PATTERN; import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_VALUE_LENGTH_LIMIT; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_TRAILER; import static org.apache.hadoop.ozone.s3.util.S3Utils.hasMultiChunksPayload; import static org.apache.hadoop.ozone.s3.util.S3Utils.hasUnsignedPayload; import static org.apache.hadoop.ozone.s3.util.S3Utils.urlDecode; @@ -822,7 +824,15 @@ protected S3ChunkInputStreamInfo getS3ChunkInputStreamInfo( if (hasUnsignedPayload(amzContentSha256Header)) { chunkInputStream = new UnsignedChunksInputStream(body); } else { - chunkInputStream = new SignedChunksInputStream(body, keyPath); + String trailerHeader = STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER.equals(amzContentSha256Header) + ? getHeaders().getHeaderString(X_AMZ_TRAILER) : null; + if (STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER.equals(amzContentSha256Header) + && StringUtils.isBlank(trailerHeader)) { + OS3Exception ex = newError(INVALID_ARGUMENT, keyPath); + ex.setErrorMessage("The " + X_AMZ_TRAILER + " header is required for signed trailing headers"); + throw ex; + } + chunkInputStream = new SignedChunksInputStream(body, keyPath, trailerHeader); } effectiveLength = Long.parseLong(amzDecodedLength); } else { @@ -846,7 +856,8 @@ protected S3ChunkInputStreamInfo getS3ChunkInputStreamInfo( // 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); + && (STREAMING_AWS4_HMAC_SHA256_PAYLOAD.equals(amzContentSha256Header) + || STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER.equals(amzContentSha256Header)); return new S3ChunkInputStreamInfo(multiDigestInputStream, effectiveLength, verifyChunkSignature); } 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 index 70e731fd42ba..b75b225b1997 100644 --- 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 @@ -31,8 +31,8 @@ import org.apache.hadoop.ozone.s3.exception.OS3Exception; /** - * Verifies the per-chunk signatures of a SigV4 chunked upload - * ({@code STREAMING-AWS4-HMAC-SHA256-PAYLOAD}). + * Verifies the per-chunk and trailing-header signatures of a SigV4 chunked upload + * ({@code STREAMING-AWS4-HMAC-SHA256-PAYLOAD} and its trailer variant). *

* Each chunk signature is {@code hex(HMAC-SHA256(signingKey, stringToSign))}, * where the string-to-sign is: @@ -57,6 +57,8 @@ public class ChunksValidator { private static final String CHUNK_STRING_TO_SIGN_ALGORITHM = "AWS4-HMAC-SHA256-PAYLOAD"; + private static final String TRAILER_STRING_TO_SIGN_ALGORITHM = + "AWS4-HMAC-SHA256-TRAILER"; private static final String HMAC_SHA256 = "HmacSHA256"; private static final String NEWLINE = "\n"; @@ -101,15 +103,34 @@ public void validateChunk(String chunkSignature, String payloadSha256Hex) String stringToSign = String.join(NEWLINE, CHUNK_STRING_TO_SIGN_ALGORITHM, dateTime, credentialScope, previousSignature, EMPTY_STRING_SHA256, payloadSha256Hex); + validateSignature(chunkSignature, stringToSign); + // 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); + } + + /** + * Verify the signature of the trailing headers and complete the signature chain. + * + * @param trailerSignature the signature from the {@code x-amz-trailer-signature} header + * @param trailingHeadersSha256Hex SHA-256 of the canonical trailing headers + * @throws OS3Exception if the computed signature does not match + */ + public void validateTrailer(String trailerSignature, String trailingHeadersSha256Hex) + throws OS3Exception { + String stringToSign = String.join(NEWLINE, + TRAILER_STRING_TO_SIGN_ALGORITHM, dateTime, credentialScope, + previousSignature, trailingHeadersSha256Hex); + validateSignature(trailerSignature, stringToSign); + } + + private void validateSignature(String signature, String stringToSign) { 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))) { + if (!MessageDigest.isEqual(expected, DatatypeConverter.parseHexBinary(signature))) { 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) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java index f75653ad098b..620d554aa5cb 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java @@ -35,6 +35,7 @@ public final class S3Consts { // Constants related to AWS Signature Version V4 calculation // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html public static final String X_AMZ_CONTENT_SHA256 = "x-amz-content-sha256"; + public static final String X_AMZ_TRAILER = "x-amz-trailer"; public static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; public static final String STREAMING_UNSIGNED_PAYLOAD_TRAILER = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"; 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 f48a1b2a7b05..866455f8893b 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 @@ -19,6 +19,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; 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.assertThrows; @@ -33,6 +34,7 @@ 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.CsvSource; import org.junit.jupiter.params.provider.ValueSource; /** @@ -55,6 +57,16 @@ public class TestSignedChunksInputStream { private static final String KEY_PATH = "key1"; private static final String FINAL_CHUNK_SIGNATURE = "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9"; + private static final String TRAILER_SEED_SIGNATURE = + "106e2a8a18243abcf37539882f36619c00e2dfc72633413f02d3b74544bfeb8e"; + private static final String TRAILER_CHUNK1_SIGNATURE = + "b474d8862b1487a5145d686f57f013e54db672cee1c953b3010fb58501ef5aa2"; + private static final String TRAILER_CHUNK2_SIGNATURE = + "1c1344b170168f8e65b41376b44b20fe354e373826ccbbe2c1d40a8cae51e5c7"; + private static final String TRAILER_FINAL_CHUNK_SIGNATURE = + "2ca2aba2005185cf7159c6277faf83795951dd77a3a99e6e65d5c9f85863f992"; + private static final String TRAILER_SIGNATURE = + "d81f82fc3505edab99d459891051a732e8730629a2e4a59689829ca17fe2e435"; /** Well-formed but meaningless signature, for the tests that only strip the chunk format. */ private static final String FAKE_SIGNATURE = repeat('0', 64); @@ -67,6 +79,26 @@ void testEmptyFile() throws IOException { } } + @ParameterizedTest + @CsvSource({"false, false", "false, true", "true, false", "true, true"}) + void consumesFinalChunkTerminatorWithoutValidator(boolean buffered, boolean empty) throws IOException { + String payload = empty ? "" : "data"; + String body = (empty ? "" : "4;chunk-signature=" + FAKE_SIGNATURE + "\r\ndata\r\n") + + "0;chunk-signature=" + FAKE_SIGNATURE + "\r\n\r\n"; + ByteArrayInputStream original = new ByteArrayInputStream(body.getBytes(UTF_8)); + try (SignedChunksInputStream stream = new SignedChunksInputStream(original, KEY_PATH)) { + if (buffered) { + assertThat(IOUtils.toString(stream, UTF_8)).isEqualTo(payload); + } else { + for (char expected : payload.toCharArray()) { + assertThat(stream.read()).isEqualTo(expected); + } + } + assertThat(stream.read()).isEqualTo(-1); + assertThat(original.available()).isZero(); + } + } + @Test void testEmptyFileWithTrailer() throws IOException { try (InputStream is = wrapContent("0;chunk-signature" @@ -257,6 +289,74 @@ void testMultiChunksWithTrailer() throws Exception { } } + @Test + void verifiesTrailerSignature() throws Exception { + String body = trailerBody(); + try (SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream(body.getBytes(UTF_8)), KEY_PATH, "x-amz-checksum-crc32c")) { + is.attachValidator(newTrailerValidator()); + assertThat(IOUtils.toString(is, UTF_8)).isEqualTo(repeat('a', 66560)); + } + } + + @Test + void rejectsTamperedTrailerSignature() { + String body = trailerBody().replace(TRAILER_SIGNATURE, + TRAILER_SIGNATURE.substring(0, TRAILER_SIGNATURE.length() - 1) + "0"); + SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream(body.getBytes(UTF_8)), KEY_PATH, "x-amz-checksum-crc32c"); + is.attachValidator(newTrailerValidator()); + assertSignatureMismatch(is); + } + + @Test + void rejectsMissingTrailerSignature() { + String body = trailerBody().replace("x-amz-trailer-signature:" + TRAILER_SIGNATURE + "\r\n\r\n", ""); + SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream(body.getBytes(UTF_8)), KEY_PATH, "x-amz-checksum-crc32c"); + is.attachValidator(newTrailerValidator()); + assertInvalidBody(is, "Missing trailing signature"); + } + + @Test + void rejectsUnterminatedTrailerSignature() { + String body = trailerBody(); + body = body.substring(0, body.length() - 4); + SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream(body.getBytes(UTF_8)), KEY_PATH, "x-amz-checksum-crc32c"); + is.attachValidator(newTrailerValidator()); + assertInvalidBody(is, "Truncated trailing header"); + } + + @Test + void rejectsDataAfterTrailerSignature() { + SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream((trailerBody() + "extra").getBytes(UTF_8)), KEY_PATH, + "x-amz-checksum-crc32c"); + is.attachValidator(newTrailerValidator()); + assertInvalidBody(is, "Unexpected data after trailing signature"); + } + + @Test + void rejectsMissingTrailerTerminator() { + String body = trailerBody(); + SignedChunksInputStream is = new SignedChunksInputStream( + new ByteArrayInputStream(body.substring(0, body.length() - 2).getBytes(UTF_8)), KEY_PATH, + "x-amz-checksum-crc32c"); + is.attachValidator(newTrailerValidator()); + assertInvalidBody(is, "Invalid trailer terminator"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", ",", "x-amz-meta-test", "x-amz-trailer-signature", "x-amz-checksum-crc32c,", + "x-amz-checksum-crc32c,x-amz-checksum-crc32c", "x-amz-checksum-crc32,x-amz-checksum-sha256"}) + void rejectsInvalidTrailerHeader(String header) { + assertThatThrownBy(() -> new SignedChunksInputStream( + new ByteArrayInputStream(new byte[0]), KEY_PATH, header)) + .isInstanceOfSatisfying(OS3Exception.class, + ex -> assertThat(ex.getCode()).isEqualTo(S3ErrorTable.INVALID_REQUEST.getCode())); + } + @Test void attachValidatorEnablesVerification() throws IOException { // The signing key is only known after the key is opened, so the validator @@ -353,6 +453,22 @@ private static ChunksValidator newValidator() { DATE_TIME, SCOPE, SEED_SIGNATURE, KEY_PATH); } + private static ChunksValidator newTrailerValidator() { + return new ChunksValidator( + SignatureTestUtils.signingKey(SECRET_KEY, "20130524", "us-east-1", "s3"), + DATE_TIME, SCOPE, TRAILER_SEED_SIGNATURE, KEY_PATH); + } + + private static String trailerBody() { + return "10000;chunk-signature=" + TRAILER_CHUNK1_SIGNATURE + "\r\n" + + repeat('a', 65536) + "\r\n" + + "400;chunk-signature=" + TRAILER_CHUNK2_SIGNATURE + "\r\n" + + repeat('a', 1024) + "\r\n" + + "0;chunk-signature=" + TRAILER_FINAL_CHUNK_SIGNATURE + "\r\n" + + "x-amz-checksum-crc32c:sOO8/Q==\r\n" + + "x-amz-trailer-signature:" + TRAILER_SIGNATURE + "\r\n\r\n"; + } + private static SignedChunksInputStream verifiedStream(String body) { SignedChunksInputStream stream = new SignedChunksInputStream(new ByteArrayInputStream(body.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 9a51f4a3e066..26bb431c221f 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 @@ -30,6 +30,7 @@ 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.signedChunkedBodyWithTrailer; 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; @@ -47,6 +48,7 @@ import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_NUM_LIMIT; import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_VALUE_LENGTH_LIMIT; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_TRAILER; 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; @@ -102,7 +104,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EmptySource; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; /** @@ -116,8 +120,6 @@ 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; @@ -268,22 +270,52 @@ void testPutObjectWithValidSignedChunks() throws Exception { assertThat(keyDetails.getDataSize()).isEqualTo(CONTENT.length()); } + @ParameterizedTest + @EmptySource + @ValueSource(strings = CONTENT) + void testPutObjectWithValidSignedChunksAndTrailer(String content) throws Exception { + configureSignedChunksWithTrailer(content.length()); + + // The trailer value is covered by the HMAC; checksum calculation is outside this test. + assertSucceeds(() -> putObject(signedChunkedBodyWithTrailer( + content, "x-amz-checksum-crc32c", "sOO8/Q=="))); + + assertThat(assertKeyContent(bucket, KEY_NAME, content).getDataSize()).isEqualTo(content.length()); + } + + @ParameterizedTest + @EmptySource + @ValueSource(strings = CONTENT) + void testPutObjectRejectsTamperedTrailer(String content) { + configureSignedChunksWithTrailer(content.length()); + String body = signedChunkedBodyWithTrailer(content, "x-amz-checksum-crc32c", "sOO8/Q==") + .replace("sOO8/Q==", "tampered"); + + assertErrorResponse(SIGNATURE_DOES_NOT_MATCH, () -> putObject(body)); + assertKeyWasNotCommitted(); + } + @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=" + FAKE_SIGNATURE + "\r\n" - + "abcde\r\n"; + void testPutObjectRejectsMissingTrailerFinalChunk() { + configureSignedChunksWithTrailer(CONTENT.length()); + String body = withoutFinalChunk(signedChunkedBody(CONTENT)); + + OS3Exception ex = assertErrorResponse(S3ErrorTable.INVALID_REQUEST, + () -> putObject(body)); + assertThat(ex.getErrorMessage()).contains("terminating 0-byte chunk"); + assertKeyWasNotCommitted(); + } + + @Test + void testPutObjectRejectsMissingTrailerHeader() { + ((OzoneBucketStub) bucket).setDerivedKey(signingKey()); when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)) .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER); - when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn("15"); - - assertSucceeds(() -> putObject(chunkedContent)); + when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn("0"); - assertKeyContent(bucket, KEY_NAME, "1234567890abcde"); + assertErrorResponse(S3ErrorTable.INVALID_ARGUMENT, + () -> putObject(signedChunkedBodyWithTrailer("", "x-amz-checksum-crc32c", "sOO8/Q=="))); + assertKeyWasNotCommitted(); } @Test @@ -380,7 +412,7 @@ void testPutObjectRejectsDecodedLengthEndingMidChunk() { 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_HMAC_SHA256_PAYLOAD_TRAILER, true), 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)); @@ -390,6 +422,9 @@ static Stream chunkSignatureVerificationCases() { @MethodSource("chunkSignatureVerificationCases") void testChunkSignatureVerificationSelection(String algorithm, boolean expected) throws Exception { when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)).thenReturn(algorithm); + if (STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER.equals(algorithm)) { + when(headers.getHeaderString(X_AMZ_TRAILER)).thenReturn("x-amz-checksum-crc32c"); + } EndpointBase.S3ChunkInputStreamInfo info = objectEndpoint.getS3ChunkInputStreamInfo( new ByteArrayInputStream(new byte[0]), 0, "0", KEY_NAME); @@ -981,6 +1016,13 @@ private void configureSignedChunks(long decodedLength) { configureSignedChunkHeaders(decodedLength); } + private void configureSignedChunksWithTrailer(long decodedLength) { + configureSignedChunks(decodedLength); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)) + .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER); + when(headers.getHeaderString(X_AMZ_TRAILER)).thenReturn("x-amz-checksum-crc32c"); + } + 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)); 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 a68a02f0c087..f8109676e148 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 @@ -24,11 +24,14 @@ 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.signedChunkedBodyWithTrailer; 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.STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_TRAILER; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -198,6 +201,35 @@ public void testPartUploadRejectsTamperedSignedChunk() throws Exception { assertNoParts(uploadID, keyName); } + @Test + public void testPartUploadWithValidSignedTrailer() throws Exception { + String keyName = UUID.randomUUID().toString(); + String content = "1234567890abcde"; + String chunkedContent = signedChunkedBodyWithTrailer( + content, "x-amz-checksum-crc32c", "sOO8/Q=="); + configureSignedChunksWithTrailer(content.length()); + + String uploadID = initiateMultipartUpload(rest, OzoneConsts.S3_BUCKET, keyName); + + assertSucceeds(() -> put(rest, OzoneConsts.S3_BUCKET, keyName, 1, uploadID, chunkedContent)); + assertContentLength(uploadID, keyName, content.length()); + } + + @Test + public void testPartUploadRejectsTamperedTrailer() throws Exception { + String keyName = UUID.randomUUID().toString(); + String content = "1234567890abcde"; + String chunkedContent = signedChunkedBodyWithTrailer( + content, "x-amz-checksum-crc32c", "sOO8/Q==").replace("sOO8/Q==", "tampered"); + configureSignedChunksWithTrailer(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(); @@ -218,6 +250,13 @@ private void configureSignedChunks(int contentLength) throws IOException { configureSignedChunkHeaders(contentLength); } + private void configureSignedChunksWithTrailer(int contentLength) throws IOException { + configureSignedChunks(contentLength); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)) + .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER); + when(headers.getHeaderString(X_AMZ_TRAILER)).thenReturn("x-amz-checksum-crc32c"); + } + private void configureSignedChunkHeaders(int contentLength) { when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)) .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD); 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 27826401b7c7..736b5dc223a8 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 @@ -26,12 +26,15 @@ 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.signedChunkedBodyWithTrailer; 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.STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER; import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_CONTENT_SHA256; +import static org.apache.hadoop.ozone.s3.util.S3Consts.X_AMZ_TRAILER; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -132,6 +135,29 @@ public void testUploadRejectsTamperedSignedChunk() throws Exception { assertThatThrownBy(() -> bucket.getKey(S3KEY)).isInstanceOf(IOException.class); } + @Test + public void testUploadWithValidSignedTrailer() throws Exception { + OzoneBucket bucket = configureSignedChunksWithTrailer(S3_COPY_EXISTING_KEY_CONTENT.length()); + String body = signedChunkedBodyWithTrailer( + S3_COPY_EXISTING_KEY_CONTENT, "x-amz-checksum-crc32c", "sOO8/Q=="); + + assertSucceeds(() -> put(rest, S3BUCKET, S3KEY, body)); + + assertKeyContent(bucket, S3KEY, S3_COPY_EXISTING_KEY_CONTENT); + } + + @Test + public void testUploadRejectsTamperedTrailer() throws Exception { + OzoneBucket bucket = configureSignedChunksWithTrailer(S3_COPY_EXISTING_KEY_CONTENT.length()); + String body = signedChunkedBodyWithTrailer( + S3_COPY_EXISTING_KEY_CONTENT, "x-amz-checksum-crc32c", "sOO8/Q==") + .replace("sOO8/Q==", "tampered"); + + assertErrorResponse(S3ErrorTable.SIGNATURE_DOES_NOT_MATCH, + () -> put(rest, S3BUCKET, S3KEY, body)); + assertThatThrownBy(() -> bucket.getKey(S3KEY)).isInstanceOf(IOException.class); + } + @Test public void testUploadDoesNotCommitWhenBodyReadFails() throws Exception { OzoneBucket bucket = client.getObjectStore().getS3Bucket(S3BUCKET); @@ -197,4 +223,12 @@ private OzoneBucket configureSignedChunks(int decodedLength) throws IOException when(headers.getHeaderString(DECODED_CONTENT_LENGTH_HEADER)).thenReturn(String.valueOf(decodedLength)); return bucket; } + + private OzoneBucket configureSignedChunksWithTrailer(int decodedLength) throws IOException { + OzoneBucket bucket = configureSignedChunks(decodedLength); + when(headers.getHeaderString(X_AMZ_CONTENT_SHA256)) + .thenReturn(STREAMING_AWS4_HMAC_SHA256_PAYLOAD_TRAILER); + when(headers.getHeaderString(X_AMZ_TRAILER)).thenReturn("x-amz-checksum-crc32c"); + 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 index 3a276c3a3d19..edb75cea592d 100644 --- 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 @@ -104,6 +104,16 @@ public static String chunkSignature(byte[] signingKey, String dateTime, String c return DatatypeConverter.printHexBinary(hmac(signingKey, stringToSign)).toLowerCase(Locale.ROOT); } + /** Compute one SigV4 streaming trailer signature. */ + public static String trailerSignature(byte[] signingKey, String dateTime, String credentialScope, + String previousSignature, String trailingHeaders) { + byte[] canonicalHeaders = (trailingHeaders + "\n").getBytes(UTF_8); + String trailingHeadersHash = sha256Hex(canonicalHeaders, 0, canonicalHeaders.length); + String stringToSign = String.join("\n", "AWS4-HMAC-SHA256-TRAILER", dateTime, + credentialScope, previousSignature, trailingHeadersHash); + 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) { @@ -125,4 +135,28 @@ public static String signedChunkedBody(byte[] signingKey, String dateTime, Strin public static String signedChunkedBody(String content) { return signedChunkedBody(SIGNING_KEY, DATE_TIME, CREDENTIAL_SCOPE, SEED_SIGNATURE, content); } + + /** Build a one-data-chunk SigV4 body with one trailing header and its signature. */ + public static String signedChunkedBodyWithTrailer(String content, String trailerName, + String trailerValue) { + byte[] payload = content.getBytes(UTF_8); + String previousSignature = SEED_SIGNATURE; + StringBuilder body = new StringBuilder(); + if (payload.length > 0) { + previousSignature = chunkSignature(SIGNING_KEY, DATE_TIME, CREDENTIAL_SCOPE, + SEED_SIGNATURE, payload); + body.append(Integer.toHexString(payload.length)) + .append(";chunk-signature=").append(previousSignature).append("\r\n") + .append(content).append("\r\n"); + } + String finalSignature = chunkSignature(SIGNING_KEY, DATE_TIME, CREDENTIAL_SCOPE, + previousSignature, new byte[0]); + body.append("0;chunk-signature=").append(finalSignature).append("\r\n") + .append(trailerName).append(':').append(trailerValue).append("\r\n") + .append("x-amz-trailer-signature:") + .append(trailerSignature(SIGNING_KEY, DATE_TIME, CREDENTIAL_SCOPE, finalSignature, + trailerName + ":" + trailerValue)) + .append("\r\n\r\n"); + return body.toString(); + } } 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 index 2b28c6f45a40..c41090ab9b47 100644 --- 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 @@ -28,6 +28,8 @@ import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Verifies {@link ChunksValidator} against the canonical AWS SigV4 streaming @@ -53,6 +55,16 @@ class TestChunksValidator { "0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497"; private static final String FINAL_CHUNK_SIGNATURE = "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9"; + private static final String TRAILER_SEED_SIGNATURE = + "106e2a8a18243abcf37539882f36619c00e2dfc72633413f02d3b74544bfeb8e"; + private static final String TRAILER_CHUNK1_SIGNATURE = + "b474d8862b1487a5145d686f57f013e54db672cee1c953b3010fb58501ef5aa2"; + private static final String TRAILER_CHUNK2_SIGNATURE = + "1c1344b170168f8e65b41376b44b20fe354e373826ccbbe2c1d40a8cae51e5c7"; + private static final String TRAILER_FINAL_CHUNK_SIGNATURE = + "2ca2aba2005185cf7159c6277faf83795951dd77a3a99e6e65d5c9f85863f992"; + private static final String TRAILER_SIGNATURE = + "d81f82fc3505edab99d459891051a732e8730629a2e4a59689829ca17fe2e435"; /** A chunk that fails verification must surface as SignatureDoesNotMatch (HTTP 403), not any other error. */ private static void assertSignatureMismatch(Executable call) { @@ -67,6 +79,12 @@ private ChunksValidator newValidator() { DATE_TIME, SCOPE, SEED_SIGNATURE, KEY_PATH); } + private ChunksValidator newTrailerValidator() { + return new ChunksValidator( + SignatureTestUtils.signingKey(SECRET_KEY, "20130524", "us-east-1", "s3"), + DATE_TIME, SCOPE, TRAILER_SEED_SIGNATURE, KEY_PATH); + } + @Test void acceptsMatchingChunkSignatures() { ChunksValidator validator = newValidator(); @@ -91,6 +109,30 @@ void acceptsUppercaseChunkSignature() { SignatureTestUtils.sha256Hex(chunk, 0, chunk.length))).doesNotThrowAnyException(); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void validatesTrailerSignature(boolean tampered) { + ChunksValidator validator = newTrailerValidator(); + byte[] chunk1 = repeat('a', 65536); + byte[] chunk2 = repeat('a', 1024); + String trailer = "x-amz-checksum-crc32c:sOO8/Q=="; + + assertDoesNotThrow(() -> validator.validateChunk(TRAILER_CHUNK1_SIGNATURE, + SignatureTestUtils.sha256Hex(chunk1, 0, chunk1.length))); + assertDoesNotThrow(() -> validator.validateChunk(TRAILER_CHUNK2_SIGNATURE, + SignatureTestUtils.sha256Hex(chunk2, 0, chunk2.length))); + assertDoesNotThrow(() -> validator.validateChunk(TRAILER_FINAL_CHUNK_SIGNATURE, + SignatureTestUtils.sha256Hex(new byte[0], 0, 0))); + String hash = SignatureTestUtils.sha256Hex((trailer + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8), + 0, trailer.length() + 1); + if (tampered) { + assertSignatureMismatch(() -> validator.validateTrailer( + TRAILER_SIGNATURE.substring(0, TRAILER_SIGNATURE.length() - 1) + "0", hash)); + } else { + assertDoesNotThrow(() -> validator.validateTrailer(TRAILER_SIGNATURE, hash)); + } + } + @Test void rejectsTamperedChunkSignature() { ChunksValidator validator = newValidator();