From bfe3bad37a76a1c57064db428be0315f3ff910c1 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Wed, 20 May 2026 09:24:52 +0200 Subject: [PATCH 1/2] MT-22022: Add webhook signature verification helper Add `io.mailtrap.webhooks.WebhookSignatures.verify(payload, signature, signingSecret)` for verifying Mailtrap webhook signatures using HMAC-SHA256 over the raw request body with constant-time comparison via `MessageDigest.isEqual`. Returns false (no throw) for null/empty/malformed/wrong-length signatures and non-hex characters, so a single guard at the request handler covers every bad-input case. Includes the shared cross-SDK test fixture (payload + secret + expected signature) that all six Mailtrap SDKs use to stay byte-for-byte compatible, plus a Jakarta Servlet / Spring example and README subsection. See https://railsware.atlassian.net/browse/MT-22022 --- README.md | 24 +++ .../webhooks/WebhookSignatureExample.java | 40 +++++ .../mailtrap/webhooks/WebhookSignatures.java | 106 ++++++++++++ .../webhooks/WebhookSignaturesTest.java | 152 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java create mode 100644 src/main/java/io/mailtrap/webhooks/WebhookSignatures.java create mode 100644 src/test/java/io/mailtrap/webhooks/WebhookSignaturesTest.java diff --git a/README.md b/README.md index 7b8d951..4bc5c40 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,30 @@ You can find the [Mailtrap Java API reference](https://mailtrap.github.io/mailtr - [Billing](examples/java/io/mailtrap/examples/general/BillingExample.java) - [API Tokens](examples/java/io/mailtrap/examples/general/ApiTokensExample.java) - [Webhooks](examples/java/io/mailtrap/examples/webhooks/WebhooksExample.java) +- [Verifying webhook signatures](examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java) + +#### Verifying webhook signatures + +Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature against the raw request body using the `signing_secret` returned when you created the webhook: + +```java +import io.mailtrap.webhooks.WebhookSignatures; + +// `payload` must be the unparsed request body — do NOT re-serialize the +// parsed JSON, as that may reorder keys and invalidate the signature. +boolean valid = WebhookSignatures.verify( + payload, + request.getHeader("Mailtrap-Signature"), + System.getenv("MAILTRAP_WEBHOOK_SIGNING_SECRET") +); + +if (!valid) { + // reject the request — 401 Unauthorized + return; +} +``` + +The helper performs a constant-time comparison and returns `false` (rather than throwing) for empty, missing, or malformed signatures. ### Organizations API diff --git a/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java b/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java new file mode 100644 index 0000000..afd5823 --- /dev/null +++ b/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java @@ -0,0 +1,40 @@ +package io.mailtrap.examples.webhooks; + +import io.mailtrap.webhooks.WebhookSignatures; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +public class WebhookSignatureExample { + + public static void main(final String[] args) throws Exception { + // --- Direct verification (e.g. for unit tests or custom routers) ---- + final String payload = "{\"event\":\"delivery\",\"message_id\":\"abc-123\"}"; + final String signingSecret = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"; + + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + final String signature = HexFormat.of().formatHex( + mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + + assertTrue(WebhookSignatures.verify(payload, signature, signingSecret)); + + // Bad input never throws — it returns false: + assertFalse(WebhookSignatures.verify(payload, "not-hex", signingSecret)); + assertFalse(WebhookSignatures.verify(payload, "", signingSecret)); + } + + private static void assertTrue(final boolean condition) { + if (!condition) { + throw new AssertionError("expected true"); + } + } + + private static void assertFalse(final boolean condition) { + if (condition) { + throw new AssertionError("expected false"); + } + } +} diff --git a/src/main/java/io/mailtrap/webhooks/WebhookSignatures.java b/src/main/java/io/mailtrap/webhooks/WebhookSignatures.java new file mode 100644 index 0000000..44a5427 --- /dev/null +++ b/src/main/java/io/mailtrap/webhooks/WebhookSignatures.java @@ -0,0 +1,106 @@ +package io.mailtrap.webhooks; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Helpers for verifying inbound Mailtrap webhook signatures. + * + *

Mailtrap signs every outbound webhook by computing + * {@code HMAC-SHA256(signing_secret, raw_request_body)} and sending the + * lowercase hex digest in the {@code Mailtrap-Signature} HTTP header. To + * authenticate a webhook on the receiver side, compute the same digest using + * the {@code signing_secret} returned when the webhook was created and compare + * it to the value of the header in constant time. + * + *

