/********************************************************************** * This file is part of iDempiere ERP Open Source * * http://www.idempiere.org * * * * Copyright (C) Contributors * * * * This program is free software; you can redistribute it and/or * * modify it under the terms of the GNU General Public License * * as published by the Free Software Foundation; either version 2 * * of the License, or (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the Free Software * * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * * MA 02110-1301, USA. * * * * Contributors: * * - Trek Global Corporation * * - Murilo Torino * **********************************************************************/ package com.trekglobal.idempiere.rest.api.webhook; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; /** * Standard Webhooks HMAC-SHA256 signing and verification utility. * * Implements the Standard Webhooks specification: * - Signing content: "msgId.timestamp.body" * - Signature format: "v1,<base64>" * - Secret format: "whsec_<base64(key)>" * - Multiple signatures space-delimited for key rotation * * @author muriloht Murilo H. Torquato <murilo@muriloht.com> * @see Standard Webhooks * @see Standard Webhooks Spec */ public class WebhookSignature { public static final String SECRET_PREFIX = "whsec_"; public static final String SIGNATURE_PREFIX = "v1,"; private static final String HMAC_SHA256 = "HmacSHA256"; private static final int MIN_KEY_BYTES = 24; private static final int DEFAULT_KEY_BYTES = 32; private static final long DEFAULT_TOLERANCE_SECONDS = 300; // 5 minutes private WebhookSignature() { } /** * Generate a new webhook secret in Standard Webhooks format. * @return secret string with whsec_ prefix (e.g., "whsec_MfKQ9r8GKYqr...") */ public static String generateSecret() { byte[] key = new byte[DEFAULT_KEY_BYTES]; new SecureRandom().nextBytes(key); return SECRET_PREFIX + Base64.getEncoder().encodeToString(key); } /** * Sign a webhook payload per Standard Webhooks spec. * * @param secret the webhook secret (whsec_ prefixed) * @param msgId the webhook message ID (msg_XXX format) * @param timestamp unix timestamp in seconds * @param body the raw JSON body * @return signature string in "v1," format * @throws IllegalArgumentException if secret format is invalid */ public static String sign(String secret, String msgId, long timestamp, String body) { byte[] key = decodeSecret(secret); String content = msgId + "." + timestamp + "." + body; byte[] signature = hmacSHA256(key, content); return SIGNATURE_PREFIX + Base64.getEncoder().encodeToString(signature); } /** * Verify a webhook signature per Standard Webhooks spec. * * @param secret the webhook secret (whsec_ prefixed) * @param msgId value of webhook-id header * @param timestampStr value of webhook-timestamp header * @param signatureHeader value of webhook-signature header (space-delimited signatures) * @param body the raw request body * @return true if any v1 signature matches * @throws WebhookVerificationException if timestamp is outside tolerance or signature invalid */ public static boolean verify(String secret, String msgId, String timestampStr, String signatureHeader, String body) { return verify(secret, msgId, timestampStr, signatureHeader, body, DEFAULT_TOLERANCE_SECONDS); } /** * Verify a webhook signature with custom tolerance. * * @param secret the webhook secret (whsec_ prefixed) * @param msgId value of webhook-id header * @param timestampStr value of webhook-timestamp header * @param signatureHeader value of webhook-signature header * @param body the raw request body * @param toleranceSeconds max age of timestamp in seconds (0 to skip check) * @return true if signature is valid * @throws WebhookVerificationException on verification failure */ public static boolean verify(String secret, String msgId, String timestampStr, String signatureHeader, String body, long toleranceSeconds) { if (msgId == null || timestampStr == null || signatureHeader == null || body == null) { throw new WebhookVerificationException("Missing required webhook headers"); } long timestamp; try { timestamp = Long.parseLong(timestampStr); } catch (NumberFormatException e) { throw new WebhookVerificationException("Invalid webhook-timestamp: " + timestampStr); } // Replay protection: reject stale timestamps if (toleranceSeconds > 0) { long now = System.currentTimeMillis() / 1000; if (Math.abs(now - timestamp) > toleranceSeconds) { throw new WebhookVerificationException( "Webhook timestamp too old or too far in the future (tolerance: " + toleranceSeconds + "s)"); } } byte[] expectedBytes; try { String expectedSignature = sign(secret, msgId, timestamp, body); expectedBytes = Base64.getDecoder().decode( expectedSignature.substring(SIGNATURE_PREFIX.length())); } catch (IllegalArgumentException e) { throw new WebhookVerificationException("Invalid signature format: " + e.getMessage()); } // Check each space-delimited signature in the header String[] signatures = signatureHeader.split(" "); boolean malformed = false; for (String sig : signatures) { sig = sig.trim(); if (!sig.startsWith(SIGNATURE_PREFIX)) { continue; // Skip non-v1 signatures } try { byte[] actualBytes = Base64.getDecoder().decode(sig.substring(SIGNATURE_PREFIX.length())); if (MessageDigest.isEqual(expectedBytes, actualBytes)) { return true; // Constant-time comparison } } catch (IllegalArgumentException e) { malformed = true; } } if (malformed) { throw new WebhookVerificationException( "Malformed v1 signature in webhook-signature header (invalid base64)"); } throw new WebhookVerificationException("No matching webhook signature found"); } /** * Decode a whsec_-prefixed secret into raw key bytes. */ static byte[] decodeSecret(String secret) { if (secret == null || !secret.startsWith(SECRET_PREFIX)) { throw new IllegalArgumentException( "Webhook secret must start with '" + SECRET_PREFIX + "' prefix"); } byte[] key = Base64.getDecoder().decode(secret.substring(SECRET_PREFIX.length())); if (key.length < MIN_KEY_BYTES) { throw new IllegalArgumentException( "Webhook secret key must be at least " + MIN_KEY_BYTES + " bytes"); } return key; } /** * Compute HMAC-SHA256. */ private static byte[] hmacSHA256(byte[] key, String content) { try { Mac mac = Mac.getInstance(HMAC_SHA256); mac.init(new SecretKeySpec(key, HMAC_SHA256)); return mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("HMAC-SHA256 computation failed", e); } } /** * Build the webhook-signature header value, optionally with dual signatures * for secret rotation. * * @param currentSecret current webhook secret * @param previousSecret previous webhook secret (nullable) * @param msgId message ID * @param timestamp unix timestamp * @param body payload * @return signature header value (space-delimited if two secrets) */ public static String signWithRotation(String currentSecret, String previousSecret, String msgId, long timestamp, String body) { String currentSig = sign(currentSecret, msgId, timestamp, body); if (previousSecret == null || previousSecret.isEmpty()) { return currentSig; } String previousSig = sign(previousSecret, msgId, timestamp, body); return currentSig + " " + previousSig; } /** * Exception thrown when webhook signature verification fails. */ public static class WebhookVerificationException extends RuntimeException { private static final long serialVersionUID = 1L; public WebhookVerificationException(String message) { super(message); } } }