feat(alerting): HmacSigner for webhook signature

HmacSHA256 signer returning sha256=<lowercase-hex>. 5 unit tests covering
known vector, prefix, hex casing, and different secrets/bodies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 20:24:39 +02:00
parent bf178ba141
commit 6f1feaa4b0
2 changed files with 90 additions and 0 deletions

View File

@@ -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.
* <p>
* Output format: {@code sha256=<lowercase hex>}
*/
@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);
}
}
}

View File

@@ -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);
}
}