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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
package com.cameleer3.server.app.security;
|
package com.cameleer3.server.app.security;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates bootstrap tokens used for initial agent registration.
|
* Validates bootstrap tokens used for initial agent registration.
|
||||||
* Stub — to be implemented in GREEN phase.
|
* <p>
|
||||||
|
* 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 {
|
public class BootstrapTokenValidator {
|
||||||
|
|
||||||
@@ -19,6 +25,26 @@ public class BootstrapTokenValidator {
|
|||||||
* @return true if the token matches the current or previous bootstrap token
|
* @return true if the token matches the current or previous bootstrap token
|
||||||
*/
|
*/
|
||||||
public boolean validate(String provided) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,53 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
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.
|
* JDK 17 Ed25519 signing implementation.
|
||||||
* Stub — to be implemented in GREEN phase.
|
* <p>
|
||||||
|
* 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 {
|
public class Ed25519SigningServiceImpl implements Ed25519SigningService {
|
||||||
|
|
||||||
|
private final PrivateKey privateKey;
|
||||||
|
private final PublicKey publicKey;
|
||||||
|
|
||||||
public Ed25519SigningServiceImpl() {
|
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
|
@Override
|
||||||
public String sign(String payload) {
|
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
|
@Override
|
||||||
public String getPublicKeyBase64() {
|
public String getPublicKeyBase64() {
|
||||||
throw new UnsupportedOperationException("Not yet implemented");
|
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,119 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
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.
|
* HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
|
||||||
* Stub — to be implemented in GREEN phase.
|
* <p>
|
||||||
|
* 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 {
|
public class JwtServiceImpl implements JwtService {
|
||||||
|
|
||||||
|
private final byte[] secret;
|
||||||
|
private final JWSSigner signer;
|
||||||
|
private final JWSVerifier verifier;
|
||||||
private final SecurityProperties properties;
|
private final SecurityProperties properties;
|
||||||
|
|
||||||
public JwtServiceImpl(SecurityProperties properties) {
|
public JwtServiceImpl(SecurityProperties properties) {
|
||||||
this.properties = 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
|
@Override
|
||||||
public String createAccessToken(String agentId, String group) {
|
public String createAccessToken(String agentId, String group) {
|
||||||
throw new UnsupportedOperationException("Not yet implemented");
|
return createToken(agentId, group, "access", properties.getAccessTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createRefreshToken(String agentId, String group) {
|
public String createRefreshToken(String agentId, String group) {
|
||||||
throw new UnsupportedOperationException("Not yet implemented");
|
return createToken(agentId, group, "refresh", properties.getRefreshTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String validateAndExtractAgentId(String token) {
|
public String validateAndExtractAgentId(String token) {
|
||||||
throw new UnsupportedOperationException("Not yet implemented");
|
return validateToken(token, "access");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String validateRefreshToken(String token) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ ingestion:
|
|||||||
clickhouse:
|
clickhouse:
|
||||||
ttl-days: 30
|
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:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
path: /api/v1/api-docs
|
path: /api/v1/api-docs
|
||||||
|
|||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,7 @@ ingestion:
|
|||||||
|
|
||||||
agent-registry:
|
agent-registry:
|
||||||
ping-interval-ms: 1000
|
ping-interval-ms: 1000
|
||||||
|
|
||||||
|
security:
|
||||||
|
bootstrap-token: test-bootstrap-token
|
||||||
|
bootstrap-token-previous: old-bootstrap-token
|
||||||
|
|||||||
Reference in New Issue
Block a user