diff --git a/.fernignore b/.fernignore index 3750e30..b570323 100644 --- a/.fernignore +++ b/.fernignore @@ -12,9 +12,11 @@ src/main/java/com/schematic/api/logger/SchematicLogger.java src/main/java/com/schematic/api/resources/accounts/AccountsClient.java src/main/java/com/schematic/api/resources/companies/CompaniesClient.java src/main/java/com/schematic/api/resources/entitlements/EntitlementsClient.java +src/main/java/com/schematic/webhook/ src/test/java/com/schematic/api/TestCache.java src/test/java/com/schematic/api/TestEventBuffer.java src/test/java/com/schematic/api/TestLogger.java src/test/java/com/schematic/api/TestOfflineMode.java src/test/java/com/schematic/api/TestReadme.java src/test/java/com/schematic/api/TestSchematic.java +src/test/java/com/schematic/webhook/ diff --git a/README.md b/README.md index 1a165f1..d46fb53 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,70 @@ user.put("user_id", "your-user-id"); boolean flagValue = schematic.checkFlag("some-flag-key", company, user); ``` +## Webhook Verification + +Schematic can send webhooks to notify your application of events. To ensure the security of these webhooks, Schematic signs each request using HMAC-SHA256. The Java SDK provides utility functions to verify these signatures. + +### Verifying Webhook Signatures + +When your application receives a webhook request from Schematic, you should verify its signature to ensure it's authentic: + +```java +import com.schematic.webhook.WebhookVerifier; +import com.schematic.webhook.WebhookSignatureException; +import java.util.Map; +import java.util.HashMap; +import java.io.BufferedReader; +import java.io.IOException; + +// In your webhook endpoint handler: +public void handleWebhook(HttpServletRequest request, HttpServletResponse response) throws IOException { + // Read the request body + String body = request.getReader().lines().collect(Collectors.joining("\n")); + + // Get the required headers + Map headers = new HashMap<>(); + headers.put(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER, + request.getHeader(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER)); + headers.put(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER, + request.getHeader(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER)); + + String webhookSecret = "your-webhook-secret"; + + try { + // Verify the webhook signature + WebhookVerifier.verifyWebhookSignature(body, headers, webhookSecret); + + // Process the webhook payload + // ... + + response.setStatus(HttpServletResponse.SC_OK); + } catch (WebhookSignatureException e) { + // Handle signature verification failure + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid signature: " + e.getMessage()); + } +} +``` + +### Verifying Signatures Manually + +If you need to verify a webhook signature outside of the context of a servlet request, you can use the `verifySignature` method: + +```java +import com.schematic.webhook.WebhookVerifier; +import com.schematic.webhook.WebhookSignatureException; + +public void verifyWebhookManually(String body, String signature, String timestamp, String secret) { + try { + WebhookVerifier.verifySignature(body, signature, timestamp, secret); + System.out.println("Signature verification successful!"); + } catch (WebhookSignatureException e) { + System.out.println("Signature verification failed: " + e.getMessage()); + } +} +``` + ## Configuration Options There are a number of configuration options that can be specified using the builder when instantiating the Schematic client. diff --git a/src/main/java/com/schematic/webhook/WebhookSignatureException.java b/src/main/java/com/schematic/webhook/WebhookSignatureException.java new file mode 100644 index 0000000..37eef89 --- /dev/null +++ b/src/main/java/com/schematic/webhook/WebhookSignatureException.java @@ -0,0 +1,26 @@ +package com.schematic.webhook; + +/** + * Exception thrown when webhook signature verification fails. + */ +public class WebhookSignatureException extends RuntimeException { + + /** + * Constructs a new WebhookSignatureException with the specified detail message. + * + * @param message the detail message + */ + public WebhookSignatureException(String message) { + super(message); + } + + /** + * Constructs a new WebhookSignatureException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause + */ + public WebhookSignatureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/schematic/webhook/WebhookVerifier.java b/src/main/java/com/schematic/webhook/WebhookVerifier.java new file mode 100644 index 0000000..bd5bd8f --- /dev/null +++ b/src/main/java/com/schematic/webhook/WebhookVerifier.java @@ -0,0 +1,179 @@ +package com.schematic.webhook; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; + +/** + * Utilities for verifying the signatures of Schematic webhooks. + *

