3434import java .util .Base64 ;
3535import java .util .Date ;
3636import java .util .List ;
37- import java .util .Locale ;
3837import java .util .Map ;
3938import java .util .zip .GZIPInputStream ;
4039import javax .crypto .Mac ;
@@ -1447,7 +1446,11 @@ public static DeletePushProviderRequest deletePushProvider(
14471446 }
14481447
14491448 /**
1450- * Validates if hmac signature is correct for message body.
1449+ * Validates if hmac signature is correct for the message body.
1450+ *
1451+ * <p>Kept for backward compatibility. New integrations should call {@link
1452+ * #verifyAndParseWebhook(byte[], String)} (or the SQS / SNS variants), which also handle gzip
1453+ * payload compression.
14511454 *
14521455 * @param body raw body from http request converted to a string.
14531456 * @param signature the signature provided in X-Signature header
@@ -1458,7 +1461,8 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
14581461 }
14591462
14601463 /**
1461- * Validates if hmac signature is correct for message body.
1464+ * Validates if hmac signature is correct for message body. Backward-compatible alias for {@link
1465+ * #verifySignature(byte[], String, String)}.
14621466 *
14631467 * @param apiSecret the secret key
14641468 * @param body raw body from http request converted to a string.
@@ -1467,202 +1471,173 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
14671471 */
14681472 public static boolean verifyWebhookSignature (
14691473 @ NotNull String apiSecret , @ NotNull String body , @ NotNull String signature ) {
1470- return verifyWebhookSignature ( apiSecret , body .getBytes (StandardCharsets .UTF_8 ), signature );
1474+ return verifySignature ( body .getBytes (StandardCharsets .UTF_8 ), signature , apiSecret );
14711475 }
14721476
14731477 /**
1474- * Validates if hmac signature is correct for message body.
1478+ * Validates if hmac signature is correct for the message body using the singleton client's API
1479+ * secret.
14751480 *
14761481 * @param body the message body
14771482 * @param signature the signature provided in X-Signature header
14781483 * @return true if the signature is valid
14791484 */
14801485 public static boolean verifyWebhookSignature (@ NotNull String body , @ NotNull String signature ) {
1481- String apiSecret = Client . getInstance (). getApiSecret ();
1482- return verifyWebhookSignature ( apiSecret , body , signature );
1486+ return verifySignature (
1487+ body . getBytes ( StandardCharsets . UTF_8 ) , signature , Client . getInstance (). getApiSecret () );
14831488 }
14841489
14851490 /**
1486- * Validates if hmac signature is correct for the raw (uncompressed) body bytes.
1491+ * Constant-time HMAC-SHA256 verification of {@code signature} against the digest of {@code body}
1492+ * using {@code secret} as the key.
14871493 *
1488- * <p>Stream computes {@code X-Signature} over the uncompressed JSON, so when webhook compression
1489- * is enabled callers must decompress the request body first (see {@link
1490- * #decompressWebhookBody(byte[], String)}) and pass the resulting bytes here.
1494+ * <p>The signature is always computed over the <b>uncompressed</b> JSON bytes, so callers that
1495+ * decoded a gzipped or base64-wrapped payload must pass the inflated bytes here.
14911496 *
1492- * @param apiSecret the app's API secret
1493- * @param body the uncompressed JSON body bytes
1494- * @param signature the signature provided in {@code X-Signature} header
1497+ * @param body the uncompressed body bytes
1498+ * @param signature the signature provided in {@code X-Signature}
1499+ * @param secret the app's API secret
14951500 * @return true if the signature matches
14961501 */
1497- public static boolean verifyWebhookSignature (
1498- @ NotNull String apiSecret , @ NotNull byte [] body , @ NotNull String signature ) {
1502+ public static boolean verifySignature (
1503+ @ NotNull byte [] body , @ NotNull String signature , @ NotNull String secret ) {
14991504 try {
1500- Key sk = new SecretKeySpec (apiSecret .getBytes (StandardCharsets .UTF_8 ), "HmacSHA256" );
1505+ Key sk = new SecretKeySpec (secret .getBytes (StandardCharsets .UTF_8 ), "HmacSHA256" );
15011506 Mac mac = Mac .getInstance (sk .getAlgorithm ());
15021507 mac .init (sk );
15031508 final byte [] hmac = mac .doFinal (body );
1504- return constantTimeEquals (bytesToHex (hmac ), signature );
1509+ return MessageDigest .isEqual (
1510+ bytesToHex (hmac ).getBytes (StandardCharsets .UTF_8 ),
1511+ signature .getBytes (StandardCharsets .UTF_8 ));
15051512 } catch (NoSuchAlgorithmException e ) {
15061513 throw new IllegalStateException ("Should not happen. Could not find HmacSHA256" , e );
15071514 } catch (InvalidKeyException e ) {
15081515 throw new IllegalStateException ("error building signature, invalid key" , e );
15091516 }
15101517 }
15111518
1512- /**
1513- * Decompresses an outbound webhook body. Equivalent to {@link #decompressWebhookBody(byte[],
1514- * String, String)} with {@code payloadEncoding == null}.
1515- */
1516- public static byte [] decompressWebhookBody (
1517- @ NotNull byte [] body , @ Nullable String contentEncoding ) {
1518- return decompressWebhookBody (body , contentEncoding , null );
1519- }
1519+ private static final byte [] GZIP_MAGIC = new byte [] {0x1f , (byte ) 0x8b , 0x08 };
15201520
15211521 /**
1522- * Decompresses an outbound webhook body, optionally undoing a transport-level wrapper first.
1523- *
1524- * <p>The returned bytes are the uncompressed JSON the server signed. Decode order matches the
1525- * inverse of how the server built the message:
1522+ * Returns {@code body} unchanged unless it starts with the gzip magic ({@code 1f 8b 08}), in
1523+ * which case the gzip stream is inflated and the decompressed bytes are returned.
15261524 *
1527- * <ol>
1528- * <li>If {@code payloadEncoding} is {@code "base64"}, base64-decode the body. This is the
1529- * wrapper Stream uses for SQS / SNS firehose so the message stays valid UTF-8 over
1530- * transport.
1531- * <li>If {@code contentEncoding} is {@code "gzip"}, gunzip the result.
1532- * </ol>
1533- *
1534- * <p>This SDK only supports {@code gzip} for compression and {@code base64} for the transport
1535- * wrapper. Any other value (including {@code br} / {@code zstd}) raises {@link
1536- * IllegalStateException} so callers can surface a clear error and the operator can flip the app
1537- * back to {@code gzip} on the dashboard. {@code null} / {@code ""} for either argument is a
1538- * no-op, which keeps the HTTP webhook path identical to before this method existed.
1539- *
1540- * @param body raw HTTP request body / SQS message body / SNS notification message
1541- * @param contentEncoding value of the {@code Content-Encoding} header / message attribute
1542- * (case-insensitive); {@code null} when absent
1543- * @param payloadEncoding transport wrapper applied after compression (today: {@code "base64"} for
1544- * SQS / SNS firehose, {@code null} for HTTP webhooks)
1545- * @return uncompressed body bytes (the JSON Stream signed)
1525+ * <p>Magic-byte detection (rather than relying on a header) lets the same handler stay correct
1526+ * when middleware auto-decompresses the request before your code sees it.
15461527 */
1547- public static byte [] decompressWebhookBody (
1548- @ NotNull byte [] body , @ Nullable String contentEncoding , @ Nullable String payloadEncoding ) {
1549- byte [] working = body ;
1550-
1551- if (payloadEncoding != null ) {
1552- String pe = payloadEncoding .trim ().toLowerCase (Locale .ROOT );
1553- if (!pe .isEmpty ()) {
1554- if (!"base64" .equals (pe ) && !"b64" .equals (pe )) {
1555- throw new IllegalStateException (
1556- "unsupported webhook payload_encoding: "
1557- + payloadEncoding
1558- + ". This SDK only supports base64." );
1559- }
1560- try {
1561- working = Base64 .getDecoder ().decode (working );
1562- } catch (IllegalArgumentException e ) {
1563- throw new IllegalStateException (
1564- "failed to base64-decode webhook body (payload_encoding: " + payloadEncoding + ")" ,
1565- e );
1566- }
1567- }
1568- }
1569-
1570- if (contentEncoding == null || contentEncoding .isEmpty ()) {
1571- return working ;
1528+ public static byte [] ungzipPayload (@ NotNull byte [] body ) {
1529+ if (body .length < 3
1530+ || body [0 ] != GZIP_MAGIC [0 ]
1531+ || body [1 ] != GZIP_MAGIC [1 ]
1532+ || body [2 ] != GZIP_MAGIC [2 ]) {
1533+ return body ;
15721534 }
1573- String encoding = contentEncoding .trim ().toLowerCase (Locale .ROOT );
1574- if (encoding .isEmpty ()) {
1575- return working ;
1576- }
1577- if (!"gzip" .equals (encoding )) {
1578- throw new IllegalStateException (
1579- "unsupported webhook Content-Encoding: "
1580- + contentEncoding
1581- + ". This SDK only supports gzip; set webhook_compression_algorithm to \" gzip\" on"
1582- + " the app config." );
1583- }
1584- try (GZIPInputStream in = new GZIPInputStream (new ByteArrayInputStream (working ))) {
1535+ try (GZIPInputStream in = new GZIPInputStream (new ByteArrayInputStream (body ))) {
15851536 return readAll (in );
15861537 } catch (IOException e ) {
1587- throw new IllegalStateException (
1588- "failed to decompress webhook body (Content-Encoding: " + contentEncoding + ")" , e );
1538+ throw new IllegalStateException ("failed to decompress gzip payload" , e );
15891539 }
15901540 }
15911541
15921542 /**
1593- * Convenience overload of {@link #verifyAndDecodeWebhook(String, byte[], String, String, String)}
1594- * for HTTP webhooks (no transport wrapper).
1543+ * Reverses the SQS firehose envelope: the message {@code Body} is base64-decoded and, when the
1544+ * result begins with the gzip magic, it is gzip-decompressed. The same call works whether or not
1545+ * Stream is currently compressing payloads.
1546+ *
1547+ * @param body the SQS message {@code Body}
1548+ * @return the raw JSON bytes Stream signed
15951549 */
1596- public static byte [] verifyAndDecodeWebhook (
1597- @ NotNull String apiSecret ,
1598- @ NotNull byte [] body ,
1599- @ NotNull String signature ,
1600- @ Nullable String contentEncoding ) {
1601- return verifyAndDecodeWebhook (apiSecret , body , signature , contentEncoding , null );
1550+ public static byte [] decodeSqsPayload (@ NotNull String body ) {
1551+ byte [] decoded ;
1552+ try {
1553+ decoded = Base64 .getDecoder ().decode (body );
1554+ } catch (IllegalArgumentException e ) {
1555+ throw new IllegalStateException ("failed to base64-decode payload" , e );
1556+ }
1557+ return ungzipPayload (decoded );
16021558 }
16031559
16041560 /**
1605- * Decompresses (when {@code Content-Encoding} / {@code payload_encoding} are set) and verifies
1606- * the HMAC signature of an outbound Stream message, returning the raw JSON bytes when the
1607- * signature matches.
1608- *
1609- * <p>This is the recommended entry point for handlers, regardless of transport:
1610- *
1611- * <ul>
1612- * <li><b>HTTP webhooks</b>: {@code body} is the request body, {@code signature} comes from
1613- * {@code X-Signature}, {@code contentEncoding} from {@code Content-Encoding}, {@code
1614- * payloadEncoding} is {@code null}.
1615- * <li><b>SQS / SNS firehose</b>: {@code body} is the SQS {@code Body} or SNS {@code Message},
1616- * {@code signature} / {@code contentEncoding} / {@code payloadEncoding} come from the
1617- * corresponding message attributes.
1618- * </ul>
1619- *
1620- * The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, so the
1621- * verification rule is invariant across transports.
1561+ * Byte-for-byte identical to {@link #decodeSqsPayload(String)}; exposed under both names so call
1562+ * sites read intent.
1563+ */
1564+ public static byte [] decodeSnsPayload (@ NotNull String message ) {
1565+ return decodeSqsPayload (message );
1566+ }
1567+
1568+ /**
1569+ * Parse a JSON-encoded webhook event into a typed {@link Event}. Unknown event types still parse
1570+ * successfully because {@link Event#getType()} is a free-form string.
16221571 *
1623- * @param apiSecret the app's API secret
1624- * @param body raw transport bytes
1625- * @param signature value of the {@code X-Signature} header / message attribute
1626- * @param contentEncoding compression applied before transport ({@code "gzip"} or {@code null})
1627- * @param payloadEncoding transport wrapper applied after compression ({@code "base64"} or {@code
1628- * null})
1629- * @return the uncompressed JSON body bytes
1630- * @throws SecurityException if the signature does not match
1572+ * @throws IllegalStateException when the bytes are not valid JSON
16311573 */
1632- public static byte [] verifyAndDecodeWebhook (
1633- @ NotNull String apiSecret ,
1634- @ NotNull byte [] body ,
1635- @ NotNull String signature ,
1636- @ Nullable String contentEncoding ,
1637- @ Nullable String payloadEncoding ) {
1638- byte [] decompressed = decompressWebhookBody (body , contentEncoding , payloadEncoding );
1639- if (!verifyWebhookSignature (apiSecret , decompressed , signature )) {
1574+ public static @ NotNull Event parseEvent (@ NotNull byte [] payload ) {
1575+ try {
1576+ return new com .fasterxml .jackson .databind .ObjectMapper ().readValue (payload , Event .class );
1577+ } catch (IOException e ) {
1578+ throw new IllegalStateException ("failed to parse webhook event" , e );
1579+ }
1580+ }
1581+
1582+ private static @ NotNull Event verifyAndParseInternal (
1583+ @ NotNull byte [] payload , @ NotNull String signature , @ NotNull String secret ) {
1584+ if (!verifySignature (payload , signature , secret )) {
16401585 throw new SecurityException ("invalid webhook signature" );
16411586 }
1642- return decompressed ;
1587+ return parseEvent (payload );
1588+ }
1589+
1590+ /**
1591+ * Decompresses {@code body} when gzipped, verifies the HMAC {@code signature}, and returns the
1592+ * parsed {@link Event}. Works for HTTP webhooks regardless of whether payload compression is
1593+ * enabled.
1594+ *
1595+ * @param body raw HTTP request body bytes Stream signed
1596+ * @param signature value of the {@code X-Signature} header
1597+ * @param secret the app's API secret
1598+ * @return the parsed event
1599+ * @throws SecurityException when the signature does not match
1600+ * @throws IllegalStateException when the gzip envelope is malformed or the payload is not JSON
1601+ */
1602+ public static @ NotNull Event verifyAndParseWebhook (
1603+ @ NotNull byte [] body , @ NotNull String signature , @ NotNull String secret ) {
1604+ return verifyAndParseInternal (ungzipPayload (body ), signature , secret );
1605+ }
1606+
1607+ /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */
1608+ public static @ NotNull Event verifyAndParseWebhook (
1609+ @ NotNull byte [] body , @ NotNull String signature ) {
1610+ return verifyAndParseWebhook (body , signature , Client .getInstance ().getApiSecret ());
16431611 }
16441612
16451613 /**
1646- * Convenience overload of {@link #verifyAndDecodeWebhook(byte[], String, String, String)} for
1647- * HTTP webhooks (no transport wrapper). Uses the configured singleton {@link Client} secret .
1614+ * Decode the SQS {@code Body} (base64, then gzip-if-magic), verify the HMAC {@code signature}
1615+ * from the {@code X-Signature} message attribute, and return the parsed {@link Event} .
16481616 */
1649- public static byte [] verifyAndDecodeWebhook (
1650- @ NotNull byte [] body , @ NotNull String signature , @ Nullable String contentEncoding ) {
1651- return verifyAndDecodeWebhook (
1652- Client .getInstance ().getApiSecret (), body , signature , contentEncoding , null );
1617+ public static @ NotNull Event verifyAndParseSqs (
1618+ @ NotNull String messageBody , @ NotNull String signature , @ NotNull String secret ) {
1619+ return verifyAndParseInternal (decodeSqsPayload (messageBody ), signature , secret );
1620+ }
1621+
1622+ /** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */
1623+ public static @ NotNull Event verifyAndParseSqs (
1624+ @ NotNull String messageBody , @ NotNull String signature ) {
1625+ return verifyAndParseSqs (messageBody , signature , Client .getInstance ().getApiSecret ());
16531626 }
16541627
16551628 /**
1656- * Verifies and decodes a Stream message using the API secret of the configured singleton {@link
1657- * Client}, supporting both HTTP webhooks and SQS / SNS envelopes via {@code payloadEncoding }.
1629+ * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code
1630+ * signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event }.
16581631 */
1659- public static byte [] verifyAndDecodeWebhook (
1660- @ NotNull byte [] body ,
1661- @ NotNull String signature ,
1662- @ Nullable String contentEncoding ,
1663- @ Nullable String payloadEncoding ) {
1664- return verifyAndDecodeWebhook (
1665- Client .getInstance ().getApiSecret (), body , signature , contentEncoding , payloadEncoding );
1632+ public static @ NotNull Event verifyAndParseSns (
1633+ @ NotNull String message , @ NotNull String signature , @ NotNull String secret ) {
1634+ return verifyAndParseInternal (decodeSnsPayload (message ), signature , secret );
1635+ }
1636+
1637+ /** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */
1638+ public static @ NotNull Event verifyAndParseSns (
1639+ @ NotNull String message , @ NotNull String signature ) {
1640+ return verifyAndParseSns (message , signature , Client .getInstance ().getApiSecret ());
16661641 }
16671642
16681643 private static byte [] readAll (InputStream in ) throws IOException {
@@ -1675,11 +1650,6 @@ private static byte[] readAll(InputStream in) throws IOException {
16751650 return out .toByteArray ();
16761651 }
16771652
1678- private static boolean constantTimeEquals (@ NotNull String a , @ NotNull String b ) {
1679- return MessageDigest .isEqual (
1680- a .getBytes (StandardCharsets .UTF_8 ), b .getBytes (StandardCharsets .UTF_8 ));
1681- }
1682-
16831653 private static String bytesToHex (byte [] hash ) {
16841654 StringBuilder hexString = new StringBuilder (2 * hash .length );
16851655 for (byte b : hash ) {
0 commit comments