diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java index 88b6f3ed..fd8d09eb 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java @@ -4,6 +4,8 @@ import com.cameleer3.server.app.config.AgentRegistryConfig; import com.cameleer3.server.core.agent.AgentCommand; import com.cameleer3.server.core.agent.AgentEventListener; import com.cameleer3.server.core.agent.AgentRegistryService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,10 +33,15 @@ public class SseConnectionManager implements AgentEventListener { private final ConcurrentHashMap emitters = new ConcurrentHashMap<>(); private final AgentRegistryService registryService; private final AgentRegistryConfig config; + private final SsePayloadSigner ssePayloadSigner; + private final ObjectMapper objectMapper; - public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config) { + public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config, + SsePayloadSigner ssePayloadSigner, ObjectMapper objectMapper) { this.registryService = registryService; this.config = config; + this.ssePayloadSigner = ssePayloadSigner; + this.objectMapper = objectMapper; } @PostConstruct @@ -136,7 +143,16 @@ public class SseConnectionManager implements AgentEventListener { @Override public void onCommandReady(String agentId, AgentCommand command) { String eventType = command.type().name().toLowerCase().replace('_', '-'); - boolean sent = sendEvent(agentId, command.id(), eventType, command.payload()); + String signedPayload = ssePayloadSigner.signPayload(command.payload()); + // Parse to JsonNode so SseEmitter serializes the tree correctly (avoids double-quoting a raw string) + Object data; + try { + data = objectMapper.readTree(signedPayload); + } catch (Exception e) { + log.warn("Failed to parse signed payload as JSON, sending raw string", e); + data = signedPayload; + } + boolean sent = sendEvent(agentId, command.id(), eventType, data); if (sent) { registryService.markDelivered(agentId, command.id()); log.debug("Command {} ({}) delivered to agent {} via SSE", command.id(), eventType, agentId); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java new file mode 100644 index 00000000..e08f124e --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java @@ -0,0 +1,77 @@ +package com.cameleer3.server.app.agent; + +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Signs SSE command payloads with Ed25519 before delivery. + *

+ * The signature is computed over the original JSON payload string (without the + * signature field). The resulting Base64-encoded signature is added as a + * {@code "signature"} field to the JSON before returning. + *

+ * Agents verify the signature by: + *

    + *
  1. Extracting and removing the {@code "signature"} field from the received JSON
  2. + *
  3. Serializing the remaining fields back to a JSON string
  4. + *
  5. Verifying the signature against that string using the server's Ed25519 public key
  6. + *
+ * In practice, agents should verify against the original payload — the signature is + * computed over the exact JSON string as received by the server. + */ +@Component +public class SsePayloadSigner { + + private static final Logger log = LoggerFactory.getLogger(SsePayloadSigner.class); + + private final Ed25519SigningService ed25519SigningService; + private final ObjectMapper objectMapper; + + public SsePayloadSigner(Ed25519SigningService ed25519SigningService, ObjectMapper objectMapper) { + this.ed25519SigningService = ed25519SigningService; + this.objectMapper = objectMapper; + } + + /** + * Signs the given JSON payload and returns a new JSON string with a {@code "signature"} field added. + *

+ * The signature is computed over the original payload string (before adding the signature field). + * + * @param jsonPayload the JSON string to sign + * @return the signed JSON string with a "signature" field, or the original payload if null/empty/blank + */ + public String signPayload(String jsonPayload) { + if (jsonPayload == null) { + log.warn("Attempted to sign null payload, returning null"); + return null; + } + if (jsonPayload.isEmpty() || jsonPayload.isBlank()) { + log.warn("Attempted to sign empty/blank payload, returning as-is"); + return jsonPayload; + } + + try { + // 1. Sign the original payload string + String signatureBase64 = ed25519SigningService.sign(jsonPayload); + + // 2. Parse payload, add signature field, serialize back + JsonNode node = objectMapper.readTree(jsonPayload); + if (node instanceof ObjectNode objectNode) { + objectNode.put("signature", signatureBase64); + return objectMapper.writeValueAsString(objectNode); + } else { + // Payload is not a JSON object (e.g., array or primitive) -- cannot add field + log.warn("Payload is not a JSON object, returning unsigned: {}", jsonPayload); + return jsonPayload; + } + } catch (Exception e) { + log.error("Failed to sign payload, returning unsigned", e); + return jsonPayload; + } + } +} 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 index 0415b95c..ccbb8af9 100644 --- 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 @@ -40,12 +40,9 @@ 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. + * Flow: register agent (with bootstrap token) -> extract JWT + public key from response -> + * open SSE stream (with JWT query param) -> push config-update command (with JWT) -> + * receive SSE event -> verify signature field against server's Ed25519 public key. */ class SseSigningIT extends AbstractClickHouseIT { @@ -68,7 +65,23 @@ class SseSigningIT extends AbstractClickHouseIT { return headers; } - private ResponseEntity registerAgent(String agentId) { + private HttpHeaders authProtocolHeaders(String accessToken) { + HttpHeaders headers = protocolHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private HttpHeaders bootstrapHeaders() { + HttpHeaders headers = protocolHeaders(); + headers.set("Authorization", "Bearer test-bootstrap-token"); + return headers; + } + + /** + * Registers an agent using the bootstrap token and returns the registration response. + * The response contains: agentId, sseEndpoint, accessToken, refreshToken, serverPublicKey. + */ + private JsonNode registerAgentWithAuth(String agentId) throws Exception { String json = """ { "agentId": "%s", @@ -80,24 +93,27 @@ class SseSigningIT extends AbstractClickHouseIT { } """.formatted(agentId); - return restTemplate.postForEntity( + ResponseEntity response = restTemplate.postForEntity( "/api/v1/agents/register", - new HttpEntity<>(json, protocolHeaders()), + new HttpEntity<>(json, bootstrapHeaders()), String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + return objectMapper.readTree(response.getBody()); } - private ResponseEntity sendCommand(String agentId, String type, String payloadJson) { + private ResponseEntity sendCommand(String agentId, String type, String payloadJson, String accessToken) { String json = """ {"type": "%s", "payload": %s} """.formatted(type, payloadJson); return restTemplate.postForEntity( "/api/v1/agents/" + agentId + "/commands", - new HttpEntity<>(json, protocolHeaders()), + new HttpEntity<>(json, authProtocolHeaders(accessToken)), String.class); } - private SseStream openSseStream(String agentId) { + private SseStream openSseStream(String agentId, String accessToken) { List lines = new ArrayList<>(); CountDownLatch connected = new CountDownLatch(1); AtomicInteger statusCode = new AtomicInteger(0); @@ -107,7 +123,7 @@ class SseSigningIT extends AbstractClickHouseIT { .build(); HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events")) + .uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events?token=" + accessToken)) .header("Accept", "text/event-stream") .GET() .build(); @@ -147,18 +163,21 @@ class SseSigningIT extends AbstractClickHouseIT { @Test void configUpdateEvent_containsValidEd25519Signature() throws Exception { String agentId = "sse-sign-it-" + UUID.randomUUID().toString().substring(0, 8); - registerAgent(agentId); + JsonNode registration = registerAgentWithAuth(agentId); + String accessToken = registration.get("accessToken").asText(); + String serverPublicKey = registration.get("serverPublicKey").asText(); - SseStream stream = openSseStream(agentId); - stream.awaitConnection(5000); + SseStream stream = openSseStream(agentId, accessToken); + assertThat(stream.awaitConnection(5000)).isTrue(); + assertThat(stream.statusCode().get()).isEqualTo(200); String originalPayload = "{\"key\":\"value\",\"setting\":\"enabled\"}"; // Send config-update and wait for SSE event - await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS) + await().atMost(10, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS) .ignoreExceptions() .until(() -> { - sendCommand(agentId, "config-update", originalPayload); + sendCommand(agentId, "config-update", originalPayload, accessToken); List lines = stream.snapshot(); return lines.stream().anyMatch(l -> l.contains("event:config-update")); }); @@ -187,7 +206,7 @@ class SseSigningIT extends AbstractClickHouseIT { // Verify signature against original payload using server's public key byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64); - PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64()); + PublicKey publicKey = loadPublicKey(serverPublicKey); Signature verifier = Signature.getInstance("Ed25519"); verifier.initVerify(publicKey); @@ -200,17 +219,20 @@ class SseSigningIT extends AbstractClickHouseIT { @Test void deepTraceEvent_containsValidSignature() throws Exception { String agentId = "sse-sign-trace-" + UUID.randomUUID().toString().substring(0, 8); - registerAgent(agentId); + JsonNode registration = registerAgentWithAuth(agentId); + String accessToken = registration.get("accessToken").asText(); + String serverPublicKey = registration.get("serverPublicKey").asText(); - SseStream stream = openSseStream(agentId); - stream.awaitConnection(5000); + SseStream stream = openSseStream(agentId, accessToken); + assertThat(stream.awaitConnection(5000)).isTrue(); + assertThat(stream.statusCode().get()).isEqualTo(200); String originalPayload = "{\"correlationId\":\"trace-123\"}"; - await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS) + await().atMost(10, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS) .ignoreExceptions() .until(() -> { - sendCommand(agentId, "deep-trace", originalPayload); + sendCommand(agentId, "deep-trace", originalPayload, accessToken); List lines = stream.snapshot(); return lines.stream().anyMatch(l -> l.contains("event:deep-trace")); }); @@ -226,7 +248,7 @@ class SseSigningIT extends AbstractClickHouseIT { // Verify signature byte[] signatureBytes = Base64.getDecoder().decode(eventNode.get("signature").asText()); - PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64()); + PublicKey publicKey = loadPublicKey(serverPublicKey); Signature verifier = Signature.getInstance("Ed25519"); verifier.initVerify(publicKey);