+ * Schematic signs webhook payloads using HMAC-SHA256. This class provides methods + * to verify these signatures. + */ +public class WebhookVerifier { + + /** + * Header containing the webhook signature. + */ + public static final String WEBHOOK_SIGNATURE_HEADER = "X-Schematic-Webhook-Signature"; + + /** + * Header containing the webhook timestamp. + */ + public static final String WEBHOOK_TIMESTAMP_HEADER = "X-Schematic-Webhook-Timestamp"; + + private static final String HMAC_SHA256 = "HmacSHA256"; + + /** + * Verifies the signature of a webhook request. + * + * @param body The request body as a string + * @param headers Map of HTTP headers + * @param secret The webhook secret + * @throws WebhookSignatureException if the signature is invalid + */ + public static void verifyWebhookSignature(String body, Map headers, String secret) + throws WebhookSignatureException { + + // Extract signature and timestamp headers + String signature = headers.get(WEBHOOK_SIGNATURE_HEADER); + String timestamp = headers.get(WEBHOOK_TIMESTAMP_HEADER); + + // Verify signature + verifySignature(body, signature, timestamp, secret); + } + + /** + * Verifies the signature of a webhook payload. + * + * @param body The webhook payload + * @param signature The signature header value + * @param timestamp The timestamp header value + * @param secret The webhook secret + * @throws WebhookSignatureException if the signature is invalid + */ + public static void verifySignature(String body, String signature, String timestamp, String secret) + throws WebhookSignatureException { + + if (signature == null || signature.isEmpty()) { + throw new WebhookSignatureException("Missing webhook signature"); + } + + if (timestamp == null || timestamp.isEmpty()) { + throw new WebhookSignatureException("Missing webhook timestamp"); + } + + // Compute expected signature + String expectedSignature = computeHexSignature(body, timestamp, secret); + + // Compare signatures using constant-time comparison + if (!constantTimeEquals(hexToBytes(expectedSignature), hexToBytes(signature))) { + throw new WebhookSignatureException("Invalid signature"); + } + } + + /** + * Computes the hex-encoded HMAC-SHA256 signature for a webhook payload. + * + * @param body The webhook payload + * @param timestamp The timestamp + * @param secret The webhook secret + * @return The hex-encoded signature + * @throws WebhookSignatureException if an error occurs during signature computation + */ + public static String computeHexSignature(String body, String timestamp, String secret) + throws WebhookSignatureException { + + byte[] signature = computeSignature(body, timestamp, secret); + return bytesToHex(signature); + } + + /** + * Computes the HMAC-SHA256 signature for a webhook payload. + * + * @param body The webhook payload + * @param timestamp The timestamp + * @param secret The webhook secret + * @return The signature bytes + * @throws WebhookSignatureException if an error occurs during signature computation + */ + public static byte[] computeSignature(String body, String timestamp, String secret) + throws WebhookSignatureException { + + try { + // Create message by concatenating body and timestamp + String message = body + "+" + timestamp; + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + + // Create HMAC-SHA256 instance + SecretKeySpec keySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + HMAC_SHA256 + ); + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(keySpec); + + // Compute and return signature + return mac.doFinal(messageBytes); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new WebhookSignatureException("Error computing signature", e); + } + } + + /** + * Converts a byte array to a hex string. + * + * @param bytes The byte array + * @return The hex string + */ + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + /** + * Converts a hex string to a byte array. + * + * @param hex The hex string + * @return The byte array + * @throws WebhookSignatureException if the hex string is invalid + */ + private static byte[] hexToBytes(String hex) throws WebhookSignatureException { + try { + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return data; + } catch (Exception e) { + throw new WebhookSignatureException("Invalid signature format", e); + } + } + + /** + * Compares two byte arrays in constant time to prevent timing attacks. + * + * @param a First byte array + * @param b Second byte array + * @return true if the arrays are equal, false otherwise + */ + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } +} diff --git a/src/main/java/com/schematic/webhook/server/WebhookTestServer.java b/src/main/java/com/schematic/webhook/server/WebhookTestServer.java new file mode 100644 index 0000000..3b6d099 --- /dev/null +++ b/src/main/java/com/schematic/webhook/server/WebhookTestServer.java @@ -0,0 +1,206 @@ +package com.schematic.webhook.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.schematic.webhook.WebhookSignatureException; +import com.schematic.webhook.WebhookVerifier; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * A simple HTTP server for testing Schematic webhooks. + * This server listens for webhook requests, verifies their signatures, and logs the payloads. + */ +public class WebhookTestServer { + + private final int port; + private final String secret; + private final HttpServer server; + private final ObjectMapper objectMapper; + + /** + * Creates a new WebhookTestServer. + * + * @param port The port to listen on + * @param secret The webhook secret for signature verification + * @throws IOException if the server cannot be created + */ + public WebhookTestServer(int port, String secret) throws IOException { + this.port = port; + this.secret = secret; + this.objectMapper = new ObjectMapper(); + this.server = HttpServer.create(new InetSocketAddress(port), 0); + + server.createContext("/webhook", new WebhookHandler()); + server.setExecutor(null); // Use default executor + } + + /** + * Starts the server. + */ + public void start() { + server.start(); + System.out.println("Webhook test server started on port " + port); + System.out.println("Ready to receive webhooks at http://localhost:" + port + "/webhook"); + if (secret != null && !secret.isEmpty()) { + System.out.println("Using webhook secret: " + secret); + } else { + System.out.println("WARNING: No webhook secret provided. Signature verification will be skipped."); + } + System.out.println("Press Ctrl+C to stop"); + } + + /** + * Stops the server. + */ + public void stop() { + server.stop(0); + System.out.println("Server stopped"); + } + + /** + * Handler for webhook requests. + */ + private class WebhookHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equals("POST")) { + sendResponse(exchange, 405, "Method not allowed"); + return; + } + + // Read request body + String body = new BufferedReader( + new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.joining("\n")); + + // Log headers + System.out.println("\nHeaders:"); + exchange.getRequestHeaders().forEach((key, values) -> + System.out.println(" " + key + ": " + String.join(", ", values))); + + // Verify signature if secret is provided + if (secret != null && !secret.isEmpty()) { + String signature = exchange.getRequestHeaders() + .getFirst(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER); + String timestamp = exchange.getRequestHeaders() + .getFirst(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER); + + try { + WebhookVerifier.verifySignature(body, signature, timestamp, secret); + System.out.println("✅ Signature verification successful!"); + } catch (WebhookSignatureException e) { + System.out.println("❌ Signature verification failed: " + e.getMessage()); + + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + + sendResponse(exchange, 401, objectMapper.writeValueAsString(error)); + return; + } + } else { + System.out.println("⚠️ No webhook secret provided, skipping signature verification"); + } + + // Log payload + try { + Object json = objectMapper.readValue(body, Object.class); + System.out.println("\nPayload:"); + System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json)); + } catch (Exception e) { + System.out.println("\nRaw body (not JSON):"); + System.out.println(body); + } + + // Send success response + Map response = new HashMap<>(); + response.put("status", "success"); + + sendResponse(exchange, 200, objectMapper.writeValueAsString(response)); + } + + private void sendResponse(HttpExchange exchange, int statusCode, String body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, body.length()); + + try (OutputStream os = exchange.getResponseBody()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + } + + /** + * Main method to run the server. + * + * @param args Command line arguments + */ + public static void main(String[] args) { + int port = 8080; + String secret = System.getenv("SCHEMATIC_WEBHOOK_SECRET"); + + // Parse command line arguments + for (int i = 0; i < args.length; i++) { + if (args[i].equals("--port") && i + 1 < args.length) { + try { + port = Integer.parseInt(args[i + 1]); + i++; + } catch (NumberFormatException e) { + System.err.println("Invalid port number: " + args[i + 1]); + System.exit(1); + } + } else if (args[i].equals("--secret") && i + 1 < args.length) { + secret = args[i + 1]; + i++; + } else if (args[i].equals("--help")) { + printUsage(); + System.exit(0); + } + } + + try { + WebhookTestServer server = new WebhookTestServer(port, secret); + server.start(); + + // Add shutdown hook to stop server gracefully + Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); + } catch (IOException e) { + System.err.println("Error starting server: " + e.getMessage()); + System.exit(1); + } + } + + private static void printUsage() { + System.out.println("Webhook Test Server for Schematic"); + System.out.println(); + System.out.println("Usage: java -cp com.schematic.webhook.server.WebhookTestServer [options]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --port PORT Port to listen on (default: 8080)"); + System.out.println(" --secret SECRET Webhook secret (default: reads from SCHEMATIC_WEBHOOK_SECRET env var)"); + System.out.println(" --help Print this help message"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" java -cp com.schematic.webhook.server.WebhookTestServer --port 8080 --secret my_webhook_secret"); + System.out.println(); + System.out.println(" # Or using environment variables:"); + System.out.println(" export SCHEMATIC_WEBHOOK_SECRET=my_webhook_secret"); + System.out.println(" java -cp com.schematic.webhook.server.WebhookTestServer"); + System.out.println(); + System.out.println("Notes:"); + System.out.println(" For testing with Schematic, you can use a tool like ngrok to expose"); + System.out.println(" your local server to the internet."); + } +} diff --git a/src/test/java/com/schematic/webhook/WebhookVerifierTest.java b/src/test/java/com/schematic/webhook/WebhookVerifierTest.java new file mode 100644 index 0000000..a5a48f8 --- /dev/null +++ b/src/test/java/com/schematic/webhook/WebhookVerifierTest.java @@ -0,0 +1,106 @@ +package com.schematic.webhook; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WebhookVerifierTest { + + private static final String WEBHOOK_SECRET = "test_secret"; + private static final String PAYLOAD = "{\"event\":\"test_event\"}"; + private static final String TIMESTAMP = "1234567890"; + + private String validSignature; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + validSignature = WebhookVerifier.computeHexSignature(PAYLOAD, TIMESTAMP, WEBHOOK_SECRET); + } + + @Test + void testComputeHexSignature() { + String signature = WebhookVerifier.computeHexSignature(PAYLOAD, TIMESTAMP, WEBHOOK_SECRET); + assertNotNull(signature); + assertFalse(signature.isEmpty()); + } + + @Test + void testVerifySignature_ValidSignature() { + // Should not throw an exception + WebhookVerifier.verifySignature(PAYLOAD, validSignature, TIMESTAMP, WEBHOOK_SECRET); + } + + @Test + void testVerifySignature_InvalidSignature() { + // Invalid signature + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, "invalid", TIMESTAMP, WEBHOOK_SECRET)); + } + + @Test + void testVerifySignature_MissingSignature() { + // Missing signature + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, null, TIMESTAMP, WEBHOOK_SECRET)); + + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, "", TIMESTAMP, WEBHOOK_SECRET)); + } + + @Test + void testVerifySignature_MissingTimestamp() { + // Missing timestamp + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, validSignature, null, WEBHOOK_SECRET)); + + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, validSignature, "", WEBHOOK_SECRET)); + } + + @Test + void testVerifySignature_TamperedPayload() { + // Tampered payload + String tamperedPayload = "{\"event\":\"tampered_event\"}"; + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(tamperedPayload, validSignature, TIMESTAMP, WEBHOOK_SECRET)); + } + + @Test + void testVerifyWebhookSignature_ValidSignature() { + // Setup headers + Map headers = new HashMap<>(); + headers.put(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER, validSignature); + headers.put(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER, TIMESTAMP); + + // Should not throw an exception + WebhookVerifier.verifyWebhookSignature(PAYLOAD, headers, WEBHOOK_SECRET); + } + + @Test + void testVerifyWebhookSignature_InvalidSignature() { + // Setup headers with invalid signature + Map headers = new HashMap<>(); + headers.put(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER, "invalid"); + headers.put(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER, TIMESTAMP); + + // Should throw an exception + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifyWebhookSignature(PAYLOAD, headers, WEBHOOK_SECRET)); + } + + @Test + void testVerifySignature_WrongSecret() { + // Generate signature using correct secret but verify with wrong secret + String wrongSecret = "wrong_secret"; + + // Should throw an exception + assertThrows(WebhookSignatureException.class, () -> + WebhookVerifier.verifySignature(PAYLOAD, validSignature, TIMESTAMP, wrongSecret)); + } +}