diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index c19fc70db..b77f8e56b 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -49,16 +49,56 @@ public boolean isValid() { // version: 1 byte, IV: 12 bytes, GCM tag: 16 bytes, timestamp: 8 bytes, nonce: 8 bytes private static final int MIN_PAYLOAD_LENGTH = 1 + AesGcm.GCM_IV_LENGTH + AesGcm.GCM_AUTHTAG_LENGTH + 8 + 8; - private static final byte VERSION = 1; + // The version byte of the encrypted request envelope format + private static final byte ENVELOPE_FORMAT_VERSION = 1; public static final int V2_REFRESH_PAYLOAD_LENGTH = 388; public static final long V2_REQUEST_TIMESTAMP_DRIFT_THRESHOLD_IN_MINUTES = 1; private static final Logger LOGGER = LoggerFactory.getLogger(V2RequestUtil.class); - public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clock) { + private static final String DOCS_PATH = "/docs/getting-started/gs-encryption-decryption"; + + private static String docsHost(IdentityScope identityScope) { + return identityScope == IdentityScope.EUID ? "https://euid.eu" : "https://unifiedid.com"; + } + + // Returned when the request body is plain JSON: the caller skipped the encryption step and sent + // (possibly base64-encoded) unencrypted JSON instead of an encrypted request envelope. + public static String unencryptedJsonErrorMessage(IdentityScope identityScope) { + return "Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope. See " + + docsHost(identityScope) + DOCS_PATH + "#encryption-and-decryption-code-examples" + + " for encryption and decryption code examples."; + } + + // Appended to envelope parse errors as a neutral pointer; the linked page documents the envelope format. + // Deliberately makes no claim about the cause - the leading part of each message does that. + private static String envelopeDocsHint(IdentityScope identityScope) { + return " See " + docsHost(identityScope) + DOCS_PATH + + " for details on the request envelope format."; + } + + private static byte[] tryDecodeBase64(String bodyString) { + try { + return Utils.decodeBase64String(bodyString); + } catch (IllegalArgumentException ex) { + return null; + } + } + + // Only called after envelope parsing has already failed; an encrypted envelope never parses as JSON. + private static boolean isUnencryptedJson(byte[] bytes) { + try { + new JsonObject(new String(bytes, StandardCharsets.UTF_8)); + return true; + } catch (Exception e) { + return false; + } + } + + public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clock, IdentityScope identityScope) { if (rc.request().headers().contains(HttpHeaders.CONTENT_TYPE, HttpMediaType.APPLICATION_OCTET_STREAM.getType(), true)) { - V2Request requestAsBuffer = V2RequestUtil.parseRequestAsBuffer(rc.body().buffer(), ck, clock); + V2Request requestAsBuffer = V2RequestUtil.parseRequestAsBuffer(rc.body().buffer(), ck, clock, identityScope); if (requestAsBuffer.isValid()) { // If the binary request is valid, use the binary request buffer @@ -66,50 +106,63 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } else { RoutingContextReader rcReader = new RoutingContextReader(rc); - // If the binary request is invalid, try to parse it as a base64 encoded string - V2Request requestAsString = V2RequestUtil.parseRequestAsString(rc.body().asString(), ck, clock); + // Binary parse failed + // Check if the body is valid base64 + String bodyString = rc.body().asString(); + if (bodyString == null) { + // Keep the binary parsing error + return requestAsBuffer; + } + byte[] decoded = tryDecodeBase64(bodyString); + if (decoded == null) { + // Not valid base64; keep the binary parsing error + // TODO: Delete this log line after fix is verified + LOGGER.info("Fallback skipped, body is not base64, for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); + return requestAsBuffer; + } + + // Fallback and attempt to parse the body as base64 + V2Request requestAsString = parseRequestCommon(decoded, ck, clock, identityScope); if (requestAsString.isValid()) { // TODO: Delete this log line after fix is verified - LOGGER.info("Fallback successful for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); + LOGGER.info("Fallback successful, body parsed as base64, for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); - // If the base64 request is valid, set the request content type to text/plain, use the base64 request string + // Body was actually base64, set content type to text/plain rc.request().headers().set(HttpHeaders.CONTENT_TYPE, HttpMediaType.TEXT_PLAIN.getType()); - return requestAsString; } else { // TODO: Delete this log line after fix is verified - LOGGER.info("Fallback failed for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); - - // If both binary and base64 requests are invalid, return the original binary request buffer error - return requestAsBuffer; + LOGGER.info("Fallback failed, base64-decoded body is not a valid envelope, for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); } + return requestAsString; } } else { - return V2RequestUtil.parseRequestAsString(rc.body().asString(), ck, clock); + return V2RequestUtil.parseRequestAsString(rc.body().asString(), ck, clock, identityScope); } } - public static V2Request parseRequestAsBuffer(Buffer bodyBuffer, ClientKey ck, IClock clock) { + public static V2Request parseRequestAsBuffer(Buffer bodyBuffer, ClientKey ck, IClock clock, IdentityScope identityScope) { if (bodyBuffer == null) { return new V2Request("Invalid body: Body is missing."); } - return parseRequestCommon(bodyBuffer.getBytes(), ck, clock); + return parseRequestCommon(bodyBuffer.getBytes(), ck, clock, identityScope); } // clock is passed in to test V2_REQUEST_TIMESTAMP_DRIFT_THRESHOLD_IN_MINUTES in unit tests - public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IClock clock) { + public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IClock clock, IdentityScope identityScope) { if (bodyString == null) { return new V2Request("Invalid body: Body is missing."); } - byte[] bodyBytes; - try { - bodyBytes = Utils.decodeBase64String(bodyString); - } catch (IllegalArgumentException ex) { - return new V2Request("Invalid body: Body is not valid base64."); + byte[] bodyBytes = tryDecodeBase64(bodyString); + if (bodyBytes == null) { + if (isUnencryptedJson(bodyString.getBytes(StandardCharsets.UTF_8))) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } + return new V2Request("Invalid body: Body is not valid base64." + envelopeDocsHint(identityScope)); } - return parseRequestCommon(bodyBytes, ck, clock); + return parseRequestCommon(bodyBytes, ck, clock, identityScope); } - private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClock clock) { + private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClock clock, IdentityScope identityScope) { // Payload envelop format: // byte 0: version // byte 1-12: GCM IV @@ -119,18 +172,26 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo } if (bodyBytes.length < MIN_PAYLOAD_LENGTH) { - return new V2Request("Invalid body: Body too short. Check encryption method."); + if (isUnencryptedJson(bodyBytes)) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } + return new V2Request("Invalid body: Body too short. Check encryption method." + envelopeDocsHint(identityScope)); } - if (bodyBytes[0] != VERSION) { - return new V2Request("Invalid body: Version mismatch."); + if (bodyBytes[0] != ENVELOPE_FORMAT_VERSION) { + if (isUnencryptedJson(bodyBytes)) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } + return new V2Request(String.format( + "Invalid body: Invalid request envelope format version: received %d, must be %d.", + Byte.toUnsignedInt(bodyBytes[0]), ENVELOPE_FORMAT_VERSION) + envelopeDocsHint(identityScope)); } byte[] decryptedBody; try { decryptedBody = AesGcm.decrypt(bodyBytes, 1, ck.getSecretBytes()); } catch (Exception ex) { - return new V2Request("Invalid body: Check encryption key (ClientSecret)"); + return new V2Request("Invalid body: Check encryption key (ClientSecret)." + envelopeDocsHint(identityScope)); } // Request envelop format: diff --git a/src/main/java/com/uid2/operator/vertx/V2PayloadHandler.java b/src/main/java/com/uid2/operator/vertx/V2PayloadHandler.java index cd15f59da..5b89d6000 100644 --- a/src/main/java/com/uid2/operator/vertx/V2PayloadHandler.java +++ b/src/main/java/com/uid2/operator/vertx/V2PayloadHandler.java @@ -49,7 +49,7 @@ public void handle(RoutingContext rc, Handler apiHandler) { passThrough(rc, apiHandler); return; } - V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock()); + V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock(), identityScope); if (!request.isValid()) { ResponseUtil.LogInfoAndSend400Response(rc, request.errorMessage); @@ -68,7 +68,7 @@ public void handleAsync(RoutingContext rc, Function apiH return; } - V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock()); + V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock(), identityScope); if (!request.isValid()) { ResponseUtil.LogInfoAndSend400Response(rc, request.errorMessage); return; @@ -86,7 +86,7 @@ public void handleTokenGenerate(RoutingContext rc, Handler apiHa return; } - V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock()); + V2RequestUtil.V2Request request = V2RequestUtil.parseRequest(rc, AuthMiddleware.getAuthClient(ClientKey.class, rc), new InstantClock(), identityScope); if (!request.isValid()) { SendClientErrorResponseAndRecordStats(ResponseUtil.ResponseStatus.ClientError, 400, rc, request.errorMessage, null, TokenResponseStatsCollector.Endpoint.GenerateV2, TokenResponseStatsCollector.ResponseStatus.BadPayload, siteProvider, TokenResponseStatsCollector.PlatformType.Other); return; diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index d08b52389..860fdc677 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -6,12 +6,18 @@ import com.uid2.operator.model.IdentityScope; import com.uid2.operator.model.KeyManager; import com.uid2.operator.service.V2RequestUtil; +import com.uid2.operator.util.HttpMediaType; import com.uid2.shared.IClock; import com.uid2.shared.auth.ClientKey; import com.uid2.shared.encryption.Random; import com.uid2.shared.model.KeysetKey; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,12 +28,17 @@ import org.mockito.quality.Strictness; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Base64; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -36,6 +47,15 @@ public class V2RequestUtilTest { private static final String LOGGER_NAME = "com.uid2.operator.service.V2RequestUtil"; private static final Instant MOCK_NOW = Instant.parse("2024-03-20T04:02:46.130Z"); + // A valid encrypted request envelope, decryptable with newClientKey() at MOCK_NOW. Encoded from: + // {"token":"AdvertisingTokenmZ4dZgeuXXl6DhoXqbRXQbHlHhA96leN94U1uavZVspwKXlfWETZ3b%2FbesPFFvJxNLLySg4QEYHUAiyUrNncgnm7ppu0mi6wU2CW6hssiuEkKfstbo9XWgRUbWNTM%2BewMzXXM8G9j8Q%3D","email_hash":"LdhtUlMQ58ZZy5YUqGPRQw5xUMS5dXG5ocJHYJHbAKI="} + private static final String VALID_REQUEST_ENVELOPE_BASE64 = "ATDX9gBKxgQaLwUi9ZDbSqo1b66u55jEN322XSR+aCvOy/c3ZiaVOh8VG22pDUSSNaUqfUwwxxYT0pS9zjW7oVPCeluHU5GCc+6A+LUTIQ8vOR+1CN7ds/61Bp82RzKf5wPABMNtqr1XkoN6d5FU/R0vpxf2hfo1cYYmW0ziCy15pPh17GN2vNTn6YK6g+MAi/dDC7mG+Mxnh9ZaEz+3IetgDPWfp5zHh/T3LWhDAA+2drlDn8KwcQE/TYKh5raR4BDHmhgBUCU6+nymoWruNYxzcII63xMTLMTGzpinNnTL3iBPII9lKRJJ2ZrGjjgMMXi066iaDDpBHH3xY+bAwriU+6GEsE8bveRMwRqT83gmkYp6mn+75Yrpdw=="; + + private static ClientKey newClientKey() { + return new ClientKey("hash", "salt", "YGdzZw9oM2RzBgB8THMyAEe408lvdfsTsGteaLAGayY=", + "name", "contact", MOCK_NOW, Set.of(), 113, false, "key-id"); + } + @Mock private IClock clock; @Mock @@ -74,25 +94,7 @@ public void testParseRequestWithExpectedJson() { expectedPayload.put("token", testToken); expectedPayload.put("email_hash", testEmailHash); - // The bodyString was encoded by below json: - // { - // "token": "AdvertisingTokenmZ4dZgeuXXl6DhoXqbRXQbHlHhA96leN94U1uavZVspwKXlfWETZ3b%2FbesPFFvJxNLLySg4QEYHUAiyUrNncgnm7ppu0mi6wU2CW6hssiuEkKfstbo9XWgRUbWNTM%2BewMzXXM8G9j8Q%3D", - // "email_hash": "LdhtUlMQ58ZZy5YUqGPRQw5xUMS5dXG5ocJHYJHbAKI=" - //} - String bodyString = "ATDX9gBKxgQaLwUi9ZDbSqo1b66u55jEN322XSR+aCvOy/c3ZiaVOh8VG22pDUSSNaUqfUwwxxYT0pS9zjW7oVPCeluHU5GCc+6A+LUTIQ8vOR+1CN7ds/61Bp82RzKf5wPABMNtqr1XkoN6d5FU/R0vpxf2hfo1cYYmW0ziCy15pPh17GN2vNTn6YK6g+MAi/dDC7mG+Mxnh9ZaEz+3IetgDPWfp5zHh/T3LWhDAA+2drlDn8KwcQE/TYKh5raR4BDHmhgBUCU6+nymoWruNYxzcII63xMTLMTGzpinNnTL3iBPII9lKRJJ2ZrGjjgMMXi066iaDDpBHH3xY+bAwriU+6GEsE8bveRMwRqT83gmkYp6mn+75Yrpdw=="; - ClientKey ck = new ClientKey( - "hash", - "salt", - "YGdzZw9oM2RzBgB8THMyAEe408lvdfsTsGteaLAGayY=", - "name", - "contact", - MOCK_NOW, - Set.of(), - 113, - false, - "key-id" - ); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(VALID_REQUEST_ENVELOPE_BASE64, newClientKey(), clock, IdentityScope.UID2); assertEquals(expectedPayload, res.payload); } @@ -100,7 +102,7 @@ public void testParseRequestWithExpectedJson() { public void testParseRequestWithNullBody() { when(clock.now()).thenReturn(MOCK_NOW); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(null, null, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(null, null, clock, IdentityScope.UID2); assertEquals("Invalid body: Body is missing.", res.errorMessage); } @@ -109,18 +111,190 @@ public void testParseRequestWithNullBody() { public void testParseRequestWithNonBase64Body() { when(clock.now()).thenReturn(MOCK_NOW); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString("test string", null, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString("test string", null, clock, IdentityScope.UID2); - assertEquals("Invalid body: Body is not valid base64.", res.errorMessage); + assertThat(res.errorMessage).startsWith("Invalid body: Body is not valid base64."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); } @Test public void testParseRequestWithTooShortBody() { when(clock.now()).thenReturn(MOCK_NOW); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString("dGVzdA==", null, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString("dGVzdA==", null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: Body too short. Check encryption method."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestWithVersionMismatch() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Long enough to pass the length check, wrong version byte, not JSON - e.g. a corrupted envelope + byte[] corrupted = new byte[64]; + Arrays.fill(corrupted, (byte) 2); + String bodyString = Base64.getEncoder().encodeToString(corrupted); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: Invalid request envelope format version: received 2, must be 1."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestWithBase64EncodedUnencryptedJson() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Base64 of plain (unencrypted) JSON - what a raw curl caller sends when they skip the encryption step + String bodyString = Base64.getEncoder().encodeToString( + "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\", \"version\": 2}" + .getBytes(StandardCharsets.UTF_8)); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); + + // Canonical rendering: assert the full literal so message wording regressions are caught + assertEquals("Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope." + + " See https://unifiedid.com/docs/getting-started/gs-encryption-decryption#encryption-and-decryption-code-examples" + + " for encryption and decryption code examples.", res.errorMessage); + } + + @Test + public void testParseRequestWithBase64EncodedShortUnencryptedJson() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Unencrypted JSON short enough to fail the length check before the version check + String bodyString = Base64.getEncoder().encodeToString( + "{\"version\": 2}".getBytes(StandardCharsets.UTF_8)); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: The request body is unencrypted JSON."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestWithRawUnencryptedJson() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Plain JSON sent directly, without base64 encoding + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString( + "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\"}", null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: The request body is unencrypted JSON."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestWithWrongClientSecret() { + when(clock.now()).thenReturn(MOCK_NOW); + + // A well-formed envelope decrypted with the wrong client secret fails the GCM auth check + ClientKey wrongKey = new ClientKey("hash", "salt", "gXQbQ460HNwOHOauI2R+g2gHG3GV6pQXHwTWHVewwlU=", + "name", "contact", MOCK_NOW, Set.of(), 113, false, "key-id"); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(VALID_REQUEST_ENVELOPE_BASE64, wrongKey, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: Check encryption key (ClientSecret)."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + private RoutingContext mockOctetStreamContext(byte[] wireBytes) { + RoutingContext rc = mock(RoutingContext.class); + HttpServerRequest request = mock(HttpServerRequest.class); + MultiMap headers = MultiMap.caseInsensitiveMultiMap(); + headers.set(HttpHeaders.CONTENT_TYPE, HttpMediaType.APPLICATION_OCTET_STREAM.getType()); + when(rc.request()).thenReturn(request); + when(request.headers()).thenReturn(headers); + when(rc.data()).thenReturn(new HashMap<>()); + RequestBody body = mock(RequestBody.class); + when(rc.body()).thenReturn(body); + when(body.buffer()).thenReturn(Buffer.buffer(wireBytes)); + when(body.asString()).thenReturn(new String(wireBytes, StandardCharsets.UTF_8)); + return rc; + } + + @Test + public void testParseRequestOctetStreamWithBase64EncodedUnencryptedJson() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Base64 of plain JSON sent with a binary content type: the binary parse fails on the base64 + // text, but the fallback decodes it and diagnoses unencrypted JSON - that diagnosis must win + byte[] wireBytes = Base64.getEncoder().encode( + "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\"}".getBytes(StandardCharsets.UTF_8)); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(mockOctetStreamContext(wireBytes), null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: The request body is unencrypted JSON."); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestOctetStreamWithCorruptedBinaryBody() { + when(clock.now()).thenReturn(MOCK_NOW); + + // A genuinely binary (non-base64) body keeps the binary parse error + byte[] wireBytes = new byte[64]; + Arrays.fill(wireBytes, (byte) 2); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(mockOctetStreamContext(wireBytes), null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: Invalid request envelope format version: received 2, must be 1."); + } + + @Test + public void testParseRequestOctetStreamWithValidBase64Envelope() { + when(clock.now()).thenReturn(MOCK_NOW); + + // A valid encrypted envelope sent as base64 text under an octet-stream content type: the + // fallback decodes and parses it, and rewrites the content type to text/plain + RoutingContext rc = mockOctetStreamContext(VALID_REQUEST_ENVELOPE_BASE64.getBytes(StandardCharsets.UTF_8)); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(rc, newClientKey(), clock, IdentityScope.UID2); + + assertThat(res.isValid()).isTrue(); + assertThat(((JsonObject) res.payload).getString("email_hash")).isEqualTo("LdhtUlMQ58ZZy5YUqGPRQw5xUMS5dXG5ocJHYJHbAKI="); + assertThat(rc.request().headers().get(HttpHeaders.CONTENT_TYPE)).isEqualTo(HttpMediaType.TEXT_PLAIN.getType()); + } + + @Test + public void testParseRequestOctetStreamWithBase64EncodedCorruptEnvelope() { + when(clock.now()).thenReturn(MOCK_NOW); + + // Valid base64 of a corrupt (wrong-version, non-JSON) envelope sent as octet-stream: the + // fallback's error about the decoded bytes (received 2) wins over the binary parse error + // about the base64 text itself (which would report its first character, 'A' = 65) + byte[] corrupted = new byte[64]; + Arrays.fill(corrupted, (byte) 2); + byte[] wireBytes = Base64.getEncoder().encode(corrupted); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(mockOctetStreamContext(wireBytes), null, clock, IdentityScope.UID2); + + assertThat(res.errorMessage).startsWith("Invalid body: Invalid request envelope format version: received 2, must be 1."); + } + + @Test + public void testParseRequestWithUnencryptedJsonEuidScope() { + when(clock.now()).thenReturn(MOCK_NOW); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString( + "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\"}", null, clock, IdentityScope.EUID); + + assertThat(res.errorMessage).contains("https://euid.eu/docs/getting-started/gs-encryption-decryption"); + } + + @Test + public void testParseRequestOctetStreamUnencryptedJsonEuidScope() { + when(clock.now()).thenReturn(MOCK_NOW); + + // EUID scope through the HTTP entry point: the docs link points to euid.eu + byte[] wireBytes = Base64.getEncoder().encode( + "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\"}".getBytes(StandardCharsets.UTF_8)); + + V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(mockOctetStreamContext(wireBytes), null, clock, IdentityScope.EUID); - assertEquals("Invalid body: Body too short. Check encryption method.", res.errorMessage); + assertThat(res.errorMessage).startsWith("Invalid body: The request body is unencrypted JSON."); + assertThat(res.errorMessage).contains("https://euid.eu/docs/getting-started/gs-encryption-decryption"); } @Test @@ -134,19 +308,7 @@ public void testParseRequestWithMalformedJson() { // test //} String bodyString = "AWDCc1W2zSIJUFbCF1Ti7FxS9Vq4xywgUxHWm60+aaNIbk9k1c3GLjcezo6ZGx3J9TUEKdCXLVi+t2d4T17acgSZYRhfTUC6OfxEHxzSkhDLviQ6BXqrx0Ute5PWT55FYG5dR8YM8CAUfLuWSxCq4yB+aJ/Sojpl2nmDO7sn7D6K+dAsdCtyciM+8ihxzOb7obhlOhjS5159XqkQTcAQvbfLXi/QJRtFPoDBpwQQZ3TvBFPUvh8uiT0Zb708Xt7zt9NHziqkwAcJWIvnTgLkxBdACpbGGl3mNcwJhHwBM0m9zlSy050yyx/b+U1mJxjj5yqBwaNSzTKiGHs+M1+vhmVD8w7J13Ec+jAUa8rUeN7c61GD/Rh7GndeEBo4WVLvfw=="; - ClientKey ck = new ClientKey( - "hash", - "salt", - "YGdzZw9oM2RzBgB8THMyAEe408lvdfsTsGteaLAGayY=", - "name", - "contact", - MOCK_NOW, - Set.of(), - 113, - false, - "key-id" - ); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, newClientKey(), clock, IdentityScope.UID2); assertEquals("Invalid payload in body: Data is not valid json string.", res.errorMessage); assertThat(memoryAppender.countEventsForLogger(LOGGER_NAME)).isEqualTo(1); diff --git a/src/test/java/com/uid2/operator/benchmark/IdentityMapBenchmark.java b/src/test/java/com/uid2/operator/benchmark/IdentityMapBenchmark.java index 185bc8a53..7288513a3 100644 --- a/src/test/java/com/uid2/operator/benchmark/IdentityMapBenchmark.java +++ b/src/test/java/com/uid2/operator/benchmark/IdentityMapBenchmark.java @@ -138,7 +138,7 @@ public MappedIdentity IdentityMapWithOptOutThroughput() { @Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) public void decompressionBenchmarkingBinary(PayloadState state, Blackhole bh) { - var data = V2RequestUtil.parseRequestAsBuffer(state.payloadBinary, PayloadState.clientKey, new InstantClock()); + var data = V2RequestUtil.parseRequestAsBuffer(state.payloadBinary, PayloadState.clientKey, new InstantClock(), IdentityScope.UID2); bh.consume(data); } @@ -149,7 +149,7 @@ public void decompressionBenchmarkingBinary(PayloadState state, Blackhole bh) { @Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) public void decompressionBenchmarkingNone(PayloadState state, Blackhole bh) { - var data = V2RequestUtil.parseRequestAsString(state.payloadNone, PayloadState.clientKey, new InstantClock()); + var data = V2RequestUtil.parseRequestAsString(state.payloadNone, PayloadState.clientKey, new InstantClock(), IdentityScope.UID2); bh.consume(data); }