// Merchant Integration · signing toolkit for Java 11+ (JDK only). // Signs requests you send to the Merchant API and verifies the Wallet API requests we send to you. // Spec: Integration Guide › Authentication. Self-test against the published test vector: // java MerchantSign.java import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.TreeMap; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; public final class MerchantSign { public static final String HEADER_MERCHANT_CODE = "X-Merchant-Code"; public static final String HEADER_REQUEST_ID = "X-Request-Id"; public static final String HEADER_TIMESTAMP = "X-Timestamp"; public static final String HEADER_SIGNATURE = "X-Signature"; public static final long TIMESTAMP_WINDOW_MS = 300_000L; // |now - X-Timestamp| must be within 300 s public static final String OK = "OK"; public static final String INVALID_SIGNATURE = "INVALID_SIGNATURE"; public static final String TIMESTAMP_EXPIRED = "TIMESTAMP_EXPIRED"; private static final SecureRandom RANDOM = new SecureRandom(); private MerchantSign() {} /** Lower-case hex SHA-256 of the exact body bytes (empty body included). */ public static String bodySha256(byte[] rawBody) { try { return hex(MessageDigest.getInstance("SHA-256").digest(rawBody)); } catch (Exception e) { throw new IllegalStateException(e); } } public static String stringToSign(String method, String path, String merchantCode, String timestamp, String requestId, byte[] rawBody) { return String.join("\n", method.toUpperCase(Locale.ROOT), path, merchantCode, timestamp, requestId, bodySha256(rawBody)); } /** X-Signature: lower-case hex HMAC-SHA256(secretKey, stringToSign). */ public static String sign(String secretKey, String method, String path, String merchantCode, String timestamp, String requestId, byte[] rawBody) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] msg = stringToSign(method, path, merchantCode, timestamp, requestId, rawBody).getBytes(StandardCharsets.UTF_8); return hex(mac.doFinal(msg)); } catch (Exception e) { throw new IllegalStateException(e); } } /** A fresh X-Request-Id (32 hex characters). Use a new one for every request, retries included. */ public static String newRequestId() { byte[] b = new byte[16]; RANDOM.nextBytes(b); return hex(b); } /** Headers for one signed request. Send exactly rawBody — the bytes that were signed. */ public static Map signedHeaders(String merchantCode, String secretKey, String method, String path, byte[] rawBody) { return signedHeaders(merchantCode, secretKey, method, path, rawBody, System.currentTimeMillis(), newRequestId()); } public static Map signedHeaders(String merchantCode, String secretKey, String method, String path, byte[] rawBody, long nowMs, String requestId) { String ts = Long.toString(nowMs); Map h = new LinkedHashMap<>(); h.put("Content-Type", "application/json; charset=utf-8"); h.put(HEADER_MERCHANT_CODE, merchantCode); h.put(HEADER_REQUEST_ID, requestId); h.put(HEADER_TIMESTAMP, ts); h.put(HEADER_SIGNATURE, sign(secretKey, method, path, merchantCode, ts, requestId, rawBody)); return h; } /** * Check one incoming request (our Wallet API calls to you). Returns OK, INVALID_SIGNATURE or TIMESTAMP_EXPIRED, * usable as the envelope code. Verify the raw body bytes BEFORE parsing JSON. */ public static String verify(String secretKey, String merchantCode, Map headers, String method, String path, byte[] rawBody, long nowMs) { Map h = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); h.putAll(headers); String code = h.getOrDefault(HEADER_MERCHANT_CODE, ""); String rid = h.getOrDefault(HEADER_REQUEST_ID, ""); String ts = h.getOrDefault(HEADER_TIMESTAMP, ""); String sig = h.getOrDefault(HEADER_SIGNATURE, "").toLowerCase(Locale.ROOT); if (code.isEmpty() || rid.isEmpty() || ts.isEmpty() || sig.isEmpty() || !code.equals(merchantCode)) { return INVALID_SIGNATURE; } long sent; try { sent = Long.parseLong(ts); } catch (NumberFormatException e) { return TIMESTAMP_EXPIRED; } if (Math.abs(nowMs - sent) > TIMESTAMP_WINDOW_MS) { return TIMESTAMP_EXPIRED; } byte[] want = sign(secretKey, method, path, code, ts, rid, rawBody).getBytes(StandardCharsets.US_ASCII); return MessageDigest.isEqual(want, sig.getBytes(StandardCharsets.US_ASCII)) ? OK : INVALID_SIGNATURE; } private static String hex(byte[] b) { StringBuilder sb = new StringBuilder(b.length * 2); for (byte x : b) { sb.append(Character.forDigit((x >> 4) & 0xf, 16)).append(Character.forDigit(x & 0xf, 16)); } return sb.toString(); } public static void main(String[] args) { String key = "test-secret-key-0001", path = "/wallet/debit", code = "M0001", ts = "1792000000000", rid = "req-0001"; byte[] body = "{\"txnId\":\"T7K2M9QXH4ZP8WNA\",\"amount\":\"10.00\"}".getBytes(StandardCharsets.UTF_8); check(bodySha256(body).equals("b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8"), "body hash"); String sig = sign(key, "POST", path, code, ts, rid, body); check(sig.equals("e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589"), "signature " + sig); long now = 1792000000000L; Map h = signedHeaders(code, key, "POST", path, body, now, rid); check(verify(key, code, h, "POST", path, body, now).equals(OK), "verify"); byte[] tampered = "{\"txnId\":\"T7K2M9QXH4ZP8WNA\",\"amount\":\"10.01\"}".getBytes(StandardCharsets.UTF_8); check(verify(key, code, h, "POST", path, tampered, now).equals(INVALID_SIGNATURE), "tampered"); check(verify(key, "M0002", h, "POST", path, body, now).equals(INVALID_SIGNATURE), "other merchant"); check(verify(key, code, h, "POST", path, body, now + TIMESTAMP_WINDOW_MS + 1).equals(TIMESTAMP_EXPIRED), "stale"); System.out.println("MerchantSign.java: self-test passed " + sig); } private static void check(boolean ok, String what) { if (!ok) { throw new AssertionError(what); } } }