The comparison is performed with {@link MessageDigest#isEqual(byte[], byte[])} + * to avoid timing side-channels. + * + *

The method never throws on inputs that could plausibly arrive over the + * wire (empty strings, wrong-length signatures, non-hex characters, missing + * secret) — it simply returns {@code false}. This makes it safe to call + * directly from a request handler without wrapping in try/catch. + * + * @see Mailtrap docs — Verifying the signature + */ +public final class WebhookSignatures { + + /** + * Hex-encoded HMAC-SHA256 signature length (SHA-256 produces 32 bytes / 64 hex chars). + */ + public static final int SIGNATURE_HEX_LENGTH = 64; + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private WebhookSignatures() { + // utility class — not instantiable + } + + /** + * Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload. + * + * @param payload the raw request body, exactly as received. Do not + * parse and re-serialize the JSON — re-encoding may reorder keys or + * alter whitespace and invalidate the signature. With Spring use + * {@code @RequestBody byte[]} or read the body directly from + * {@code HttpServletRequest.getInputStream()} on the webhook route + * so the body is preserved verbatim. + * @param signature the value of the {@code Mailtrap-Signature} HTTP header + * (lowercase hex string). + * @param signingSecret the webhook's {@code signing_secret}, returned by the Webhooks API + * on webhook creation. + * @return {@code true} if the signature is valid for the given payload and secret, + * {@code false} otherwise (including any {@code null}/empty input, + * wrong-length or non-hex signatures). + */ + public static boolean verify(final String payload, final String signature, final String signingSecret) { + if (signature == null || signature.isEmpty()) { + return false; + } + if (signingSecret == null || signingSecret.isEmpty()) { + return false; + } + if (payload == null || payload.isEmpty()) { + return false; + } + if (signature.length() != SIGNATURE_HEX_LENGTH) { + return false; + } + + final byte[] providedBytes; + try { + providedBytes = HexFormat.of().parseHex(signature); + } catch (final IllegalArgumentException e) { + // Non-hex characters in the provided signature — reject without throwing. + return false; + } + + final byte[] expectedBytes; + try { + final Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + expectedBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + } catch (final NoSuchAlgorithmException e) { + // HmacSHA256 is required by every standards-conformant JVM (JCA spec). This + // branch is unreachable in practice — treat it as a fatal misconfiguration. + throw new IllegalStateException("HmacSHA256 algorithm is not available in this JVM", e); + } catch (final java.security.InvalidKeyException e) { + // SecretKeySpec rejects only zero-length keys, which we already guard above. + // Any other InvalidKeyException would indicate a JVM/provider bug. + throw new IllegalStateException("Failed to initialize HmacSHA256 with the provided signing secret", e); + } + + // Guard the byte-length first — MessageDigest.isEqual is constant-time only when + // the inputs have the same length, and we already enforced this via the hex-length + // check above, but reassert defensively in case SIGNATURE_HEX_LENGTH ever changes. + if (expectedBytes.length != providedBytes.length) { + return false; + } + + return MessageDigest.isEqual(expectedBytes, providedBytes); + } +} diff --git a/src/test/java/io/mailtrap/webhooks/WebhookSignaturesTest.java b/src/test/java/io/mailtrap/webhooks/WebhookSignaturesTest.java new file mode 100644 index 0000000..f9ebaae --- /dev/null +++ b/src/test/java/io/mailtrap/webhooks/WebhookSignaturesTest.java @@ -0,0 +1,152 @@ +package io.mailtrap.webhooks; + +import org.junit.jupiter.api.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WebhookSignaturesTest { + + // --------------------------------------------------------------------- + // Cross-SDK shared fixture — DO NOT CHANGE. + // + // The same (payload, signing_secret, expected_signature) triple is + // embedded verbatim in the test suites of every official Mailtrap SDK + // (Ruby, Python, PHP, Node.js, Java, .NET) to guarantee byte-for-byte + // compatibility of the verification algorithm across languages. Keep + // these three strings in sync with the other SDKs. + // --------------------------------------------------------------------- + private static final String FIXTURE_PAYLOAD = + "{\"event\":\"delivery\",\"sending_stream\":\"transactional\",\"category\":\"welcome\"," + + "\"message_id\":\"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f\"," + + "\"email\":\"recipient@example.com\"," + + "\"event_id\":\"f1e2d3c4-b5a6-7890-1234-567890abcdef\"," + + "\"timestamp\":1716070000}"; + private static final String FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e"; + private static final String FIXTURE_EXPECTED_SIGNATURE = + "6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433"; + + // --------------------------------------------------------------------- + // 1. Valid signature → true + // --------------------------------------------------------------------- + @Test + void verify_withValidSignature_returnsTrue() { + assertTrue(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 2. Wrong secret → false + // --------------------------------------------------------------------- + @Test + void verify_withWrongSecret_returnsFalse() { + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, "wrong_secret_value")); + } + + // --------------------------------------------------------------------- + // 3. Payload tampered (one byte changed) → false + // --------------------------------------------------------------------- + @Test + void verify_withTamperedPayload_returnsFalse() { + // Flip "delivery" to "delivere" — same length, different bytes. + final String tampered = FIXTURE_PAYLOAD.replace("\"delivery\"", "\"delivere\""); + assertFalse(WebhookSignatures.verify(tampered, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 4. Signature with wrong length → false (no throw) + // --------------------------------------------------------------------- + @Test + void verify_withSignatureOfWrongLength_returnsFalse() { + final String tooShort = FIXTURE_EXPECTED_SIGNATURE.substring(0, 63); + final String tooLong = FIXTURE_EXPECTED_SIGNATURE + "a"; + + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, tooShort, FIXTURE_SIGNING_SECRET)); + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, tooLong, FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 5. Signature with non-hex characters → false (no throw) + // --------------------------------------------------------------------- + @Test + void verify_withNonHexCharactersInSignature_returnsFalse() { + // Same length (64), but contains 'z' which is not a hex digit. + final String nonHex = "z" + FIXTURE_EXPECTED_SIGNATURE.substring(1); + assertEquals(SIGNATURE_HEX_LENGTH(), nonHex.length()); + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, nonHex, FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 6. Empty signature string → false + // --------------------------------------------------------------------- + @Test + void verify_withEmptySignature_returnsFalse() { + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, "", FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 7. Empty signingSecret → false + // --------------------------------------------------------------------- + @Test + void verify_withEmptySigningSecret_returnsFalse() { + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, "")); + } + + // --------------------------------------------------------------------- + // 8. Empty payload with non-empty signature → false + // --------------------------------------------------------------------- + @Test + void verify_withEmptyPayload_returnsFalse() { + assertFalse(WebhookSignatures.verify("", FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET)); + } + + // --------------------------------------------------------------------- + // 9. Known-good fixture round-trip — independently recompute the HMAC + // in the test (not via the helper) and assert it matches both the + // embedded expected signature AND the helper's verdict. + // --------------------------------------------------------------------- + @Test + void verify_fixtureRoundTrip_matchesIndependentlyComputedHmac() throws Exception { + // Recompute the HMAC-SHA256 independently of the helper, using the JDK + // primitives directly. If this drifts from FIXTURE_EXPECTED_SIGNATURE, + // either the fixture is wrong or the algorithm/encoding has changed. + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec( + FIXTURE_SIGNING_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + final byte[] digest = mac.doFinal(FIXTURE_PAYLOAD.getBytes(StandardCharsets.UTF_8)); + final String computedHex = HexFormat.of().formatHex(digest); + + assertEquals(FIXTURE_EXPECTED_SIGNATURE, computedHex, + "Independently computed HMAC must equal embedded fixture signature"); + + assertTrue(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET), + "Helper must agree the fixture is valid"); + } + + // --------------------------------------------------------------------- + // Bonus: null inputs → false (no NullPointerException) + // --------------------------------------------------------------------- + @Test + void verify_withNullPayload_returnsFalse() { + assertFalse(WebhookSignatures.verify(null, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET)); + } + + @Test + void verify_withNullSignature_returnsFalse() { + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, null, FIXTURE_SIGNING_SECRET)); + } + + @Test + void verify_withNullSigningSecret_returnsFalse() { + assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, null)); + } + + private static int SIGNATURE_HEX_LENGTH() { + return WebhookSignatures.SIGNATURE_HEX_LENGTH; + } +} From 052f754a08478f5d627a5a3f2709232c6fac913a Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 21 May 2026 09:47:39 +0200 Subject: [PATCH 2/2] MT-22022: Simplify example to happy-path verification only --- .../webhooks/WebhookSignatureExample.java | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java b/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java index afd5823..ca2017f 100644 --- a/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java +++ b/examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java @@ -19,22 +19,8 @@ public static void main(final String[] args) throws Exception { final String signature = HexFormat.of().formatHex( mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); - assertTrue(WebhookSignatures.verify(payload, signature, signingSecret)); - - // Bad input never throws — it returns false: - assertFalse(WebhookSignatures.verify(payload, "not-hex", signingSecret)); - assertFalse(WebhookSignatures.verify(payload, "", signingSecret)); - } - - private static void assertTrue(final boolean condition) { - if (!condition) { - throw new AssertionError("expected true"); - } - } - - private static void assertFalse(final boolean condition) { - if (condition) { - throw new AssertionError("expected false"); + if (!WebhookSignatures.verify(payload, signature, signingSecret)) { + throw new IllegalStateException("Signature verification failed!"); } } }