From b3b4e62d3441c22cf2db41ad07bc5db8fec7c7d6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:13:44 +0100 Subject: [PATCH] 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 --- .../app/agent/SsePayloadSignerTest.java | 127 +++++++++ .../server/app/security/SseSigningIT.java | 243 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java new file mode 100644 index 00000000..8bc00d1c --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java @@ -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); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java new file mode 100644 index 00000000..0415b95c --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java @@ -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. + *

+ * Flow: register agent -> open SSE stream -> push config-update command -> + * receive SSE event -> verify signature field against server's public key. + *

+ * 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 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 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 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 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 lines, CompletableFuture future, + CountDownLatch connected, AtomicInteger statusCode) { + List 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 lines = stream.snapshot(); + return lines.stream().anyMatch(l -> l.contains("event:config-update")); + }); + + // Extract the data line from SSE event + List 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 lines = stream.snapshot(); + return lines.stream().anyMatch(l -> l.contains("event:deep-trace")); + }); + + List 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); + } +}