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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user