Files
qbit-webhooks/src/test/java/com/kingsrook/qbits/webhooks/signing/WebhookSigningTest.java
T
james.maesandClaude Opus 5.5 f33e592aef refactor(signing): use core WebhookSigner and WebhookVerifier
Delete the local shared/signing package (WebhookSigner, WebhookVerifier,
WebhookSecret, WebhookVerificationException) and use core
com.kingsrook.qqq.backend.core.utils.webhooks from QQQ 4.1.0-RC.5.
Delivery signing, inbound verification and secret generation and rotation
all go through core. The wire format is unchanged: same headers, v1,
prefix, whsec_ secrets and timestamp tolerance. Endpoints, secret storage
and delivery stay in this qbit.

Refs QRun-IO/qqq#1160 (this repo has issues disabled), architecture review
QRun-IO/qqq#1150 finding F11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:16 -05:00

365 lines
18 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webhooks.signing;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerificationException;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/*******************************************************************************
** Qbit wire contract and interoperability with the released core primitives.
*******************************************************************************/
class WebhookSigningTest
{
private static final Instant NOW = Instant.ofEpochSecond(1_760_000_000L);
private static final byte[] BODY = "{\"type\":\"cms_blog_post.updated\",\"id\":42}".getBytes(StandardCharsets.UTF_8);
/*******************************************************************************
** Independent local HMAC and core interoperate without re-encoding the body.
** The reference uses literal wire names and prefixes to detect contract drift.
*******************************************************************************/
@Test
void testLocalAndCoreInteropWithRotation() throws Exception
{
List<String> texts = List.of("a-raw-shared-secret-of-enough-length", "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
List<WebhookSecret> secrets = texts.stream().map(WebhookSecret::parse).toList();
for(byte[] body : List.of(BODY, new byte[0], new byte[] { 0, (byte) 0xff, (byte) 0xc3, (byte) 0xa9, 10 }))
{
String first = localSignature("evt_interop", "1760000000", body, texts.get(0));
String second = localSignature("evt_interop", "1760000000", body, texts.get(1));
Map<String, String> local = Map.of("webhook-id", "evt_interop", "webhook-timestamp", "1760000000",
"webhook-signature", "v1," + first + " v1," + second);
Map<String, String> core = WebhookSigner.headers("evt_interop", NOW, body, secrets);
assertThat(core).isEqualTo(local);
for(int i = 0; i < secrets.size(); i++)
{
verifier(List.of(secrets.get(i))).verify(local, body);
assertThat(localVerify(core, body, texts.get(i))).isTrue();
}
assertThat(localVerify(core, body, "an-unrelated-secret-of-enough-length")).isFalse();
verifier(secrets).verify(WebhookSigner.headers("evt_interop", NOW, body, List.of(secrets.get(1))), body);
}
}
/*******************************************************************************
** The five-minute window includes both boundaries, but not the next second.
*******************************************************************************/
@Test
void testExactTimestampTolerance()
{
assertThat(WebhookVerifier.DEFAULT_TOLERANCE).isEqualTo(Duration.ofSeconds(300));
WebhookSecret secret = WebhookSecret.generate();
for(long offset : new long[] { -300, 300 })
{
assertDoesNotThrow(() -> verifier(List.of(secret)).verify(WebhookSigner.headers("evt_edge", NOW.plusSeconds(offset), BODY, List.of(secret)), BODY));
}
for(long offset : new long[] { -301, 301 })
{
assertThatThrownBy(() -> verifier(List.of(secret)).verify(WebhookSigner.headers("evt_edge", NOW.plusSeconds(offset), BODY, List.of(secret)), BODY))
.isInstanceOf(WebhookVerificationException.class)
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.TIMESTAMP_OUT_OF_TOLERANCE);
}
}
/*******************************************************************************
** Test-only reference HMAC, independent of core signing and secret parsing.
*******************************************************************************/
private static String localSignature(String id, String timestamp, byte[] body, String text) throws Exception
{
byte[] key = text.startsWith("whsec_") ? Base64.getDecoder().decode(text.substring(6)) : text.getBytes(StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
return (Base64.getEncoder().encodeToString(mac.doFinal(body)));
}
/*******************************************************************************
** Test-only receiver checks rotation signatures independently of core.
*******************************************************************************/
private static boolean localVerify(Map<String, String> headers, byte[] body, String secret) throws Exception
{
byte[] expected = localSignature(headers.get("webhook-id"), headers.get("webhook-timestamp"), body, secret).getBytes(StandardCharsets.US_ASCII);
return (Arrays.stream(headers.get("webhook-signature").split(" "))
.filter(part -> part.startsWith("v1,"))
.anyMatch(part -> MessageDigest.isEqual(expected, part.substring(3).getBytes(StandardCharsets.US_ASCII))));
}
/*******************************************************************************
** The Standard Webhooks reference vector (standard-webhooks/standard-webhooks,
** libraries/go/webhook_test.go, TestWebhookSign).
*******************************************************************************/
@Test
void testStandardWebhooksReferenceVector()
{
WebhookSecret secret = WebhookSecret.parse("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
String signature = WebhookSigner.signatureHeader("msg_p5jXN8AQM9LWM0D4loKWxJek", 1614265330L,
"{\"test\": 2432232314}".getBytes(StandardCharsets.UTF_8), List.of(secret));
assertThat(signature).isEqualTo("v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testRoundTrip() throws Exception
{
WebhookSecret secret = WebhookSecret.generate();
Map<String, String> headers = WebhookSigner.headers("evt_123", NOW, BODY, List.of(secret));
assertThat(headers).containsOnlyKeys(WebhookSigner.HEADER_ID, WebhookSigner.HEADER_TIMESTAMP, WebhookSigner.HEADER_SIGNATURE);
assertThat(headers.get(WebhookSigner.HEADER_ID)).isEqualTo("evt_123");
assertThat(headers.get(WebhookSigner.HEADER_TIMESTAMP)).isEqualTo("1760000000");
assertThat(headers.get(WebhookSigner.HEADER_SIGNATURE)).startsWith("v1,");
verifier(List.of(secret)).verify(headers, BODY);
}
/*******************************************************************************
** Header names are matched case-insensitively.
*******************************************************************************/
@Test
void testHeaderNamesAreCaseInsensitive() throws Exception
{
WebhookSecret secret = WebhookSecret.generate();
Map<String, String> headers = new HashMap<>();
WebhookSigner.headers("evt_1", NOW, BODY, List.of(secret)).forEach((k, v) -> headers.put(k.toUpperCase(), v));
verifier(List.of(secret)).verify(headers, BODY);
}
/*******************************************************************************
** During a rotation both secrets sign; a receiver with either one accepts.
*******************************************************************************/
@Test
void testRotationSignsWithBothSecrets() throws Exception
{
WebhookSecret oldSecret = WebhookSecret.generate();
WebhookSecret newSecret = WebhookSecret.generate();
Map<String, String> headers = WebhookSigner.headers("evt_rotate", NOW, BODY, List.of(newSecret, oldSecret));
String[] parts = headers.get(WebhookSigner.HEADER_SIGNATURE).split(" ");
assertThat(parts).hasSize(2).allMatch(part -> part.startsWith("v1,"));
verifier(List.of(oldSecret)).verify(headers, BODY);
verifier(List.of(newSecret)).verify(headers, BODY);
assertThatThrownBy(() -> verifier(List.of(WebhookSecret.generate())).verify(headers, BODY))
.isInstanceOf(WebhookVerificationException.class)
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.NO_MATCHING_SIGNATURE);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testTimestampToleranceRejectsOldAndFuture()
{
WebhookSecret secret = WebhookSecret.generate();
for(Instant sent : List.of(NOW.minus(Duration.ofMinutes(6)), NOW.plus(Duration.ofMinutes(6))))
{
Map<String, String> headers = WebhookSigner.headers("evt_t", sent, BODY, List.of(secret));
assertThatThrownBy(() -> verifier(List.of(secret)).verify(headers, BODY))
.isInstanceOf(WebhookVerificationException.class)
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.TIMESTAMP_OUT_OF_TOLERANCE);
}
Map<String, String> justInside = WebhookSigner.headers("evt_t", NOW.minus(Duration.ofMinutes(4)), BODY, List.of(secret));
assertDoesNotThrow(() -> verifier(List.of(secret)).verify(justInside, BODY));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testTamperingIsRejected()
{
WebhookSecret secret = WebhookSecret.generate();
Map<String, String> headers = WebhookSigner.headers("evt_x", NOW, BODY, List.of(secret));
byte[] tamperedBody = "{\"type\":\"cms_blog_post.updated\",\"id\":43}".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(() -> verifier(List.of(secret)).verify(headers, tamperedBody)).isInstanceOf(WebhookVerificationException.class);
Map<String, String> otherId = new HashMap<>(headers);
otherId.put(WebhookSigner.HEADER_ID, "evt_y");
assertThatThrownBy(() -> verifier(List.of(secret)).verify(otherId, BODY)).isInstanceOf(WebhookVerificationException.class);
Map<String, String> otherTime = new HashMap<>(headers);
otherTime.put(WebhookSigner.HEADER_TIMESTAMP, Long.toString(NOW.getEpochSecond() + 1));
assertThatThrownBy(() -> verifier(List.of(secret)).verify(otherTime, BODY)).isInstanceOf(WebhookVerificationException.class);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMissingAndBadHeaders()
{
WebhookSecret secret = WebhookSecret.generate();
Map<String, String> headers = WebhookSigner.headers("evt_m", NOW, BODY, List.of(secret));
for(String name : List.of(WebhookSigner.HEADER_ID, WebhookSigner.HEADER_TIMESTAMP, WebhookSigner.HEADER_SIGNATURE))
{
Map<String, String> missing = new HashMap<>(headers);
missing.remove(name);
assertThatThrownBy(() -> verifier(List.of(secret)).verify(missing, BODY))
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.MISSING_HEADERS);
}
assertThatThrownBy(() -> verifier(List.of(secret)).verify(null, BODY))
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.MISSING_HEADERS);
Map<String, String> badTime = new HashMap<>(headers);
badTime.put(WebhookSigner.HEADER_TIMESTAMP, "yesterday");
assertThatThrownBy(() -> verifier(List.of(secret)).verify(badTime, BODY))
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.BAD_TIMESTAMP);
}
/*******************************************************************************
** Unknown versions are skipped, and a valid v1 among them still passes.
*******************************************************************************/
@Test
void testUnknownVersionsAreIgnored() throws Exception
{
WebhookSecret secret = WebhookSecret.generate();
Map<String, String> headers = new HashMap<>(WebhookSigner.headers("evt_v", NOW, BODY, List.of(secret)));
String v1 = headers.get(WebhookSigner.HEADER_SIGNATURE);
headers.put(WebhookSigner.HEADER_SIGNATURE, "v2,AAAA v1a,BBBB garbage " + v1);
verifier(List.of(secret)).verify(headers, BODY);
headers.put(WebhookSigner.HEADER_SIGNATURE, v1.replace("v1,", "v2,"));
assertThatThrownBy(() -> verifier(List.of(secret)).verify(headers, BODY))
.extracting(e -> ((WebhookVerificationException) e).getReason())
.isEqualTo(WebhookVerificationException.Reason.NO_MATCHING_SIGNATURE);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testSecretParseGenerateAndToString()
{
WebhookSecret generated = WebhookSecret.generate();
String text = generated.reveal();
assertThat(text).startsWith(WebhookSecret.PREFIX);
assertThat(generated.keyBytes()).hasSize(WebhookSecret.GENERATED_KEY_BYTES);
assertThat(WebhookSecret.parse(text)).isEqualTo(generated);
assertThat(WebhookSecret.parse(" " + text + "\n")).isEqualTo(generated);
assertThat(WebhookSecret.generate()).isNotEqualTo(generated);
assertThat(generated.toString()).doesNotContain(text.substring(WebhookSecret.PREFIX.length())).contains(generated.fingerprint());
assertThat(generated.fingerprint()).hasSize(12).matches("[0-9a-f]+");
assertThat(generated.hashCode()).isEqualTo(WebhookSecret.parse(text).hashCode());
WebhookSecret raw = WebhookSecret.parse("a-raw-shared-secret-of-enough-length");
assertThat(raw.keyBytes()).isEqualTo("a-raw-shared-secret-of-enough-length".getBytes(StandardCharsets.UTF_8));
byte[] copy = generated.keyBytes();
copy[0]++;
assertThat(generated.keyBytes()[0]).isNotEqualTo(copy[0]);
assertThatThrownBy(() -> WebhookSecret.parse(null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSecret.parse(" ")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSecret.parse("short")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSecret.parse("whsec_!!!not-base64!!!")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSecret.parse("whsec_c2hvcnQ=")).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testSignerArgumentChecks()
{
WebhookSecret secret = WebhookSecret.generate();
assertThatThrownBy(() -> WebhookSigner.signatureHeader("evt", 1L, BODY, List.of())).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSigner.signatureHeader("evt", 1L, BODY, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSigner.sign("a.b", 1L, BODY, secret)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSigner.sign("a b", 1L, BODY, secret)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> WebhookSigner.sign("", 1L, BODY, secret)).isInstanceOf(IllegalArgumentException.class);
assertThat(WebhookSigner.sign("evt", 1L, null, secret)).isEqualTo(WebhookSigner.sign("evt", 1L, new byte[0], secret));
assertThatThrownBy(() -> new WebhookVerifier(List.of())).isInstanceOf(IllegalArgumentException.class);
assertDoesNotThrow(() -> new WebhookVerifier(List.of(secret)));
}
/*******************************************************************************
** A verifier at NOW with the default tolerance.
*******************************************************************************/
private static WebhookVerifier verifier(List<WebhookSecret> secrets)
{
return (new WebhookVerifier(secrets, WebhookVerifier.DEFAULT_TOLERANCE, Clock.fixed(NOW, ZoneOffset.UTC)));
}
}