package com.stripe.net; import com.stripe.exception.SignatureVerificationException; import com.stripe.model.Event; import com.stripe.model.StripeObject; import java.util.ArrayList; import java.util.List; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; public final class Webhook { private static final long DEFAULT_TOLERANCE = 300; /** * Returns an Event instance using the provided JSON payload. Throws a * JsonSyntaxException if the payload is not valid JSON, and a * SignatureVerificationException if the signature verification fails for * any reason. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. * @param secret secret used to generate the signature. * @return the Event instance * @throws SignatureVerificationException if the verification fails. */ public static Event constructEvent(String payload, String sigHeader, String secret) throws SignatureVerificationException { return constructEvent(payload, sigHeader, secret, DEFAULT_TOLERANCE); } /** * Returns an Event instance using the provided JSON payload. Throws a * JsonSyntaxException if the payload is not valid JSON, and a * SignatureVerificationException if the signature verification fails for * any reason. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. * @param secret secret used to generate the signature. * @param tolerance maximum difference allowed between the header's * timestamp and the current time * @return the Event instance * @throws SignatureVerificationException if the verification fails. */ public static Event constructEvent(String payload, String sigHeader, String secret, long tolerance) throws SignatureVerificationException { Event event = StripeObject.PRETTY_PRINT_GSON.fromJson(payload, Event.class); Signature.verifyHeader(payload, sigHeader, secret, tolerance); return event; } public static final class Signature { public static final String EXPECTED_SCHEME = "v1"; /** * Verifies the signature header sent by Stripe. Throws a * SignatureVerificationException if the verification fails for any reason. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. * @param secret secret used to generate the signature. * @param tolerance maximum difference allowed between the header's * timestamp and the current time * @throws SignatureVerificationException if the verification fails. */ public static boolean verifyHeader(String payload, String sigHeader, String secret, long tolerance) throws SignatureVerificationException { // Get timestamp and signatures from header long timestamp = getTimestamp(sigHeader); List<String> signatures = getSignatures(sigHeader, EXPECTED_SCHEME); if (timestamp <= 0) { throw new SignatureVerificationException("Unable to extract timestamp and signatures from header", sigHeader); } if (signatures.size() == 0) { throw new SignatureVerificationException("No signatures found with expected scheme", sigHeader); } // Compute expected signature String signedPayload = String.format("%d.%s", timestamp, payload); String expectedSignature; try { expectedSignature = computeSignature(signedPayload, secret); } catch (Exception e) { throw new SignatureVerificationException("Unable to compute signature for payload", sigHeader); } // Check if expected signature is found in list of header's signatures Boolean signatureFound = false; for (String signature : signatures) { if (Util.secureCompare(expectedSignature, signature)) { signatureFound = true; break; } } if (!signatureFound) { throw new SignatureVerificationException("No signatures found matching the expected signature for payload", sigHeader); } // Check tolerance if ((tolerance > 0) && (timestamp < (Util.getTimeNow() - tolerance))) { throw new SignatureVerificationException("Timestamp outside the tolerance zone", sigHeader); } return true; } /** * Extracts the timestamp in a signature header. * * @param sigHeader the signature header * @return the timestamp contained in the header. */ private static long getTimestamp(String sigHeader) { String[] items = sigHeader.split(","); for (String item : items) { String[] itemParts = item.split("=", 2); if (itemParts[0].equals("t")) { return Long.parseLong(itemParts[1]); } } return -1; } /** * Extracts the signatures matching a given scheme in a signature header. * * @param sigHeader the signature header * @param scheme the signature scheme to look for. * @return the list of signatures matching the provided scheme. */ private static List<String> getSignatures(String sigHeader, String scheme) { List<String> signatures = new ArrayList<String>(); String[] items = sigHeader.split(","); for (String item : items) { String[] itemParts = item.split("=", 2); if (itemParts[0].equals(scheme)) { signatures.add(itemParts[1]); } } return signatures; } /** * Computes the signature for a given payload and secret. * * The current scheme used by Stripe ("v1") is HMAC/SHA-256. * * @param payload the payload to sign. * @param secret the secret used to generate the signature. * @return the signature as a string. */ private static String computeSignature(String payload, String secret) throws NoSuchAlgorithmException, InvalidKeyException { return Util.computeHmacSHA256(secret, payload); } } public static final class Util { /** * Computes the HMAC/SHA-256 code for a given key and message. * * @param key the key used to generate the code. * @param message the message. * @return the code as a string. */ public static String computeHmacSHA256(String key, String message) throws NoSuchAlgorithmException, InvalidKeyException { Mac hasher = Mac.getInstance("HmacSHA256"); hasher.init(new SecretKeySpec(key.getBytes(), "HmacSHA256")); byte[] hash = hasher.doFinal(message.getBytes()); String result = ""; for (byte b : hash) { result += Integer.toString((b & 0xff) + 0x100, 16).substring(1); } return result; } /** * Compares two strings for equality. The time taken is independent of the * number of characters that match. * * Java actually has MessageDigest.isEqual() for this, but the * implementation in very old Java 6 versions does not protect against * timing attacks. Once we drop support for Java 6, we'll be able to just * use MessageDigest.isEqual(). * * @param a one of the strings to compare. * @param b the other string to compare. * @return true if the strings are equal, false otherwise. */ public static boolean secureCompare(String a, String b) { byte[] digesta = a.getBytes(); byte[] digestb = b.getBytes(); if (digesta.length != digestb.length) { return false; } int result = 0; for (int i = 0; i < digesta.length; i++) { result |= digesta[i] ^ digestb[i]; } return result == 0; } /** * Returns the current UTC timestamp in seconds. * * @return the timestamp as a long. */ public static long getTimeNow() { long time = (long)(System.currentTimeMillis() / 1000L); return time; } } }