feat(04-03): implement SSE payload signing with Ed25519

- SsePayloadSigner signs JSON payloads and adds signature field before SSE delivery
- SseConnectionManager signs all command payloads via SsePayloadSigner before sendEvent
- Signed payload parsed to JsonNode for correct SseEmitter serialization
- Integration tests use bootstrap token + JWT auth (adapts to Plan 02 security layer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 20:29:54 +01:00
parent 387e2e66b2
commit 0215fd96ae
3 changed files with 142 additions and 27 deletions

View File

@@ -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<String, SseEmitter> 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);

View File

@@ -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.
* <p>
* 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.
* <p>
* Agents verify the signature by:
* <ol>
* <li>Extracting and removing the {@code "signature"} field from the received JSON</li>
* <li>Serializing the remaining fields back to a JSON string</li>
* <li>Verifying the signature against that string using the server's Ed25519 public key</li>
* </ol>
* 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.
* <p>
* 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;
}
}
}