Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -61,87 +61,126 @@ 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);

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.
* 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,
String signingSecret)
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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));
}
}
Loading