From 6f1feaa4b04aa1a91627a07012091696f1080b88 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:24:39 +0200 Subject: [PATCH] feat(alerting): HmacSigner for webhook signature HmacSHA256 signer returning sha256=. 5 unit tests covering known vector, prefix, hex casing, and different secrets/bodies. Co-Authored-By: Claude Sonnet 4.6 --- .../app/alerting/notify/HmacSigner.java | 35 ++++++++++++ .../app/alerting/notify/HmacSignerTest.java | 55 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java new file mode 100644 index 00000000..6aaed7ae --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/HmacSigner.java @@ -0,0 +1,35 @@ +package com.cameleer.server.app.alerting.notify; + +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +/** + * Computes HMAC-SHA256 webhook signatures. + *

+ * Output format: {@code sha256=} + */ +@Component +public class HmacSigner { + + /** + * Signs {@code body} with {@code secret} using HmacSHA256. + * + * @param secret plain-text secret (UTF-8 encoded) + * @param body request body bytes to sign + * @return {@code "sha256=" + hex(hmac)} + */ + public String sign(String secret, byte[] body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] digest = mac.doFinal(body); + return "sha256=" + HexFormat.of().formatHex(digest); + } catch (Exception e) { + throw new IllegalStateException("HMAC signing failed", e); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java new file mode 100644 index 00000000..2e69ae4e --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/HmacSignerTest.java @@ -0,0 +1,55 @@ +package com.cameleer.server.app.alerting.notify; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class HmacSignerTest { + + private final HmacSigner signer = new HmacSigner(); + + /** + * Pre-computed: + * secret = "test-secret-key" + * body = "hello world" + * result = sha256=b3df71b4790eb32b24c2f0bbb20f215d82b0da5e921caa880c74acfc97cf7e5b + * + * Verified with: python3 -c "import hmac,hashlib; print('sha256='+hmac.new(b'test-secret-key',b'hello world',hashlib.sha256).hexdigest())" + */ + @Test + void knownVector() { + String result = signer.sign("test-secret-key", "hello world".getBytes(StandardCharsets.UTF_8)); + assertThat(result).isEqualTo("sha256=b3df71b4790eb32b24c2f0bbb20f215d82b0da5e921caa880c74acfc97cf7e5b"); + } + + @Test + void outputStartsWithSha256Prefix() { + String result = signer.sign("any-secret", "body".getBytes(StandardCharsets.UTF_8)); + assertThat(result).startsWith("sha256="); + } + + @Test + void outputIsLowercaseHex() { + String result = signer.sign("key", "data".getBytes(StandardCharsets.UTF_8)); + // After "sha256=" every char must be a lowercase hex digit + String hex = result.substring("sha256=".length()); + assertThat(hex).matches("[0-9a-f]{64}"); + } + + @Test + void differentSecretsProduceDifferentSignatures() { + byte[] body = "payload".getBytes(StandardCharsets.UTF_8); + String sig1 = signer.sign("secret-a", body); + String sig2 = signer.sign("secret-b", body); + assertThat(sig1).isNotEqualTo(sig2); + } + + @Test + void differentBodiesProduceDifferentSignatures() { + String sig1 = signer.sign("secret", "body1".getBytes(StandardCharsets.UTF_8)); + String sig2 = signer.sign("secret", "body2".getBytes(StandardCharsets.UTF_8)); + assertThat(sig1).isNotEqualTo(sig2); + } +}