test(04-03): add failing tests for SSE payload signing
- SsePayloadSignerTest: 7 unit tests for sign/verify roundtrip and edge cases - SseSigningIT: 2 integration tests for end-to-end SSE signature verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
package com.cameleer3.server.app.agent;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.security.Ed25519SigningServiceImpl;
|
||||||
|
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for SsePayloadSigner.
|
||||||
|
* Uses real Ed25519SigningServiceImpl (no Spring context needed).
|
||||||
|
*/
|
||||||
|
class SsePayloadSignerTest {
|
||||||
|
|
||||||
|
private Ed25519SigningService signingService;
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
private SsePayloadSigner signer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
signingService = new Ed25519SigningServiceImpl();
|
||||||
|
objectMapper = new ObjectMapper();
|
||||||
|
signer = new SsePayloadSigner(signingService, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_addsSignatureFieldToJson() throws Exception {
|
||||||
|
String payload = "{\"key\":\"value\",\"count\":42}";
|
||||||
|
|
||||||
|
String signed = signer.signPayload(payload);
|
||||||
|
|
||||||
|
JsonNode node = objectMapper.readTree(signed);
|
||||||
|
assertThat(node.has("signature")).isTrue();
|
||||||
|
assertThat(node.get("key").asText()).isEqualTo("value");
|
||||||
|
assertThat(node.get("count").asInt()).isEqualTo(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_signatureIsBase64Encoded() throws Exception {
|
||||||
|
String payload = "{\"key\":\"value\"}";
|
||||||
|
|
||||||
|
String signed = signer.signPayload(payload);
|
||||||
|
|
||||||
|
JsonNode node = objectMapper.readTree(signed);
|
||||||
|
String signature = node.get("signature").asText();
|
||||||
|
// Should not throw -- valid Base64
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(signature);
|
||||||
|
assertThat(decoded).isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_signatureVerifiesAgainstPublicKey() throws Exception {
|
||||||
|
String payload = "{\"key\":\"value\",\"nested\":{\"a\":1}}";
|
||||||
|
|
||||||
|
String signed = signer.signPayload(payload);
|
||||||
|
|
||||||
|
JsonNode node = objectMapper.readTree(signed);
|
||||||
|
String signatureBase64 = node.get("signature").asText();
|
||||||
|
|
||||||
|
// Verify signature against original payload (before signature was added)
|
||||||
|
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
|
||||||
|
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
|
||||||
|
|
||||||
|
Signature verifier = Signature.getInstance("Ed25519");
|
||||||
|
verifier.initVerify(publicKey);
|
||||||
|
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(verifier.verify(signatureBytes)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_signatureIsOverOriginalPayloadWithoutSignatureField() throws Exception {
|
||||||
|
String payload = "{\"data\":\"test\"}";
|
||||||
|
|
||||||
|
String signed = signer.signPayload(payload);
|
||||||
|
|
||||||
|
// Remove signature field and verify the original payload was what was signed
|
||||||
|
JsonNode node = objectMapper.readTree(signed);
|
||||||
|
String signatureBase64 = node.get("signature").asText();
|
||||||
|
|
||||||
|
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
|
||||||
|
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
|
||||||
|
|
||||||
|
// Verify against original payload string (not reconstructed from parsed JSON)
|
||||||
|
Signature verifier = Signature.getInstance("Ed25519");
|
||||||
|
verifier.initVerify(publicKey);
|
||||||
|
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(verifier.verify(signatureBytes))
|
||||||
|
.as("Signature should be computed over the original JSON string")
|
||||||
|
.isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_nullPayloadReturnsNull() {
|
||||||
|
String result = signer.signPayload(null);
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_emptyPayloadReturnsEmpty() {
|
||||||
|
String result = signer.signPayload("");
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signPayload_blankPayloadReturnsAsIs() {
|
||||||
|
String result = signer.signPayload(" ");
|
||||||
|
assertThat(result).isEqualTo(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
|
||||||
|
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||||
|
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
||||||
|
return keyFactory.generatePublic(keySpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.AbstractClickHouseIT;
|
||||||
|
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test verifying that SSE command events carry valid Ed25519 signatures.
|
||||||
|
* <p>
|
||||||
|
* Flow: register agent -> open SSE stream -> push config-update command ->
|
||||||
|
* receive SSE event -> verify signature field against server's public key.
|
||||||
|
* <p>
|
||||||
|
* NOTE: Uses TestSecurityConfig (permit-all) since Plan 02 (Spring Security filter chain)
|
||||||
|
* may not yet be complete. When Plan 02 is done, this test should be updated to use
|
||||||
|
* bootstrap token for registration and JWT for SSE connection.
|
||||||
|
*/
|
||||||
|
class SseSigningIT extends AbstractClickHouseIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private Ed25519SigningService ed25519SigningService;
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
private HttpHeaders protocolHeaders() {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> registerAgent(String agentId) {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"agentId": "%s",
|
||||||
|
"name": "SSE Signing Test Agent",
|
||||||
|
"group": "test-group",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"routeIds": ["route-1"],
|
||||||
|
"capabilities": {}
|
||||||
|
}
|
||||||
|
""".formatted(agentId);
|
||||||
|
|
||||||
|
return restTemplate.postForEntity(
|
||||||
|
"/api/v1/agents/register",
|
||||||
|
new HttpEntity<>(json, protocolHeaders()),
|
||||||
|
String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> sendCommand(String agentId, String type, String payloadJson) {
|
||||||
|
String json = """
|
||||||
|
{"type": "%s", "payload": %s}
|
||||||
|
""".formatted(type, payloadJson);
|
||||||
|
|
||||||
|
return restTemplate.postForEntity(
|
||||||
|
"/api/v1/agents/" + agentId + "/commands",
|
||||||
|
new HttpEntity<>(json, protocolHeaders()),
|
||||||
|
String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SseStream openSseStream(String agentId) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
CountDownLatch connected = new CountDownLatch(1);
|
||||||
|
AtomicInteger statusCode = new AtomicInteger(0);
|
||||||
|
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events"))
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
CompletableFuture<Void> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||||
|
.thenAccept(response -> {
|
||||||
|
statusCode.set(response.statusCode());
|
||||||
|
connected.countDown();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
synchronized (lines) {
|
||||||
|
lines.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Stream closed -- expected
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new SseStream(lines, future, connected, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SseStream(List<String> lines, CompletableFuture<Void> future,
|
||||||
|
CountDownLatch connected, AtomicInteger statusCode) {
|
||||||
|
List<String> snapshot() {
|
||||||
|
synchronized (lines) {
|
||||||
|
return new ArrayList<>(lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean awaitConnection(long timeoutMs) throws InterruptedException {
|
||||||
|
return connected.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configUpdateEvent_containsValidEd25519Signature() throws Exception {
|
||||||
|
String agentId = "sse-sign-it-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
registerAgent(agentId);
|
||||||
|
|
||||||
|
SseStream stream = openSseStream(agentId);
|
||||||
|
stream.awaitConnection(5000);
|
||||||
|
|
||||||
|
String originalPayload = "{\"key\":\"value\",\"setting\":\"enabled\"}";
|
||||||
|
|
||||||
|
// Send config-update and wait for SSE event
|
||||||
|
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
|
||||||
|
.ignoreExceptions()
|
||||||
|
.until(() -> {
|
||||||
|
sendCommand(agentId, "config-update", originalPayload);
|
||||||
|
List<String> lines = stream.snapshot();
|
||||||
|
return lines.stream().anyMatch(l -> l.contains("event:config-update"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract the data line from SSE event
|
||||||
|
List<String> lines = stream.snapshot();
|
||||||
|
String dataLine = lines.stream()
|
||||||
|
.filter(l -> l.startsWith("data:"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("No data line in SSE event"));
|
||||||
|
|
||||||
|
String eventData = dataLine.substring("data:".length());
|
||||||
|
JsonNode eventNode = objectMapper.readTree(eventData);
|
||||||
|
|
||||||
|
// Verify signature field exists
|
||||||
|
assertThat(eventNode.has("signature"))
|
||||||
|
.as("SSE event data should contain 'signature' field")
|
||||||
|
.isTrue();
|
||||||
|
|
||||||
|
String signatureBase64 = eventNode.get("signature").asText();
|
||||||
|
assertThat(signatureBase64).isNotBlank();
|
||||||
|
|
||||||
|
// Verify original payload fields are preserved
|
||||||
|
assertThat(eventNode.get("key").asText()).isEqualTo("value");
|
||||||
|
assertThat(eventNode.get("setting").asText()).isEqualTo("enabled");
|
||||||
|
|
||||||
|
// Verify signature against original payload using server's public key
|
||||||
|
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
|
||||||
|
PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64());
|
||||||
|
|
||||||
|
Signature verifier = Signature.getInstance("Ed25519");
|
||||||
|
verifier.initVerify(publicKey);
|
||||||
|
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(verifier.verify(signatureBytes))
|
||||||
|
.as("Ed25519 signature should verify against original payload")
|
||||||
|
.isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deepTraceEvent_containsValidSignature() throws Exception {
|
||||||
|
String agentId = "sse-sign-trace-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
registerAgent(agentId);
|
||||||
|
|
||||||
|
SseStream stream = openSseStream(agentId);
|
||||||
|
stream.awaitConnection(5000);
|
||||||
|
|
||||||
|
String originalPayload = "{\"correlationId\":\"trace-123\"}";
|
||||||
|
|
||||||
|
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
|
||||||
|
.ignoreExceptions()
|
||||||
|
.until(() -> {
|
||||||
|
sendCommand(agentId, "deep-trace", originalPayload);
|
||||||
|
List<String> lines = stream.snapshot();
|
||||||
|
return lines.stream().anyMatch(l -> l.contains("event:deep-trace"));
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> lines = stream.snapshot();
|
||||||
|
String dataLine = lines.stream()
|
||||||
|
.filter(l -> l.startsWith("data:"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
JsonNode eventNode = objectMapper.readTree(dataLine.substring("data:".length()));
|
||||||
|
assertThat(eventNode.has("signature")).isTrue();
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
byte[] signatureBytes = Base64.getDecoder().decode(eventNode.get("signature").asText());
|
||||||
|
PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64());
|
||||||
|
|
||||||
|
Signature verifier = Signature.getInstance("Ed25519");
|
||||||
|
verifier.initVerify(publicKey);
|
||||||
|
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(verifier.verify(signatureBytes)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
|
||||||
|
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||||
|
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
||||||
|
return keyFactory.generatePublic(keySpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user