From ac9e8ae4dd9b98f7446ea1895237fcb209731680 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:08:30 +0100 Subject: [PATCH] feat(04-01): implement security service foundation - JwtServiceImpl: HMAC-SHA256 via Nimbus JOSE+JWT with ephemeral 256-bit secret - Ed25519SigningServiceImpl: JDK 17 KeyPairGenerator with ephemeral keypair - BootstrapTokenValidator: constant-time comparison with dual-token rotation - SecurityBeanConfig: bean wiring with fail-fast validation for CAMELEER_AUTH_TOKEN - SecurityProperties: config binding for token expiry and bootstrap tokens - TestSecurityConfig: permit-all filter chain to keep existing tests green - application.yml: security config with env var mapping - All 18 security unit tests pass, all 71 tests pass in full verify Co-Authored-By: Claude Opus 4.6 --- .../app/security/BootstrapTokenValidator.java | 30 +++++- .../security/Ed25519SigningServiceImpl.java | 38 +++++++- .../server/app/security/JwtServiceImpl.java | 93 ++++++++++++++++++- .../app/security/SecurityBeanConfig.java | 43 +++++++++ .../src/main/resources/application.yml | 6 ++ .../app/security/TestSecurityConfig.java | 28 ++++++ .../src/test/resources/application-test.yml | 4 + 7 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java 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 index 0ff82dfe..8019f6fc 100644 --- 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 @@ -1,8 +1,14 @@ package com.cameleer3.server.app.security; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + /** * Validates bootstrap tokens used for initial agent registration. - * Stub — to be implemented in GREEN phase. + *

+ * Uses constant-time comparison ({@link MessageDigest#isEqual}) to prevent + * timing attacks. Supports dual-token rotation: both the current and previous + * bootstrap tokens are accepted during a rotation window. */ public class BootstrapTokenValidator { @@ -19,6 +25,26 @@ public class BootstrapTokenValidator { * @return true if the token matches the current or previous bootstrap token */ public boolean validate(String provided) { - throw new UnsupportedOperationException("Not yet implemented"); + if (provided == null || provided.isBlank()) { + return false; + } + + byte[] providedBytes = provided.getBytes(StandardCharsets.UTF_8); + + // Check current token + String currentToken = properties.getBootstrapToken(); + if (currentToken != null + && MessageDigest.isEqual(providedBytes, currentToken.getBytes(StandardCharsets.UTF_8))) { + return true; + } + + // Check previous token (rotation support) + String previousToken = properties.getBootstrapTokenPrevious(); + if (previousToken != null + && MessageDigest.isEqual(providedBytes, previousToken.getBytes(StandardCharsets.UTF_8))) { + return true; + } + + return false; } } 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 index ac24f68c..bb5e13a6 100644 --- 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 @@ -2,23 +2,53 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.Ed25519SigningService; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.util.Base64; + /** * JDK 17 Ed25519 signing implementation. - * Stub — to be implemented in GREEN phase. + *

+ * Generates an ephemeral keypair at construction time. The public key is made + * available as Base64-encoded X.509 SubjectPublicKeyInfo DER bytes. Agents receive + * this key during registration and use it to verify signed SSE payloads. */ public class Ed25519SigningServiceImpl implements Ed25519SigningService { + private final PrivateKey privateKey; + private final PublicKey publicKey; + public Ed25519SigningServiceImpl() { - // KeyPair generation will go here + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); + KeyPair keyPair = generator.generateKeyPair(); + this.privateKey = keyPair.getPrivate(); + this.publicKey = keyPair.getPublic(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to generate Ed25519 keypair", e); + } } @Override public String sign(String payload) { - throw new UnsupportedOperationException("Not yet implemented"); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(privateKey); + signer.update(payload.getBytes(StandardCharsets.UTF_8)); + byte[] signatureBytes = signer.sign(); + return Base64.getEncoder().encodeToString(signatureBytes); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to sign payload", e); + } } @Override public String getPublicKeyBase64() { - throw new UnsupportedOperationException("Not yet implemented"); + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } } 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 index b268fe00..86310484 100644 --- 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 @@ -2,36 +2,119 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.InvalidTokenException; import com.cameleer3.server.core.security.JwtService; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import java.security.SecureRandom; +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; /** * HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT. - * Stub — to be implemented in GREEN phase. + *

+ * Generates a random 256-bit secret in the constructor (ephemeral per server instance). + * Creates access tokens (1h default) and refresh tokens (7d default) with type claims + * to distinguish between the two. */ public class JwtServiceImpl implements JwtService { + private final byte[] secret; + private final JWSSigner signer; + private final JWSVerifier verifier; private final SecurityProperties properties; public JwtServiceImpl(SecurityProperties properties) { this.properties = properties; + this.secret = new byte[32]; // 256-bit + new SecureRandom().nextBytes(secret); + try { + this.signer = new MACSigner(secret); + this.verifier = new MACVerifier(secret); + } catch (JOSEException e) { + throw new IllegalStateException("Failed to initialize JWT signer/verifier", e); + } } @Override public String createAccessToken(String agentId, String group) { - throw new UnsupportedOperationException("Not yet implemented"); + return createToken(agentId, group, "access", properties.getAccessTokenExpiryMs()); } @Override public String createRefreshToken(String agentId, String group) { - throw new UnsupportedOperationException("Not yet implemented"); + return createToken(agentId, group, "refresh", properties.getRefreshTokenExpiryMs()); } @Override public String validateAndExtractAgentId(String token) { - throw new UnsupportedOperationException("Not yet implemented"); + return validateToken(token, "access"); } @Override public String validateRefreshToken(String token) { - throw new UnsupportedOperationException("Not yet implemented"); + return validateToken(token, "refresh"); + } + + private String createToken(String agentId, String group, String type, long expiryMs) { + Instant now = Instant.now(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .subject(agentId) + .claim("group", group) + .claim("type", type) + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plusMillis(expiryMs))) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + try { + signedJWT.sign(signer); + } catch (JOSEException e) { + throw new IllegalStateException("Failed to sign JWT", e); + } + return signedJWT.serialize(); + } + + private String validateToken(String token, String expectedType) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + + if (!signedJWT.verify(verifier)) { + throw new InvalidTokenException("Invalid JWT signature"); + } + + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + // Check expiration + Date expiration = claims.getExpirationTime(); + if (expiration == null || expiration.before(new Date())) { + throw new InvalidTokenException("Token has expired"); + } + + // Check type + String type = claims.getStringClaim("type"); + if (!expectedType.equals(type)) { + throw new InvalidTokenException( + "Expected token type '" + expectedType + "' but got '" + type + "'"); + } + + String subject = claims.getSubject(); + if (subject == null || subject.isBlank()) { + throw new InvalidTokenException("Token has no subject (agentId)"); + } + + return subject; + } catch (ParseException e) { + throw new InvalidTokenException("Failed to parse JWT", e); + } catch (JOSEException e) { + throw new InvalidTokenException("Failed to verify JWT signature", e); + } } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java new file mode 100644 index 00000000..3dd05fd6 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java @@ -0,0 +1,43 @@ +package com.cameleer3.server.app.security; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class that creates security service beans and validates + * that required security properties are set. + *

+ * Fails fast on startup if {@code CAMELEER_AUTH_TOKEN} is not set. + */ +@Configuration +@EnableConfigurationProperties(SecurityProperties.class) +public class SecurityBeanConfig { + + @Bean + public JwtServiceImpl jwtService(SecurityProperties properties) { + return new JwtServiceImpl(properties); + } + + @Bean + public Ed25519SigningServiceImpl ed25519SigningService() { + return new Ed25519SigningServiceImpl(); + } + + @Bean + public BootstrapTokenValidator bootstrapTokenValidator(SecurityProperties properties) { + return new BootstrapTokenValidator(properties); + } + + @Bean + public InitializingBean bootstrapTokenValidation(SecurityProperties properties) { + return () -> { + String token = properties.getBootstrapToken(); + if (token == null || token.isBlank()) { + throw new IllegalStateException( + "CAMELEER_AUTH_TOKEN environment variable must be set"); + } + }; + } +} diff --git a/cameleer3-server-app/src/main/resources/application.yml b/cameleer3-server-app/src/main/resources/application.yml index 7ce15062..13b155d0 100644 --- a/cameleer3-server-app/src/main/resources/application.yml +++ b/cameleer3-server-app/src/main/resources/application.yml @@ -32,6 +32,12 @@ ingestion: clickhouse: ttl-days: 30 +security: + access-token-expiry-ms: 3600000 + refresh-token-expiry-ms: 604800000 + bootstrap-token: ${CAMELEER_AUTH_TOKEN:} + bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:} + springdoc: api-docs: path: /api/v1/api-docs diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java new file mode 100644 index 00000000..300b5048 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java @@ -0,0 +1,28 @@ +package com.cameleer3.server.app.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Temporary test security configuration that permits all requests. + *

+ * Adding {@code spring-boot-starter-security} enables security by default (all endpoints + * return 401). This configuration overrides that behavior in tests until the real + * security filter chain is configured in Plan 02. + *

+ * Uses {@code @Order(-1)} to take precedence over any auto-configured security filter chain. + */ +@Configuration +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/cameleer3-server-app/src/test/resources/application-test.yml b/cameleer3-server-app/src/test/resources/application-test.yml index 8777cc5f..027a4f67 100644 --- a/cameleer3-server-app/src/test/resources/application-test.yml +++ b/cameleer3-server-app/src/test/resources/application-test.yml @@ -12,3 +12,7 @@ ingestion: agent-registry: ping-interval-ms: 1000 + +security: + bootstrap-token: test-bootstrap-token + bootstrap-token-previous: old-bootstrap-token