diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml index 14fc2f5e..6c8bef54 100644 --- a/cameleer3-server-app/pom.xml +++ b/cameleer3-server-app/pom.xml @@ -66,11 +66,25 @@ org.eclipse.xtext.xbase.lib 2.37.0 + + org.springframework.boot + spring-boot-starter-security + + + com.nimbusds + nimbus-jose-jwt + 9.47 + org.springframework.boot spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + 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); +}