Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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)));

if (!WebhookSignatures.verify(payload, signature, signingSecret)) {
throw new IllegalStateException("Signature verification failed!");
}
}
}
106 changes: 106 additions & 0 deletions src/main/java/io/mailtrap/webhooks/WebhookSignatures.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>The comparison is performed with {@link MessageDigest#isEqual(byte[], byte[])}
* to avoid timing side-channels.
*
* <p>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 <a href="https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature">Mailtrap docs — Verifying the signature</a>
*/
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. <strong>Do not</strong>
* 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);
}
}
152 changes: 152 additions & 0 deletions src/test/java/io/mailtrap/webhooks/WebhookSignaturesTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading