org.testcontainers
testcontainers-clickhouse
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java
new file mode 100644
index 00000000..0ff82dfe
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java
@@ -0,0 +1,24 @@
+package com.cameleer3.server.app.security;
+
+/**
+ * Validates bootstrap tokens used for initial agent registration.
+ * Stub — to be implemented in GREEN phase.
+ */
+public class BootstrapTokenValidator {
+
+ private final SecurityProperties properties;
+
+ public BootstrapTokenValidator(SecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * Validates the provided token against the configured bootstrap token(s).
+ *
+ * @param provided the token to validate
+ * @return true if the token matches the current or previous bootstrap token
+ */
+ public boolean validate(String provided) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java
new file mode 100644
index 00000000..ac24f68c
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java
@@ -0,0 +1,24 @@
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.security.Ed25519SigningService;
+
+/**
+ * JDK 17 Ed25519 signing implementation.
+ * Stub — to be implemented in GREEN phase.
+ */
+public class Ed25519SigningServiceImpl implements Ed25519SigningService {
+
+ public Ed25519SigningServiceImpl() {
+ // KeyPair generation will go here
+ }
+
+ @Override
+ public String sign(String payload) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+
+ @Override
+ public String getPublicKeyBase64() {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
new file mode 100644
index 00000000..b268fe00
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
@@ -0,0 +1,37 @@
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.security.InvalidTokenException;
+import com.cameleer3.server.core.security.JwtService;
+
+/**
+ * HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
+ * Stub — to be implemented in GREEN phase.
+ */
+public class JwtServiceImpl implements JwtService {
+
+ private final SecurityProperties properties;
+
+ public JwtServiceImpl(SecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ public String createAccessToken(String agentId, String group) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+
+ @Override
+ public String createRefreshToken(String agentId, String group) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+
+ @Override
+ public String validateAndExtractAgentId(String token) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+
+ @Override
+ public String validateRefreshToken(String token) {
+ throw new UnsupportedOperationException("Not yet implemented");
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
new file mode 100644
index 00000000..4926cdbc
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
@@ -0,0 +1,48 @@
+package com.cameleer3.server.app.security;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for security settings.
+ * Bound from the {@code security.*} namespace in application.yml.
+ */
+@ConfigurationProperties(prefix = "security")
+public class SecurityProperties {
+
+ private long accessTokenExpiryMs = 3_600_000;
+ private long refreshTokenExpiryMs = 604_800_000;
+ private String bootstrapToken;
+ private String bootstrapTokenPrevious;
+
+ public long getAccessTokenExpiryMs() {
+ return accessTokenExpiryMs;
+ }
+
+ public void setAccessTokenExpiryMs(long accessTokenExpiryMs) {
+ this.accessTokenExpiryMs = accessTokenExpiryMs;
+ }
+
+ public long getRefreshTokenExpiryMs() {
+ return refreshTokenExpiryMs;
+ }
+
+ public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) {
+ this.refreshTokenExpiryMs = refreshTokenExpiryMs;
+ }
+
+ public String getBootstrapToken() {
+ return bootstrapToken;
+ }
+
+ public void setBootstrapToken(String bootstrapToken) {
+ this.bootstrapToken = bootstrapToken;
+ }
+
+ public String getBootstrapTokenPrevious() {
+ return bootstrapTokenPrevious;
+ }
+
+ public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
+ this.bootstrapTokenPrevious = bootstrapTokenPrevious;
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
new file mode 100644
index 00000000..ed7a8edd
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
@@ -0,0 +1,70 @@
+package com.cameleer3.server.app.security;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link BootstrapTokenValidator}.
+ * No Spring context needed — implementation constructed directly.
+ */
+class BootstrapTokenValidatorTest {
+
+ @Test
+ void validate_correctToken_returnsTrue() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("my-secret-token");
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertTrue(validator.validate("my-secret-token"));
+ }
+
+ @Test
+ void validate_wrongToken_returnsFalse() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("my-secret-token");
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertFalse(validator.validate("wrong-token"));
+ }
+
+ @Test
+ void validate_previousToken_returnsTrueWhenSet() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("new-token");
+ props.setBootstrapTokenPrevious("old-token");
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertTrue(validator.validate("old-token"), "Previous token should be accepted during rotation");
+ }
+
+ @Test
+ void validate_nullToken_returnsFalse() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("my-secret-token");
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertFalse(validator.validate(null));
+ }
+
+ @Test
+ void validate_blankToken_returnsFalse() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("my-secret-token");
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertFalse(validator.validate(""));
+ assertFalse(validator.validate(" "));
+ }
+
+ @Test
+ void validate_previousTokenNotSet_onlyCurrentAccepted() {
+ SecurityProperties props = new SecurityProperties();
+ props.setBootstrapToken("current-token");
+ // bootstrapTokenPrevious is null by default
+ BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
+
+ assertTrue(validator.validate("current-token"));
+ assertFalse(validator.validate("some-old-token"));
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java
new file mode 100644
index 00000000..6d8875db
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java
@@ -0,0 +1,88 @@
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.security.Ed25519SigningService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link Ed25519SigningServiceImpl}.
+ * No Spring context needed — implementation constructed directly.
+ */
+class Ed25519SigningServiceTest {
+
+ private Ed25519SigningService signingService;
+
+ @BeforeEach
+ void setUp() {
+ signingService = new Ed25519SigningServiceImpl();
+ }
+
+ @Test
+ void getPublicKeyBase64_returnsNonNullBase64String() {
+ String publicKeyBase64 = signingService.getPublicKeyBase64();
+ assertNotNull(publicKeyBase64);
+ assertFalse(publicKeyBase64.isBlank());
+ // Verify it's valid Base64
+ assertDoesNotThrow(() -> Base64.getDecoder().decode(publicKeyBase64));
+ }
+
+ @Test
+ void sign_returnsBase64SignatureString() {
+ String signature = signingService.sign("test payload");
+ assertNotNull(signature);
+ assertFalse(signature.isBlank());
+ assertDoesNotThrow(() -> Base64.getDecoder().decode(signature));
+ }
+
+ @Test
+ void sign_signatureVerifiesAgainstPublicKey() throws Exception {
+ String payload = "important config data";
+ String signatureBase64 = signingService.sign(payload);
+ String publicKeyBase64 = signingService.getPublicKeyBase64();
+
+ // Reconstruct public key from Base64
+ byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
+ KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
+ PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
+
+ // Verify signature
+ Signature verifier = Signature.getInstance("Ed25519");
+ verifier.initVerify(publicKey);
+ verifier.update(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ assertTrue(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
+ "Signature should verify against the public key");
+ }
+
+ @Test
+ void sign_differentPayloadsProduceDifferentSignatures() {
+ String sig1 = signingService.sign("payload one");
+ String sig2 = signingService.sign("payload two");
+ assertNotEquals(sig1, sig2, "Different payloads should produce different signatures");
+ }
+
+ @Test
+ void sign_tamperedPayloadFailsVerification() throws Exception {
+ String payload = "original payload";
+ String signatureBase64 = signingService.sign(payload);
+ String publicKeyBase64 = signingService.getPublicKeyBase64();
+
+ byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
+ KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
+ PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
+
+ // Verify against tampered payload
+ Signature verifier = Signature.getInstance("Ed25519");
+ verifier.initVerify(publicKey);
+ verifier.update("tampered payload".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ assertFalse(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
+ "Tampered payload should fail verification");
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
new file mode 100644
index 00000000..99784949
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
@@ -0,0 +1,86 @@
+package com.cameleer3.server.app.security;
+
+import com.cameleer3.server.core.security.InvalidTokenException;
+import com.cameleer3.server.core.security.JwtService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link JwtServiceImpl}.
+ * No Spring context needed — implementations are constructed directly.
+ */
+class JwtServiceTest {
+
+ private JwtService jwtService;
+
+ @BeforeEach
+ void setUp() {
+ SecurityProperties props = new SecurityProperties();
+ props.setAccessTokenExpiryMs(3_600_000); // 1 hour
+ props.setRefreshTokenExpiryMs(604_800_000); // 7 days
+ jwtService = new JwtServiceImpl(props);
+ }
+
+ @Test
+ void createAccessToken_returnsSignedJwtWithCorrectClaims() {
+ String token = jwtService.createAccessToken("agent-1", "group-a");
+ assertNotNull(token);
+ assertFalse(token.isBlank());
+ // JWT format: header.payload.signature
+ assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
+ }
+
+ @Test
+ void createAccessToken_canBeValidated() {
+ String token = jwtService.createAccessToken("agent-1", "group-a");
+ String agentId = jwtService.validateAndExtractAgentId(token);
+ assertEquals("agent-1", agentId);
+ }
+
+ @Test
+ void createRefreshToken_returnsSignedJwt() {
+ String token = jwtService.createRefreshToken("agent-2", "group-b");
+ assertNotNull(token);
+ assertFalse(token.isBlank());
+ assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
+ }
+
+ @Test
+ void createRefreshToken_canBeValidatedWithRefreshMethod() {
+ String token = jwtService.createRefreshToken("agent-2", "group-b");
+ String agentId = jwtService.validateRefreshToken(token);
+ assertEquals("agent-2", agentId);
+ }
+
+ @Test
+ void validateAndExtractAgentId_rejectsRefreshToken() {
+ String refreshToken = jwtService.createRefreshToken("agent-3", "group-c");
+ assertThrows(InvalidTokenException.class, () ->
+ jwtService.validateAndExtractAgentId(refreshToken),
+ "Access validation should reject refresh tokens");
+ }
+
+ @Test
+ void validateRefreshToken_rejectsAccessToken() {
+ String accessToken = jwtService.createAccessToken("agent-4", "group-d");
+ assertThrows(InvalidTokenException.class, () ->
+ jwtService.validateRefreshToken(accessToken),
+ "Refresh validation should reject access tokens");
+ }
+
+ @Test
+ void validateAndExtractAgentId_rejectsExpiredToken() {
+ // Create a service with 0ms expiry to produce already-expired tokens
+ SecurityProperties shortProps = new SecurityProperties();
+ shortProps.setAccessTokenExpiryMs(0);
+ shortProps.setRefreshTokenExpiryMs(604_800_000);
+ JwtService shortLivedService = new JwtServiceImpl(shortProps);
+
+ String token = shortLivedService.createAccessToken("agent-5", "group-e");
+ assertThrows(InvalidTokenException.class, () ->
+ jwtService.validateAndExtractAgentId(token),
+ "Should reject expired tokens");
+ }
+}
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java
new file mode 100644
index 00000000..8f337922
--- /dev/null
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java
@@ -0,0 +1,28 @@
+package com.cameleer3.server.core.security;
+
+/**
+ * Service for Ed25519 digital signatures.
+ *
+ * Used to sign configuration and command payloads pushed to agents via SSE,
+ * allowing agents to verify the authenticity of received data.
+ * The keypair is ephemeral (generated at startup); agents receive the public
+ * key during registration.
+ */
+public interface Ed25519SigningService {
+
+ /**
+ * Signs the given payload using the server's Ed25519 private key.
+ *
+ * @param payload the string payload to sign
+ * @return Base64-encoded signature bytes
+ */
+ String sign(String payload);
+
+ /**
+ * Returns the server's Ed25519 public key as a Base64-encoded string
+ * (X.509 SubjectPublicKeyInfo DER format).
+ *
+ * @return Base64-encoded public key
+ */
+ String getPublicKeyBase64();
+}
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/InvalidTokenException.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/InvalidTokenException.java
new file mode 100644
index 00000000..f6daed00
--- /dev/null
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/InvalidTokenException.java
@@ -0,0 +1,15 @@
+package com.cameleer3.server.core.security;
+
+/**
+ * Thrown when a JWT token is invalid, expired, or of the wrong type.
+ */
+public class InvalidTokenException extends RuntimeException {
+
+ public InvalidTokenException(String message) {
+ super(message);
+ }
+
+ public InvalidTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
new file mode 100644
index 00000000..dc8f5318
--- /dev/null
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
@@ -0,0 +1,48 @@
+package com.cameleer3.server.core.security;
+
+/**
+ * Service for creating and validating JSON Web Tokens (JWT).
+ *
+ * Access tokens are short-lived (default 1 hour) and used for API authentication.
+ * Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens.
+ */
+public interface JwtService {
+
+ /**
+ * Creates a signed access JWT with the given agent ID and group.
+ *
+ * @param agentId the agent identifier (becomes the {@code sub} claim)
+ * @param group the agent group (becomes the {@code group} claim)
+ * @return a signed JWT string
+ */
+ String createAccessToken(String agentId, String group);
+
+ /**
+ * Creates a signed refresh JWT with the given agent ID and group.
+ *
+ * @param agentId the agent identifier (becomes the {@code sub} claim)
+ * @param group the agent group (becomes the {@code group} claim)
+ * @return a signed JWT string
+ */
+ String createRefreshToken(String agentId, String group);
+
+ /**
+ * Validates an access token and extracts the agent ID.
+ * Rejects expired tokens and tokens that are not of type "access".
+ *
+ * @param token the JWT string to validate
+ * @return the agent ID from the {@code sub} claim
+ * @throws InvalidTokenException if the token is invalid, expired, or not an access token
+ */
+ String validateAndExtractAgentId(String token);
+
+ /**
+ * Validates a refresh token and extracts the agent ID.
+ * Rejects expired tokens and tokens that are not of type "refresh".
+ *
+ * @param token the JWT string to validate
+ * @return the agent ID from the {@code sub} claim
+ * @throws InvalidTokenException if the token is invalid, expired, or not a refresh token
+ */
+ String validateRefreshToken(String token);
+}