test(sse): contract test for SSE command Ed25519 canonicalization

Adds SseCommandSigningContractTest — a parameterized test covering all 7
emit paths (6 command types, CONFIG_UPDATE split across 2 sites) that
performs the agent-side parse→remove-signature→reserialize→verify flow
to confirm the server's signed bytes are byte-equivalent to what the
agent reconstructs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-29 10:36:00 +02:00
parent 459558e5f3
commit 288fb3c66e

View File

@@ -0,0 +1,207 @@
package io.cameleer.server.app.agent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.cameleer.server.app.security.Ed25519SigningServiceImpl;
import io.cameleer.server.core.security.Ed25519SigningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
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 java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Contract test for the agent team's "byte-identical canonical form" requirement
* (cameleer/docs/server-team-sse-command-signing.md §"Canonicalization rule").
*
* For each of the server's 6 command types, builds a representative payload (the
* structure that emit sites actually produce), signs it via {@link SsePayloadSigner},
* then performs the agent-side parse-remove-signature-reserialize step and asserts
* the Ed25519 signature verifies against the resulting bytes.
*
* <h2>Emit site summary</h2>
* <ul>
* <li><b>CONFIG_UPDATE</b> — two sites:
* (1) {@code ApplicationConfigController.pushConfigToAgentsWithMergedKeys}: converts
* {@code ApplicationConfig} to {@code Map} via Jackson {@code convertValue}, adds
* {@code sensitiveKeys}, then {@code writeValueAsString}.
* (2) {@code SensitiveKeysAdminController.fanOutToAllAgents}: builds a
* {@code LinkedHashMap} with {@code application} + {@code sensitiveKeys}, then
* {@code writeValueAsString}.
* </li>
* <li><b>REPLAY</b> — {@code AgentCommandController.replayExchange}: builds a
* {@code LinkedHashMap} with {@code routeId}, nested {@code exchange} map,
* optional {@code originalExchangeId}, and a {@code nonce} UUID. Via
* {@code writeValueAsString}. Has nonce.
* </li>
* <li><b>ROUTE_CONTROL</b> / <b>DEEP_TRACE</b> / <b>SET_TRACED_PROCESSORS</b> —
* no dedicated emit site. All routed through the generic
* {@code AgentCommandController.sendCommand/sendGroupCommand/broadcastCommand}
* path, which serializes {@code request.payload()} (an {@code Object} already
* deserialized from the HTTP request body by Jackson) via
* {@code writeValueAsString}. Payload shape is caller-defined.
* ROUTE_CONTROL has a {@code nonce} field per the handoff doc.
* </li>
* <li><b>TEST_EXPRESSION</b> — {@code ApplicationConfigController.testExpression}:
* builds a {@code Map.of()} with 4 fields (expression, language, body, target),
* then {@code writeValueAsString}. No nonce.
* </li>
* </ul>
*
* <h2>The suspected bug</h2>
* {@code SsePayloadSigner.signPayload} currently signs the <em>input</em> string, but
* transmits a <em>re-serialized</em> form (after parse → add-signature → serialize).
* If the input and the re-serialized form are byte-inequivalent (whitespace, field order,
* number formatting differences), the agent's verify-after-reserialize step will fail.
* These tests surface that failure for each command type.
*/
class SseCommandSigningContractTest {
private SsePayloadSigner signer;
private ObjectMapper mapper;
private Ed25519SigningService signingService;
@BeforeEach
void setUp() {
signingService = Ed25519SigningServiceImpl.ephemeral();
// Plain ObjectMapper — no JavaTimeModule or other customisations.
// The actual emit sites use the Spring-managed ObjectMapper, but all payloads
// they produce contain only strings, numbers, and maps — no Java time types —
// so a vanilla ObjectMapper is byte-equivalent to the production one for these
// specific payloads.
mapper = new ObjectMapper();
signer = new SsePayloadSigner(signingService, mapper);
}
static Stream<Arguments> commandPayloads() {
ObjectMapper m = new ObjectMapper();
try {
// --- CONFIG_UPDATE (site 1): ApplicationConfigController ---
// convertValue(ApplicationConfig, Map.class) produces a Jackson-serialized map;
// sensitiveKeys is then added. We approximate with a map of typical config fields.
Map<String, Object> configUpdatePayload = new LinkedHashMap<>();
configUpdatePayload.put("application", "my-app");
configUpdatePayload.put("environment", "dev");
configUpdatePayload.put("version", 3);
configUpdatePayload.put("metricsEnabled", true);
configUpdatePayload.put("samplingRate", 1.0);
configUpdatePayload.put("applicationLogLevel", "INFO");
configUpdatePayload.put("agentLogLevel", "INFO");
configUpdatePayload.put("engineLevel", "REGULAR");
configUpdatePayload.put("payloadCaptureMode", "BOTH");
configUpdatePayload.put("sensitiveKeys", List.of("password", "token", "secret"));
String configUpdateJson = m.writeValueAsString(configUpdatePayload);
// --- CONFIG_UPDATE (site 2): SensitiveKeysAdminController ---
// LinkedHashMap with just application + sensitiveKeys (partial config update).
Map<String, Object> sensitiveKeysPushPayload = new LinkedHashMap<>();
sensitiveKeysPushPayload.put("application", "my-app");
sensitiveKeysPushPayload.put("sensitiveKeys", List.of("password", "apiKey"));
String sensitiveKeysPushJson = m.writeValueAsString(sensitiveKeysPushPayload);
// --- REPLAY: AgentCommandController.replayExchange ---
// LinkedHashMap: routeId, exchange{body, headers}, optional originalExchangeId, nonce.
Map<String, Object> replayPayload = new LinkedHashMap<>();
replayPayload.put("routeId", "direct:inbound");
Map<String, Object> exchange = new LinkedHashMap<>();
exchange.put("body", "{\"orderId\":42}");
exchange.put("headers", Map.of("Content-Type", "application/json"));
replayPayload.put("exchange", exchange);
replayPayload.put("originalExchangeId", "abc-123-def-456");
replayPayload.put("nonce", UUID.randomUUID().toString());
String replayJson = m.writeValueAsString(replayPayload);
// --- ROUTE_CONTROL: generic sendCommand path ---
// Caller provides a payload object; Jackson serialises whatever it passed in.
// Handoff doc requires a nonce field.
Map<String, Object> routeControlPayload = new LinkedHashMap<>();
routeControlPayload.put("routeId", "direct:inbound");
routeControlPayload.put("action", "stop");
routeControlPayload.put("nonce", UUID.randomUUID().toString());
String routeControlJson = m.writeValueAsString(routeControlPayload);
// --- TEST_EXPRESSION: ApplicationConfigController.testExpression ---
// Map.of() with 4 fields: expression, language, body, target.
// Note: Map.of() has non-deterministic iteration order in Java, but Jackson
// serialises whatever order the JVM gives — this matches what the emit site does.
// We use a LinkedHashMap here to keep the test deterministic across JVM versions.
Map<String, Object> testExpressionPayload = new LinkedHashMap<>();
testExpressionPayload.put("expression", "${body}");
testExpressionPayload.put("language", "simple");
testExpressionPayload.put("body", "{\"foo\":\"bar\"}");
testExpressionPayload.put("target", "processor-1");
String testExpressionJson = m.writeValueAsString(testExpressionPayload);
// --- SET_TRACED_PROCESSORS: generic sendCommand path ---
Map<String, Object> setTracedPayload = new LinkedHashMap<>();
setTracedPayload.put("processorIds", List.of("processor-1", "processor-2"));
String setTracedJson = m.writeValueAsString(setTracedPayload);
// --- DEEP_TRACE: generic sendCommand path ---
Map<String, Object> deepTracePayload = new LinkedHashMap<>();
deepTracePayload.put("durationSeconds", 300);
String deepTraceJson = m.writeValueAsString(deepTracePayload);
return Stream.of(
Arguments.of("config-update (ApplicationConfigController)", configUpdateJson),
Arguments.of("config-update (SensitiveKeysAdminController fan-out)", sensitiveKeysPushJson),
Arguments.of("replay", replayJson),
Arguments.of("route-control", routeControlJson),
Arguments.of("test-expression", testExpressionJson),
Arguments.of("set-traced-processors", setTracedJson),
Arguments.of("deep-trace", deepTraceJson)
);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@ParameterizedTest(name = "[{index}] {0}")
@MethodSource("commandPayloads")
void signedPayloadVerifiesAfterAgentSideReserialize(String commandType, String unsignedJson) throws Exception {
// 1. Server: sign + add signature field + serialize
String signedJson = signer.signPayload(unsignedJson);
assertThat(signedJson)
.as("signer must add a signature field for command %s", commandType)
.contains("\"signature\":");
// 2. Agent: parse, extract signature, remove signature field, re-serialize
JsonNode node = mapper.readTree(signedJson);
String signatureBase64 = node.path("signature").asText();
ObjectNode copy = ((ObjectNode) node).deepCopy();
copy.remove("signature");
String reSerialized = mapper.writeValueAsString(copy);
// 3. Agent: Ed25519 verify against re-serialized bytes
byte[] keyBytes = Base64.getDecoder().decode(signingService.getPublicKeyBase64());
PublicKey publicKey = KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(keyBytes));
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(reSerialized.getBytes(StandardCharsets.UTF_8));
boolean valid = verifier.verify(Base64.getDecoder().decode(signatureBase64));
assertThat(valid)
.as("Ed25519 signature must verify after agent-side reserialize for '%s'. " +
"If this fails, server is signing a different byte sequence than the " +
"agent reconstructs — see SsePayloadSigner.signPayload: it signs the " +
"INPUT string but transmits the RE-SERIALIZED form. Fix: parse first, " +
"reserialize to canonical form, sign THAT, then add signature field.",
commandType)
.isTrue();
}
}