diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d7e6667..1c43cf915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ### Changed * `toString()` of credential-bearing structures (`B2AccountAuthorization`, `B2FileSseForRequest`, `B2WebhookConfiguration`, `B2WebhookCustomHeader`, and `B2HeadersImpl`) omits sensitive values; `B2WebhookCustomHeader` omits all header values. * `B2EventNotificationRule` now orders and deduplicates rules by `maxEventsPerBatch`, so rules that differ only by batch size no longer collapse in sorted collections; it also uses direct webhook settings instead of the former string-based order (url, headers, then signing secret), so callers that persist or diff ordered rules may observe a different order. +* Security / behaviour change: the three-argument `B2EventNotification.parse` overloads now require a signature header and a signing secret; receivers should answer HTTP 401 on `B2SignatureVerificationException`. + A missing or empty signature header throws `B2SignatureVerificationException`; a null or empty signing secret throws `IllegalArgumentException`. +* Signature comparison is now constant-time. + +### Added +* Added `B2EventNotification.parseUnverified`, which parses without checking a signature. The one-argument `parse` overloads are deprecated in its favor. ## [6.5.0] - 2025-12-15 * Add `readBucketLifecycleRules` and `writeBucketLifecycleRules` capabilities. diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java index 8666548e2..ac2f3b925 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java @@ -15,8 +15,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -61,13 +61,15 @@ public String toString() { } /** - * Create a new EventNotification from JSON content with signature check. + * Create a new EventNotification from JSON content WITHOUT verifying any signature. + * Only use this when the signature has been verified by other means; otherwise + * use {@link #parse(byte[], String, String)}. * * @param json - The JSON content to create an B2EventNotification object from. The byte array should be UTF-8 encoded * @return The B2EventNotification object * @throws B2JsonException - If the content is not valid JSON */ - public static B2EventNotification parse(byte[] json) throws IOException, B2JsonException { + public static B2EventNotification parseUnverified(byte[] json) throws IOException, B2JsonException { B2Preconditions.checkArgumentIsNotNull(json, "json"); B2Preconditions.checkArgument(json.length > 0); @@ -75,27 +77,59 @@ public static B2EventNotification parse(byte[] json) throws IOException, B2JsonE } /** - * Create a new EventNotification from JSON content with signature check. + * Create a new EventNotification from JSON content WITHOUT verifying any signature. + * Only use this when the signature has been verified by other means; otherwise + * use {@link #parse(String, String, String)}. * * @param json - The JSON content to create an B2EventNotification object from. * @return The B2EventNotification object * @throws B2JsonException - If the content is not valid JSON */ - public static B2EventNotification parse(String json) throws B2JsonException { + public static B2EventNotification parseUnverified(String json) throws B2JsonException { B2Preconditions.checkArgument(!B2StringUtil.isEmpty(json), "json is required"); return B2Json.get().fromJson(json, B2EventNotification.class); } /** - * Create a new EventNotification from JSON content with signature check. + * Create a new EventNotification from JSON content WITHOUT verifying any signature. + * + * @param json - The JSON content to create an B2EventNotification object from. The byte array should be UTF-8 encoded + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + * @deprecated This does not check a signature. Use {@link #parse(byte[], String, String)} + * to verify one, or {@link #parseUnverified(byte[])} to make skipping it explicit. + */ + @Deprecated + public static B2EventNotification parse(byte[] json) throws IOException, B2JsonException { + return parseUnverified(json); + } + + /** + * Create a new EventNotification from JSON content WITHOUT verifying any signature. + * + * @param json - The JSON content to create an B2EventNotification object from. + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + * @deprecated This does not check a signature. Use {@link #parse(String, String, String)} + * to verify one, or {@link #parseUnverified(String)} to make skipping it explicit. + */ + @Deprecated + public static B2EventNotification parse(String json) throws B2JsonException { + return parseUnverified(json); + } + + /** + * Create a new EventNotification from JSON content, verifying its signature. * * @param json - The JSON content to create an B2EventNotification object from. The byte array should be UTF-8 encoded * @param signatureFromHeader - The value of the X-Bz-Event-Notification-Signature header - * @param signingSecret - The secret for computing the signature. + * @param signingSecret - The secret for computing the signature. Must not be null or empty. * @return The B2EventNotification object * @throws B2JsonException - If the content is not valid JSON - * @throws B2SignatureVerificationException - if the content does not match the signature from header. + * @throws B2SignatureVerificationException - if the signature header is missing or empty, + * or the content does not match the signature from header. + * @throws IllegalArgumentException - if the signing secret is null or empty. */ public static B2EventNotification parse(byte[] json, String signatureFromHeader, @@ -103,45 +137,50 @@ public static B2EventNotification parse(byte[] json, throws B2JsonException, IOException, B2SignatureVerificationException { B2Preconditions.checkArgumentIsNotNull(json, "json"); B2Preconditions.checkArgument(json.length > 0); + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(signingSecret), "signingSecret is required"); - // Only validate the signature if signature and secret are both provided - if (signatureFromHeader != null && signingSecret != null) { - SignatureUtils.verifySignature(json, signatureFromHeader, signingSecret); - } + SignatureUtils.verifySignature(json, signatureFromHeader, signingSecret); return B2Json.get().fromJson(json , B2EventNotification.class); } /** - * Create a new EventNotification from JSON content with signature check. + * Create a new EventNotification from JSON content, verifying its signature. * * @param json - The JSON content to create an B2EventNotification object from. * @param signatureFromHeader - The value of the X-Bz-Event-Notification-Signature header - * @param signingSecret - The secret for computing the signature. + * @param signingSecret - The secret for computing the signature. Must not be null or empty. * @return The B2EventNotification object * @throws B2JsonException - If the content is not valid JSON - * @throws B2SignatureVerificationException - if the content does not match the signature from header. + * @throws B2SignatureVerificationException - if the signature header is missing or empty, + * or the content does not match the signature from header. + * @throws IllegalArgumentException - if the signing secret is null or empty. */ public static B2EventNotification parse(String json, String signatureFromHeader, String signingSecret) throws B2JsonException, B2SignatureVerificationException { B2Preconditions.checkArgument(!B2StringUtil.isEmpty(json), "json is required"); + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(signingSecret), "signingSecret is required"); - // Only validate the signature if signature and secret are both provided - if (signatureFromHeader != null && signingSecret != null) { - SignatureUtils.verifySignature( - json.getBytes(StandardCharsets.UTF_8), signatureFromHeader, signingSecret); - } + SignatureUtils.verifySignature( + json.getBytes(StandardCharsets.UTF_8), signatureFromHeader, signingSecret); return B2Json.get().fromJson(json , B2EventNotification.class); } /*testing*/ static class SignatureUtils { public static void verifySignature(byte[] json, String signatureFromHeader, String signingSecret) throws B2SignatureVerificationException { + if (B2StringUtil.isEmpty(signatureFromHeader)) { + throw new B2SignatureVerificationException("Signature header is missing", null); + } final String[] signatures = signatureFromHeader.split(","); - final String signature = computeHmacSha256Signature(signingSecret, json); + final byte[] signature = computeHmacSha256Signature(signingSecret, json).getBytes(StandardCharsets.UTF_8); - boolean signatureMatch = Arrays.stream(signatures).anyMatch(signatureToVerify -> Objects.equals(signatureToVerify, signature)); + boolean signatureMatch = false; + for (String signatureToVerify : signatures) { + // Constant-time comparison; no short-circuit so every entry is compared. + signatureMatch |= MessageDigest.isEqual(signatureToVerify.getBytes(StandardCharsets.UTF_8), signature); + } if (!signatureMatch) { throw new B2SignatureVerificationException("Signature from header does not match calculated signature", null); } diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java index bbc935431..4d381c8f1 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java @@ -125,25 +125,20 @@ public void testParseSuccess() throws B2JsonException, B2SignatureVerificationEx } @Test - public void testParseSuccessWithNoSignature() throws B2JsonException, B2SignatureVerificationException { - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, HMAC_SHA256_SIGNING_SECRET); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + public void testParseWithNoSignatureIsRejected() { + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, HMAC_SHA256_SIGNING_SECRET)); } @Test - public void testParseSuccessWithNoSecret() throws B2JsonException, B2SignatureVerificationException { + public void testParseWithNoSecretIsRejected() throws B2SignatureVerificationException { final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, null); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, null)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, "")); } @Test - public void testParseSuccessWithNoSignatureOrSecret() throws B2JsonException, B2SignatureVerificationException { - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, null); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + public void testParseWithNoSignatureOrSecretIsRejected() { + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, null)); } @Test @@ -168,25 +163,20 @@ public void testParseBytesSuccess() throws B2JsonException, IOException, B2Signa } @Test - public void testParseBytesSuccessWithNoSignature() throws B2JsonException, IOException, B2SignatureVerificationException { - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, HMAC_SHA256_SIGNING_SECRET); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + public void testParseBytesWithNoSignatureIsRejected() { + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, HMAC_SHA256_SIGNING_SECRET)); } @Test - public void testParseBytesSuccessWithNoSecret() throws B2JsonException, IOException, B2SignatureVerificationException { - final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, signature, null); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + public void testParseBytesWithNoSecretIsRejected() throws B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_BYTES); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_BYTES, signature, null)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_BYTES, signature, "")); } @Test - public void testParseBytesSuccessWithNoSignatureOrSecret() throws B2JsonException, IOException, B2SignatureVerificationException { - final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, null); - final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); - assertEquals(parsedEventNotification, b2EventNotification); + public void testParseBytesWithNoSignatureOrSecretIsRejected() { + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, null)); } @Test @@ -233,4 +223,30 @@ public void testParseWithInvalidJson() throws B2SignatureVerificationException { jsonBytes); assertThrows(B2JsonException.class, () -> B2EventNotification.parse(jsonBytes, signature, HMAC_SHA256_SIGNING_SECRET)); } + + @Test + public void testParseWithEmptySignatureIsRejected() { + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, "", HMAC_SHA256_SIGNING_SECRET)); + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_BYTES, "", HMAC_SHA256_SIGNING_SECRET)); + } + + @Test + public void testParseAcceptsAnyValidSignatureInCommaSeparatedHeader() throws Exception { + final String valid = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_BYTES); + final String other = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET2, DEFAULT_EVENT_BYTES); + final B2EventNotification expected = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + + assertEquals(expected, B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, other + "," + valid, HMAC_SHA256_SIGNING_SECRET)); + assertEquals(expected, B2EventNotification.parse(DEFAULT_EVENT_BYTES, valid + "," + other, HMAC_SHA256_SIGNING_SECRET)); + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, other + ",v1=00", HMAC_SHA256_SIGNING_SECRET)); + } + + @Test + public void testParseUnverifiedStillParses() throws Exception { + final B2EventNotification expected = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(expected, B2EventNotification.parseUnverified(DEFAULT_EVENT_PAYLOAD)); + assertEquals(expected, B2EventNotification.parseUnverified(DEFAULT_EVENT_BYTES)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parseUnverified((String) null)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parseUnverified((byte[]) null)); + } }