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