From dc16e4bde2f344e5f4497dcd9d1c5e9f6aa148e5 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:06 +1000 Subject: [PATCH 01/13] UID2-7505: Detect unencrypted JSON request bodies in v2 envelope parsing When a v2 request body fails envelope parsing and the (decoded) bytes parse as JSON, return a specific error explaining the body must be an encrypted request envelope, with a link to the encryption/decryption docs (unifiedid.com or euid.eu depending on identity scope). An encrypted envelope never parses as JSON, so the check only fires for genuinely unencrypted payloads, and the existing error messages remain byte-identical for all other causes (corrupted envelope, proxy-transformed body, wrong client secret). Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 47 ++++++++--- .../uid2/operator/vertx/V2PayloadHandler.java | 6 +- .../com/uid2/operator/V2RequestUtilTest.java | 77 +++++++++++++++++-- .../benchmark/IdentityMapBenchmark.java | 4 +- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index c19fc70db..24069ad76 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -56,9 +56,29 @@ public boolean isValid() { private static final Logger LOGGER = LoggerFactory.getLogger(V2RequestUtil.class); - public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clock) { + // 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) { + String docsHost = identityScope == IdentityScope.EUID ? "https://euid.eu" : "https://unifiedid.com"; + return "Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope;" + + " base64-encoding the JSON without encrypting it first is not sufficient. See " + + docsHost + "/docs/getting-started/gs-encryption-decryption#encryption-and-decryption-code-examples" + + " for encryption and decryption code examples."; + } + + // 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 @@ -67,7 +87,7 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo 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); + V2Request requestAsString = V2RequestUtil.parseRequestAsString(rc.body().asString(), 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()); @@ -84,19 +104,19 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } } } 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."); } @@ -104,12 +124,15 @@ public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IC try { bodyBytes = Utils.decodeBase64String(bodyString); } catch (IllegalArgumentException ex) { + if (isUnencryptedJson(bodyString.getBytes(StandardCharsets.UTF_8))) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } return new V2Request("Invalid body: Body is not valid base64."); } - 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,10 +142,16 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo } if (bodyBytes.length < MIN_PAYLOAD_LENGTH) { + if (isUnencryptedJson(bodyBytes)) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } return new V2Request("Invalid body: Body too short. Check encryption method."); } if (bodyBytes[0] != VERSION) { + if (isUnencryptedJson(bodyBytes)) { + return new V2Request(unencryptedJsonErrorMessage(identityScope)); + } return new V2Request("Invalid body: Version mismatch."); } 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..737564775 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -22,7 +22,10 @@ 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.Base64; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -92,7 +95,7 @@ public void testParseRequestWithExpectedJson() { false, "key-id" ); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, clock, IdentityScope.UID2); assertEquals(expectedPayload, res.payload); } @@ -100,7 +103,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,7 +112,7 @@ 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); } @@ -118,11 +121,75 @@ public void testParseRequestWithNonBase64Body() { 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); assertEquals("Invalid body: Body too short. Check encryption method.", res.errorMessage); } + @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); + + assertEquals("Invalid body: Version mismatch.", res.errorMessage); + } + + @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); + + assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + assertThat(res.errorMessage).contains("unencrypted JSON"); + assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + } + + @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); + + assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + } + + @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); + + assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + } + + @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 testParseRequestWithMalformedJson() { when(clock.now()).thenReturn(Instant.parse("2024-03-20T06:33:15.627Z")); @@ -146,7 +213,7 @@ public void testParseRequestWithMalformedJson() { false, "key-id" ); - V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, clock); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, ck, 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); } From f033592c09fac61019831123c9f4b0b24b1a44f5 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 02/13] UID2-7505: Clarify envelope version-byte error and rename VERSION constant 'Version mismatch' reads as an operator/API version problem. Rename the constant to ENVELOPE_FORMAT_VERSION and report the error using the docs' own term ('version of the envelope format'), including the received byte: 'Invalid body: Invalid request envelope format version: received X, must be 1.' Co-Authored-By: Claude Fable 5 --- .../java/com/uid2/operator/service/V2RequestUtil.java | 9 ++++++--- src/test/java/com/uid2/operator/V2RequestUtilTest.java | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 24069ad76..a33f5f2b7 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -49,7 +49,8 @@ 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, not an operator or API version + 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; @@ -148,11 +149,13 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo return new V2Request("Invalid body: Body too short. Check encryption method."); } - if (bodyBytes[0] != VERSION) { + if (bodyBytes[0] != ENVELOPE_FORMAT_VERSION) { if (isUnencryptedJson(bodyBytes)) { return new V2Request(unencryptedJsonErrorMessage(identityScope)); } - return new V2Request("Invalid body: Version mismatch."); + return new V2Request(String.format( + "Invalid body: Invalid request envelope format version: received %d, must be %d.", + Byte.toUnsignedInt(bodyBytes[0]), ENVELOPE_FORMAT_VERSION)); } byte[] decryptedBody; diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 737564775..26d0bb69c 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -137,7 +137,7 @@ public void testParseRequestWithVersionMismatch() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); - assertEquals("Invalid body: Version mismatch.", res.errorMessage); + assertEquals("Invalid body: Invalid request envelope format version: received 2, must be 1.", res.errorMessage); } @Test From d21ab7ab1fe4d3b4d191a37fdb509db8909f60e6 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 03/13] UID2-7505: Trim envelope version constant comment Co-Authored-By: Claude Fable 5 --- src/main/java/com/uid2/operator/service/V2RequestUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index a33f5f2b7..4ed6e3821 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -49,7 +49,7 @@ 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; - // The version byte of the encrypted request envelope format, not an operator or API version + // 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; From d2dda79ca62d893dd28eb17b11ebedc8157bdc35 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 04/13] UID2-7505: Tighten unencrypted-JSON error message Co-Authored-By: Claude Fable 5 --- src/main/java/com/uid2/operator/service/V2RequestUtil.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 4ed6e3821..917da7caf 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -61,8 +61,7 @@ public boolean isValid() { // (possibly base64-encoded) unencrypted JSON instead of an encrypted request envelope. public static String unencryptedJsonErrorMessage(IdentityScope identityScope) { String docsHost = identityScope == IdentityScope.EUID ? "https://euid.eu" : "https://unifiedid.com"; - return "Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope;" - + " base64-encoding the JSON without encrypting it first is not sufficient. See " + return "Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope. See " + docsHost + "/docs/getting-started/gs-encryption-decryption#encryption-and-decryption-code-examples" + " for encryption and decryption code examples."; } From ae119d8144c91e5182a9c6772093c8da62736b63 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 05/13] UID2-7505: Append docs link to remaining envelope parse errors A neutral pointer to the envelope-format docs, appended after each error's cause statement; the leading phrase of every message is preserved. Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 22 ++++++++++++++----- .../com/uid2/operator/V2RequestUtilTest.java | 9 +++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 917da7caf..720db64b4 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -57,15 +57,25 @@ public boolean isValid() { private static final Logger LOGGER = LoggerFactory.getLogger(V2RequestUtil.class); + 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) { - String docsHost = identityScope == IdentityScope.EUID ? "https://euid.eu" : "https://unifiedid.com"; return "Invalid body: The request body is unencrypted JSON. It must be an encrypted request envelope. See " - + docsHost + "/docs/getting-started/gs-encryption-decryption#encryption-and-decryption-code-examples" + + docsHost(identityScope) + "/docs/getting-started/gs-encryption-decryption#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 envelopeDocsSuffix(IdentityScope identityScope) { + return " See " + docsHost(identityScope) + + "/docs/getting-started/gs-encryption-decryption for details on the request envelope format."; + } + // Only called after envelope parsing has already failed; an encrypted envelope never parses as JSON. private static boolean isUnencryptedJson(byte[] bytes) { try { @@ -127,7 +137,7 @@ public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IC if (isUnencryptedJson(bodyString.getBytes(StandardCharsets.UTF_8))) { return new V2Request(unencryptedJsonErrorMessage(identityScope)); } - return new V2Request("Invalid body: Body is not valid base64."); + return new V2Request("Invalid body: Body is not valid base64." + envelopeDocsSuffix(identityScope)); } return parseRequestCommon(bodyBytes, ck, clock, identityScope); } @@ -145,7 +155,7 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo if (isUnencryptedJson(bodyBytes)) { return new V2Request(unencryptedJsonErrorMessage(identityScope)); } - return new V2Request("Invalid body: Body too short. Check encryption method."); + return new V2Request("Invalid body: Body too short. Check encryption method." + envelopeDocsSuffix(identityScope)); } if (bodyBytes[0] != ENVELOPE_FORMAT_VERSION) { @@ -154,14 +164,14 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo } return new V2Request(String.format( "Invalid body: Invalid request envelope format version: received %d, must be %d.", - Byte.toUnsignedInt(bodyBytes[0]), ENVELOPE_FORMAT_VERSION)); + Byte.toUnsignedInt(bodyBytes[0]), ENVELOPE_FORMAT_VERSION) + envelopeDocsSuffix(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)." + envelopeDocsSuffix(identityScope)); } // Request envelop format: diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 26d0bb69c..3657a1661 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -114,7 +114,8 @@ public void testParseRequestWithNonBase64Body() { 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 @@ -123,7 +124,8 @@ public void testParseRequestWithTooShortBody() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString("dGVzdA==", null, clock, IdentityScope.UID2); - assertEquals("Invalid body: Body too short. Check encryption method.", res.errorMessage); + 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 @@ -137,7 +139,8 @@ public void testParseRequestWithVersionMismatch() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); - assertEquals("Invalid body: Invalid request envelope format version: received 2, must be 1.", res.errorMessage); + 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 From 51980e69d8ce35fc13b1a0e9a9d256adb9e7a968 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 06/13] UID2-7505: Rename envelopeDocsSuffix to envelopeDocsHint Co-Authored-By: Claude Fable 5 --- .../java/com/uid2/operator/service/V2RequestUtil.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 720db64b4..5ccdb608c 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -71,7 +71,7 @@ public static String unencryptedJsonErrorMessage(IdentityScope identityScope) { // 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 envelopeDocsSuffix(IdentityScope identityScope) { + private static String envelopeDocsHint(IdentityScope identityScope) { return " See " + docsHost(identityScope) + "/docs/getting-started/gs-encryption-decryption for details on the request envelope format."; } @@ -137,7 +137,7 @@ public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IC if (isUnencryptedJson(bodyString.getBytes(StandardCharsets.UTF_8))) { return new V2Request(unencryptedJsonErrorMessage(identityScope)); } - return new V2Request("Invalid body: Body is not valid base64." + envelopeDocsSuffix(identityScope)); + return new V2Request("Invalid body: Body is not valid base64." + envelopeDocsHint(identityScope)); } return parseRequestCommon(bodyBytes, ck, clock, identityScope); } @@ -155,7 +155,7 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo if (isUnencryptedJson(bodyBytes)) { return new V2Request(unencryptedJsonErrorMessage(identityScope)); } - return new V2Request("Invalid body: Body too short. Check encryption method." + envelopeDocsSuffix(identityScope)); + return new V2Request("Invalid body: Body too short. Check encryption method." + envelopeDocsHint(identityScope)); } if (bodyBytes[0] != ENVELOPE_FORMAT_VERSION) { @@ -164,14 +164,14 @@ private static V2Request parseRequestCommon(byte[] bodyBytes, ClientKey ck, IClo } return new V2Request(String.format( "Invalid body: Invalid request envelope format version: received %d, must be %d.", - Byte.toUnsignedInt(bodyBytes[0]), ENVELOPE_FORMAT_VERSION) + envelopeDocsSuffix(identityScope)); + 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)." + envelopeDocsSuffix(identityScope)); + return new V2Request("Invalid body: Check encryption key (ClientSecret)." + envelopeDocsHint(identityScope)); } // Request envelop format: From b92bc0ee94dc471008aa7638c95f7caccc868d43 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 13:07:07 +1000 Subject: [PATCH 07/13] UID2-7505: Fix error precedence when binary parse falls back to base64 When both the binary and base64 interpretations of an octet-stream body fail, the binary error was always returned - hiding the base64 path's more accurate diagnosis (e.g. unencrypted JSON) whenever the body was actually base64 text. A binary envelope can never be valid base64 (its first byte is the 0x01 envelope version byte, not a base64 character), so if the fallback got past base64 decoding, the body was base64 text and the fallback's error applies. Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 17 +++++-- .../com/uid2/operator/V2RequestUtilTest.java | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 5ccdb608c..a0ff59b8a 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -26,12 +26,19 @@ public static class V2Request { public final byte[] nonce; public final Object payload; public final byte[] encryptionKey; + // True when the body could not even be base64-decoded, i.e. it was never base64 text + final boolean failedBase64Decoding; V2Request(String errorMessage) { + this(errorMessage, false); + } + + V2Request(String errorMessage, boolean failedBase64Decoding) { this.errorMessage = errorMessage; this.nonce = null; this.payload = null; this.encryptionKey = null; + this.failedBase64Decoding = failedBase64Decoding; } V2Request(byte[] nonce, Object payload, byte[] encryptionKey) { @@ -39,6 +46,7 @@ public static class V2Request { this.nonce = nonce; this.payload = payload; this.encryptionKey = encryptionKey; + this.failedBase64Decoding = false; } public boolean isValid() { @@ -109,8 +117,11 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo // 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; + // If both binary and base64 requests are invalid, pick the error from the interpretation + // that matches what the body actually was. A binary envelope can never be valid base64 + // (its first byte is the 0x01 version byte, not a base64 character), so if the fallback + // got past base64 decoding, the body was base64 text and the fallback's diagnosis applies. + return requestAsString.failedBase64Decoding ? requestAsBuffer : requestAsString; } } } else { @@ -137,7 +148,7 @@ public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IC 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 new V2Request("Invalid body: Body is not valid base64." + envelopeDocsHint(identityScope), true); } return parseRequestCommon(bodyBytes, ck, clock, identityScope); } diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 3657a1661..2f54cf227 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; @@ -25,12 +31,14 @@ 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) @@ -183,6 +191,48 @@ public void testParseRequestWithRawUnencryptedJson() { assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); } + 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); + + assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + } + + @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 testParseRequestWithUnencryptedJsonEuidScope() { when(clock.now()).thenReturn(MOCK_NOW); From d8cc6955da33233a7da778dfdcca574788eca49c Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 14:27:46 +1000 Subject: [PATCH 08/13] UID2-7505: Test fallback precedence for base64-encoded corrupt envelopes Covers the general half of the precedence rule: an octet-stream body that is valid base64 of a corrupt (non-JSON) envelope gets the fallback's error about the decoded bytes, not the binary parse error about the base64 text. Co-Authored-By: Claude Fable 5 --- .../com/uid2/operator/V2RequestUtilTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 2f54cf227..2b3a6c8af 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -233,6 +233,22 @@ public void testParseRequestOctetStreamWithCorruptedBinaryBody() { assertThat(res.errorMessage).startsWith("Invalid body: Invalid request envelope format version: received 2, must be 1."); } + @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); From 698144143cdf201ec1f1056b9362409c2816b964 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 15:28:18 +1000 Subject: [PATCH 09/13] UID2-7505: Decide fallback interpretation by base64-decodability Replaces the failedBase64Decoding flag: instead of parsing the octet-stream body both ways and arbitrating between the two errors afterwards, the fallback first checks whether the body base64-decodes. If not, the body was truly binary and the binary parse error stands; if it does, the decoded bytes are parsed once and that result is authoritative, valid or not. Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index a0ff59b8a..d362178bc 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -26,19 +26,12 @@ public static class V2Request { public final byte[] nonce; public final Object payload; public final byte[] encryptionKey; - // True when the body could not even be base64-decoded, i.e. it was never base64 text - final boolean failedBase64Decoding; V2Request(String errorMessage) { - this(errorMessage, false); - } - - V2Request(String errorMessage, boolean failedBase64Decoding) { this.errorMessage = errorMessage; this.nonce = null; this.payload = null; this.encryptionKey = null; - this.failedBase64Decoding = failedBase64Decoding; } V2Request(byte[] nonce, Object payload, byte[] encryptionKey) { @@ -46,7 +39,6 @@ public static class V2Request { this.nonce = nonce; this.payload = payload; this.encryptionKey = encryptionKey; - this.failedBase64Decoding = false; } public boolean isValid() { @@ -84,6 +76,14 @@ private static String envelopeDocsHint(IdentityScope identityScope) { + "/docs/getting-started/gs-encryption-decryption 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 { @@ -104,25 +104,32 @@ 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, identityScope); + // If the binary request is invalid, fall back to the base64 interpretation. A binary + // envelope can never be valid base64 (its first byte is the 0x01 version byte, not a + // base64 character), so base64-decodability decides which interpretation of the body + // is authoritative. + String bodyString = rc.body().asString(); + byte[] decoded = bodyString == null ? null : tryDecodeBase64(bodyString); + if (decoded == null) { + // TODO: Delete this log line after fix is verified + LOGGER.info("Fallback failed for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); + + // The body was truly binary; keep the binary parse error + return requestAsBuffer; + } + + 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()); // If the base64 request is valid, set the request content type to text/plain, use the base64 request string 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, pick the error from the interpretation - // that matches what the body actually was. A binary envelope can never be valid base64 - // (its first byte is the 0x01 version byte, not a base64 character), so if the fallback - // got past base64 decoding, the body was base64 text and the fallback's diagnosis applies. - return requestAsString.failedBase64Decoding ? requestAsBuffer : requestAsString; } + return requestAsString; } } else { return V2RequestUtil.parseRequestAsString(rc.body().asString(), ck, clock, identityScope); @@ -141,14 +148,12 @@ public static V2Request parseRequestAsString(String bodyString, ClientKey ck, IC if (bodyString == null) { return new V2Request("Invalid body: Body is missing."); } - byte[] bodyBytes; - try { - bodyBytes = Utils.decodeBase64String(bodyString); - } catch (IllegalArgumentException ex) { + 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), true); + return new V2Request("Invalid body: Body is not valid base64." + envelopeDocsHint(identityScope)); } return parseRequestCommon(bodyBytes, ck, clock, identityScope); } From 83b91c127f5f8925f66260ed55dbb54dfa466b12 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 15:35:49 +1000 Subject: [PATCH 10/13] UID2-7505: Make fallback log lines distinguish each outcome The two 'Fallback failed' lines were indistinguishable in the logs. Each of the three fallback outcomes (not base64, parsed as base64, base64 but invalid envelope) now logs a distinct message. Co-Authored-By: Claude Fable 5 --- .../com/uid2/operator/service/V2RequestUtil.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index d362178bc..4f5a563f3 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -104,30 +104,26 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } else { RoutingContextReader rcReader = new RoutingContextReader(rc); - // If the binary request is invalid, fall back to the base64 interpretation. A binary - // envelope can never be valid base64 (its first byte is the 0x01 version byte, not a - // base64 character), so base64-decodability decides which interpretation of the body - // is authoritative. + // Attempt to decode as Bas64 String bodyString = rc.body().asString(); byte[] decoded = bodyString == null ? null : tryDecodeBase64(bodyString); if (decoded == null) { - // TODO: Delete this log line after fix is verified - LOGGER.info("Fallback failed for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); - // The body was truly binary; keep the binary parse 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; } 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 rc.request().headers().set(HttpHeaders.CONTENT_TYPE, HttpMediaType.TEXT_PLAIN.getType()); } else { // TODO: Delete this log line after fix is verified - LOGGER.info("Fallback failed for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); + LOGGER.info("Fallback failed, base64-decoded body is not a valid envelope, for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); } return requestAsString; } From a3da60503eb989abd1b56426d15a3dd4577d52f7 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 15:53:22 +1000 Subject: [PATCH 11/13] UID2-7505: Cover fallback-success path, fix null-body telemetry, restore rationale - Add a test for the base64-over-octet-stream success path (rewritten in the Option B restructure): a valid envelope decodes, parses, and the content type is rewritten to text/plain. Extracts the valid-envelope fixture and ClientKey to shared members. - Handle a null body before the base64 probe so a missing body no longer logs the misleading 'body is not base64' fallback line. - Restore the rationale for why base64-decodability is the discriminator, fix a typo, and reword the stale success-branch comment. Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 16 +++-- .../com/uid2/operator/V2RequestUtilTest.java | 58 +++++++++---------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 4f5a563f3..805dc63e2 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -104,11 +104,18 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } else { RoutingContextReader rcReader = new RoutingContextReader(rc); - // Attempt to decode as Bas64 + // Binary parse failed; fall back to interpreting the body as base64. A well-formed + // envelope never decodes as base64 (its first byte is 0x01, outside the base64 + // alphabet), so base64-decodability decides which interpretation is authoritative. String bodyString = rc.body().asString(); - byte[] decoded = bodyString == null ? null : tryDecodeBase64(bodyString); + if (bodyString == null) { + // No body to fall back on; the binary parse error (missing body) stands + return requestAsBuffer; + } + byte[] decoded = tryDecodeBase64(bodyString); if (decoded == null) { - // The body was truly binary; keep the binary parse error + // Not base64, so genuinely binary: the buffer parse already examined these exact + // bytes (including the unencrypted-JSON check), so its error stands. // 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; @@ -119,7 +126,8 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo // TODO: Delete this log line after fix is verified 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 base64, not binary, despite the octet-stream header: rewrite the content + // type so the response is base64-encoded to match what the client sent. rc.request().headers().set(HttpHeaders.CONTENT_TYPE, HttpMediaType.TEXT_PLAIN.getType()); } else { // TODO: Delete this log line after fix is verified diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 2b3a6c8af..71a52e56f 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -47,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 @@ -85,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, IdentityScope.UID2); + V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(VALID_REQUEST_ENVELOPE_BASE64, newClientKey(), clock, IdentityScope.UID2); assertEquals(expectedPayload, res.payload); } @@ -233,6 +224,21 @@ public void testParseRequestOctetStreamWithCorruptedBinaryBody() { 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); @@ -270,19 +276,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, IdentityScope.UID2); + 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); From e1df7eea9e0f6044899827af8f0004221335a438 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Thu, 23 Jul 2026 15:59:11 +1000 Subject: [PATCH 12/13] UID2-7505: Sweep up test-coverage tail - Add a test for the wrong-client-secret decrypt-failure branch. - Replace the tautological assertEquals(unencryptedJsonErrorMessage(...), ...) assertions: one canonical test asserts the full literal message; the rest assert the stable leading phrase plus the docs link. - Add an EUID unencrypted-JSON test through the HTTP entry point (parseRequest). - Extract the shared docs path to a DOCS_PATH constant. Co-Authored-By: Claude Fable 5 --- .../uid2/operator/service/V2RequestUtil.java | 11 ++--- .../com/uid2/operator/V2RequestUtilTest.java | 44 ++++++++++++++++--- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 805dc63e2..2de33fbb8 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -57,6 +57,8 @@ public boolean isValid() { private static final Logger LOGGER = LoggerFactory.getLogger(V2RequestUtil.class); + 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"; } @@ -65,15 +67,15 @@ private static String docsHost(IdentityScope identityScope) { // (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/getting-started/gs-encryption-decryption#encryption-and-decryption-code-examples" + + 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/getting-started/gs-encryption-decryption for details on the request envelope format."; + return " See " + docsHost(identityScope) + DOCS_PATH + + " for details on the request envelope format."; } private static byte[] tryDecodeBase64(String bodyString) { @@ -114,8 +116,7 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } byte[] decoded = tryDecodeBase64(bodyString); if (decoded == null) { - // Not base64, so genuinely binary: the buffer parse already examined these exact - // bytes (including the unencrypted-JSON check), so its error stands. + // Not 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; diff --git a/src/test/java/com/uid2/operator/V2RequestUtilTest.java b/src/test/java/com/uid2/operator/V2RequestUtilTest.java index 71a52e56f..860fdc677 100644 --- a/src/test/java/com/uid2/operator/V2RequestUtilTest.java +++ b/src/test/java/com/uid2/operator/V2RequestUtilTest.java @@ -153,9 +153,10 @@ public void testParseRequestWithBase64EncodedUnencryptedJson() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); - assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); - assertThat(res.errorMessage).contains("unencrypted JSON"); - assertThat(res.errorMessage).contains("https://unifiedid.com/docs/getting-started/gs-encryption-decryption"); + // 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 @@ -168,7 +169,8 @@ public void testParseRequestWithBase64EncodedShortUnencryptedJson() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString(bodyString, null, clock, IdentityScope.UID2); - assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + 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 @@ -179,7 +181,22 @@ public void testParseRequestWithRawUnencryptedJson() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequestAsString( "{\"email_hash\": \"tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=\"}", null, clock, IdentityScope.UID2); - assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + 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) { @@ -208,7 +225,8 @@ public void testParseRequestOctetStreamWithBase64EncodedUnencryptedJson() { V2RequestUtil.V2Request res = V2RequestUtil.parseRequest(mockOctetStreamContext(wireBytes), null, clock, IdentityScope.UID2); - assertEquals(V2RequestUtil.unencryptedJsonErrorMessage(IdentityScope.UID2), res.errorMessage); + 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 @@ -265,6 +283,20 @@ public void testParseRequestWithUnencryptedJsonEuidScope() { 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); + + 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 public void testParseRequestWithMalformedJson() { when(clock.now()).thenReturn(Instant.parse("2024-03-20T06:33:15.627Z")); From 11cc5bd964f37cde385830b73517b1db553c1612 Mon Sep 17 00:00:00 2001 From: sean wibisono Date: Fri, 24 Jul 2026 10:18:50 +1000 Subject: [PATCH 13/13] update comments --- .../com/uid2/operator/service/V2RequestUtil.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/uid2/operator/service/V2RequestUtil.java b/src/main/java/com/uid2/operator/service/V2RequestUtil.java index 2de33fbb8..b77f8e56b 100644 --- a/src/main/java/com/uid2/operator/service/V2RequestUtil.java +++ b/src/main/java/com/uid2/operator/service/V2RequestUtil.java @@ -106,29 +106,28 @@ public static V2Request parseRequest(RoutingContext rc, ClientKey ck, IClock clo } else { RoutingContextReader rcReader = new RoutingContextReader(rc); - // Binary parse failed; fall back to interpreting the body as base64. A well-formed - // envelope never decodes as base64 (its first byte is 0x01, outside the base64 - // alphabet), so base64-decodability decides which interpretation is authoritative. + // Binary parse failed + // Check if the body is valid base64 String bodyString = rc.body().asString(); if (bodyString == null) { - // No body to fall back on; the binary parse error (missing body) stands + // Keep the binary parsing error return requestAsBuffer; } byte[] decoded = tryDecodeBase64(bodyString); if (decoded == null) { - // Not base64; keep the binary parsing error + // 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, body parsed as base64, for {}, site ID: {}", rcReader.getContact(), rcReader.getSiteId()); - // Body was base64, not binary, despite the octet-stream header: rewrite the content - // type so the response is base64-encoded to match what the client sent. + // Body was actually base64, set content type to text/plain rc.request().headers().set(HttpHeaders.CONTENT_TYPE, HttpMediaType.TEXT_PLAIN.getType()); } else { // TODO: Delete this log line after fix is verified