Skip to content

Commit 6a6e43b

Browse files
authored
Merge pull request #53 from mailtrap/MT-22022-webhook-signature-verification
MT-22022: Add webhook signature verification helper
2 parents ac731b2 + 75cd558 commit 6a6e43b

4 files changed

Lines changed: 296 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ You can find the [Mailtrap Java API reference](https://mailtrap.github.io/mailtr
334334
- [Billing](examples/java/io/mailtrap/examples/general/BillingExample.java)
335335
- [API Tokens](examples/java/io/mailtrap/examples/general/ApiTokensExample.java)
336336
- [Webhooks](examples/java/io/mailtrap/examples/webhooks/WebhooksExample.java)
337+
- [Verifying webhook signatures](examples/java/io/mailtrap/examples/webhooks/WebhookSignatureExample.java)
337338

338339
### Organizations API
339340

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.mailtrap.examples.webhooks;
2+
3+
import com.sun.net.httpserver.HttpServer;
4+
import io.mailtrap.webhooks.WebhookSignatures;
5+
6+
import java.io.IOException;
7+
import java.io.InputStream;
8+
import java.net.InetSocketAddress;
9+
import java.nio.charset.StandardCharsets;
10+
11+
public class WebhookSignatureExample {
12+
13+
public static void main(final String[] args) throws IOException {
14+
final String signingSecret = System.getenv("MAILTRAP_WEBHOOK_SIGNING_SECRET");
15+
16+
final HttpServer server = HttpServer.create(new InetSocketAddress(9292), 0);
17+
server.createContext("/webhooks/mailtrap", exchange -> {
18+
// Use the raw request body — parsing and re-serializing the JSON may
19+
// reorder keys or alter whitespace and invalidate the signature.
20+
final String payload;
21+
try (InputStream body = exchange.getRequestBody()) {
22+
payload = new String(body.readAllBytes(), StandardCharsets.UTF_8);
23+
}
24+
final String signature = exchange.getRequestHeaders().getFirst("Mailtrap-Signature");
25+
26+
if (!WebhookSignatures.verify(payload, signature, signingSecret)) {
27+
exchange.sendResponseHeaders(401, -1);
28+
exchange.close();
29+
return;
30+
}
31+
32+
exchange.sendResponseHeaders(200, -1);
33+
exchange.close();
34+
});
35+
server.start();
36+
}
37+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.mailtrap.webhooks;
2+
3+
import javax.crypto.Mac;
4+
import javax.crypto.spec.SecretKeySpec;
5+
import java.nio.charset.StandardCharsets;
6+
import java.security.MessageDigest;
7+
import java.security.NoSuchAlgorithmException;
8+
import java.util.HexFormat;
9+
10+
/**
11+
* Helpers for verifying inbound Mailtrap webhook signatures.
12+
*
13+
* <p>Mailtrap signs every outbound webhook by computing
14+
* {@code HMAC-SHA256(signing_secret, raw_request_body)} and sending the
15+
* lowercase hex digest in the {@code Mailtrap-Signature} HTTP header. To
16+
* authenticate a webhook on the receiver side, compute the same digest using
17+
* the {@code signing_secret} returned when the webhook was created and compare
18+
* it to the value of the header in constant time.
19+
*
20+
* <p>The comparison is performed with {@link MessageDigest#isEqual(byte[], byte[])}
21+
* to avoid timing side-channels.
22+
*
23+
* <p>The method never throws on inputs that could plausibly arrive over the
24+
* wire (empty strings, wrong-length signatures, non-hex characters, missing
25+
* secret) — it simply returns {@code false}. This makes it safe to call
26+
* directly from a request handler without wrapping in try/catch.
27+
*
28+
* @see <a href="https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature">Mailtrap docs — Verifying the signature</a>
29+
*/
30+
public final class WebhookSignatures {
31+
32+
/**
33+
* Hex-encoded HMAC-SHA256 signature length (SHA-256 produces 32 bytes / 64 hex chars).
34+
*/
35+
public static final int SIGNATURE_HEX_LENGTH = 64;
36+
37+
private static final String HMAC_ALGORITHM = "HmacSHA256";
38+
39+
private WebhookSignatures() {
40+
// utility class — not instantiable
41+
}
42+
43+
/**
44+
* Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload.
45+
*
46+
* @param payload the raw request body, exactly as received. <strong>Do not</strong>
47+
* parse and re-serialize the JSON — re-encoding may reorder keys or
48+
* alter whitespace and invalidate the signature. With Spring use
49+
* {@code @RequestBody byte[]} or read the body directly from
50+
* {@code HttpServletRequest.getInputStream()} on the webhook route
51+
* so the body is preserved verbatim.
52+
* @param signature the value of the {@code Mailtrap-Signature} HTTP header
53+
* (lowercase hex string).
54+
* @param signingSecret the webhook's {@code signing_secret}, returned by the Webhooks API
55+
* on webhook creation.
56+
* @return {@code true} if the signature is valid for the given payload and secret,
57+
* {@code false} otherwise (including any {@code null}/empty input,
58+
* wrong-length or non-hex signatures).
59+
*/
60+
public static boolean verify(final String payload, final String signature, final String signingSecret) {
61+
if (signature == null || signature.isEmpty()) {
62+
return false;
63+
}
64+
if (signingSecret == null || signingSecret.isEmpty()) {
65+
return false;
66+
}
67+
if (payload == null || payload.isEmpty()) {
68+
return false;
69+
}
70+
if (signature.length() != SIGNATURE_HEX_LENGTH) {
71+
return false;
72+
}
73+
74+
final byte[] providedBytes;
75+
try {
76+
providedBytes = HexFormat.of().parseHex(signature);
77+
} catch (final IllegalArgumentException e) {
78+
// Non-hex characters in the provided signature — reject without throwing.
79+
return false;
80+
}
81+
82+
final byte[] expectedBytes;
83+
try {
84+
final Mac mac = Mac.getInstance(HMAC_ALGORITHM);
85+
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
86+
expectedBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
87+
} catch (final NoSuchAlgorithmException e) {
88+
// HmacSHA256 is required by every standards-conformant JVM (JCA spec). This
89+
// branch is unreachable in practice — treat it as a fatal misconfiguration.
90+
throw new IllegalStateException("HmacSHA256 algorithm is not available in this JVM", e);
91+
} catch (final java.security.InvalidKeyException e) {
92+
// SecretKeySpec rejects only zero-length keys, which we already guard above.
93+
// Any other InvalidKeyException would indicate a JVM/provider bug.
94+
throw new IllegalStateException("Failed to initialize HmacSHA256 with the provided signing secret", e);
95+
}
96+
97+
// Guard the byte-length first — MessageDigest.isEqual is constant-time only when
98+
// the inputs have the same length, and we already enforced this via the hex-length
99+
// check above, but reassert defensively in case SIGNATURE_HEX_LENGTH ever changes.
100+
if (expectedBytes.length != providedBytes.length) {
101+
return false;
102+
}
103+
104+
return MessageDigest.isEqual(expectedBytes, providedBytes);
105+
}
106+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package io.mailtrap.webhooks;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import javax.crypto.Mac;
6+
import javax.crypto.spec.SecretKeySpec;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.HexFormat;
9+
10+
import static org.junit.jupiter.api.Assertions.assertEquals;
11+
import static org.junit.jupiter.api.Assertions.assertFalse;
12+
import static org.junit.jupiter.api.Assertions.assertTrue;
13+
14+
class WebhookSignaturesTest {
15+
16+
// ---------------------------------------------------------------------
17+
// Cross-SDK shared fixture — DO NOT CHANGE.
18+
//
19+
// The same (payload, signing_secret, expected_signature) triple is
20+
// embedded verbatim in the test suites of every official Mailtrap SDK
21+
// (Ruby, Python, PHP, Node.js, Java, .NET) to guarantee byte-for-byte
22+
// compatibility of the verification algorithm across languages. Keep
23+
// these three strings in sync with the other SDKs.
24+
// ---------------------------------------------------------------------
25+
private static final String FIXTURE_PAYLOAD =
26+
"{\"event\":\"delivery\",\"sending_stream\":\"transactional\",\"category\":\"welcome\","
27+
+ "\"message_id\":\"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f\","
28+
+ "\"email\":\"recipient@example.com\","
29+
+ "\"event_id\":\"f1e2d3c4-b5a6-7890-1234-567890abcdef\","
30+
+ "\"timestamp\":1716070000}";
31+
private static final String FIXTURE_SIGNING_SECRET = "8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e";
32+
private static final String FIXTURE_EXPECTED_SIGNATURE =
33+
"6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433";
34+
35+
// ---------------------------------------------------------------------
36+
// 1. Valid signature → true
37+
// ---------------------------------------------------------------------
38+
@Test
39+
void verify_withValidSignature_returnsTrue() {
40+
assertTrue(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET));
41+
}
42+
43+
// ---------------------------------------------------------------------
44+
// 2. Wrong secret → false
45+
// ---------------------------------------------------------------------
46+
@Test
47+
void verify_withWrongSecret_returnsFalse() {
48+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, "wrong_secret_value"));
49+
}
50+
51+
// ---------------------------------------------------------------------
52+
// 3. Payload tampered (one byte changed) → false
53+
// ---------------------------------------------------------------------
54+
@Test
55+
void verify_withTamperedPayload_returnsFalse() {
56+
// Flip "delivery" to "delivere" — same length, different bytes.
57+
final String tampered = FIXTURE_PAYLOAD.replace("\"delivery\"", "\"delivere\"");
58+
assertFalse(WebhookSignatures.verify(tampered, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET));
59+
}
60+
61+
// ---------------------------------------------------------------------
62+
// 4. Signature with wrong length → false (no throw)
63+
// ---------------------------------------------------------------------
64+
@Test
65+
void verify_withSignatureOfWrongLength_returnsFalse() {
66+
final String tooShort = FIXTURE_EXPECTED_SIGNATURE.substring(0, 63);
67+
final String tooLong = FIXTURE_EXPECTED_SIGNATURE + "a";
68+
69+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, tooShort, FIXTURE_SIGNING_SECRET));
70+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, tooLong, FIXTURE_SIGNING_SECRET));
71+
}
72+
73+
// ---------------------------------------------------------------------
74+
// 5. Signature with non-hex characters → false (no throw)
75+
// ---------------------------------------------------------------------
76+
@Test
77+
void verify_withNonHexCharactersInSignature_returnsFalse() {
78+
// Same length (64), but contains 'z' which is not a hex digit.
79+
final String nonHex = "z" + FIXTURE_EXPECTED_SIGNATURE.substring(1);
80+
assertEquals(SIGNATURE_HEX_LENGTH(), nonHex.length());
81+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, nonHex, FIXTURE_SIGNING_SECRET));
82+
}
83+
84+
// ---------------------------------------------------------------------
85+
// 6. Empty signature string → false
86+
// ---------------------------------------------------------------------
87+
@Test
88+
void verify_withEmptySignature_returnsFalse() {
89+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, "", FIXTURE_SIGNING_SECRET));
90+
}
91+
92+
// ---------------------------------------------------------------------
93+
// 7. Empty signingSecret → false
94+
// ---------------------------------------------------------------------
95+
@Test
96+
void verify_withEmptySigningSecret_returnsFalse() {
97+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, ""));
98+
}
99+
100+
// ---------------------------------------------------------------------
101+
// 8. Empty payload with non-empty signature → false
102+
// ---------------------------------------------------------------------
103+
@Test
104+
void verify_withEmptyPayload_returnsFalse() {
105+
assertFalse(WebhookSignatures.verify("", FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET));
106+
}
107+
108+
// ---------------------------------------------------------------------
109+
// 9. Known-good fixture round-trip — independently recompute the HMAC
110+
// in the test (not via the helper) and assert it matches both the
111+
// embedded expected signature AND the helper's verdict.
112+
// ---------------------------------------------------------------------
113+
@Test
114+
void verify_fixtureRoundTrip_matchesIndependentlyComputedHmac() throws Exception {
115+
// Recompute the HMAC-SHA256 independently of the helper, using the JDK
116+
// primitives directly. If this drifts from FIXTURE_EXPECTED_SIGNATURE,
117+
// either the fixture is wrong or the algorithm/encoding has changed.
118+
final Mac mac = Mac.getInstance("HmacSHA256");
119+
mac.init(new SecretKeySpec(
120+
FIXTURE_SIGNING_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
121+
final byte[] digest = mac.doFinal(FIXTURE_PAYLOAD.getBytes(StandardCharsets.UTF_8));
122+
final String computedHex = HexFormat.of().formatHex(digest);
123+
124+
assertEquals(FIXTURE_EXPECTED_SIGNATURE, computedHex,
125+
"Independently computed HMAC must equal embedded fixture signature");
126+
127+
assertTrue(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET),
128+
"Helper must agree the fixture is valid");
129+
}
130+
131+
// ---------------------------------------------------------------------
132+
// Bonus: null inputs → false (no NullPointerException)
133+
// ---------------------------------------------------------------------
134+
@Test
135+
void verify_withNullPayload_returnsFalse() {
136+
assertFalse(WebhookSignatures.verify(null, FIXTURE_EXPECTED_SIGNATURE, FIXTURE_SIGNING_SECRET));
137+
}
138+
139+
@Test
140+
void verify_withNullSignature_returnsFalse() {
141+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, null, FIXTURE_SIGNING_SECRET));
142+
}
143+
144+
@Test
145+
void verify_withNullSigningSecret_returnsFalse() {
146+
assertFalse(WebhookSignatures.verify(FIXTURE_PAYLOAD, FIXTURE_EXPECTED_SIGNATURE, null));
147+
}
148+
149+
private static int SIGNATURE_HEX_LENGTH() {
150+
return WebhookSignatures.SIGNATURE_HEX_LENGTH;
151+
}
152+
}

0 commit comments

Comments
 (0)