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