From 2fcc3a1ebcb11b5a300e12e8f5b96e83dd553f7e Mon Sep 17 00:00:00 2001 From: scarreras Date: Mon, 2 Nov 2026 16:15:46 -0300 Subject: [PATCH] Require a signature when a signing secret is configured The three-argument B2EventNotification.parse overloads now throw B2SignatureVerificationException when the signature header is missing or empty, and IllegalArgumentException when the signing secret is null or empty. Signatures are compared in constant time. Add parseUnverified for callers that intentionally skip verification, and deprecate the one-argument parse overloads, whose Javadoc wrongly mentioned a check. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++ .../structures/B2EventNotification.java | 83 ++++++++++++++----- .../structures/B2EventNotificationTest.java | 66 +++++++++------ 3 files changed, 108 insertions(+), 47 deletions(-) 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)); + } }