Rename Java packages from com.cameleer3 to com.cameleer, module directories from cameleer3-* to cameleer-*, and all references throughout workflows, Dockerfiles, docs, migrations, and pom.xml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
501 lines
31 KiB
Markdown
501 lines
31 KiB
Markdown
# Phase 4: Security - Research
|
|
|
|
**Researched:** 2026-03-11
|
|
**Domain:** Spring Security JWT authentication, Ed25519 signing, bootstrap token validation
|
|
**Confidence:** HIGH
|
|
|
|
## Summary
|
|
|
|
This phase adds authentication and integrity protection to the Cameleer server. The implementation uses Spring Security 6.4.3 (managed by Spring Boot 3.4.3) with a custom `OncePerRequestFilter` for JWT validation, JDK 17 built-in Ed25519 for signing SSE payloads, and environment variable-based bootstrap tokens for agent registration. The approach is deliberately simple -- no OAuth2 resource server, no external identity provider, just symmetric HMAC JWTs for access control and Ed25519 signatures for payload integrity.
|
|
|
|
The existing codebase has clear integration points: `AgentRegistrationController.register()` already returns `serverPublicKey: null` as a placeholder, `SseConnectionManager.onCommandReady()` is the signing hook for SSE events, and `WebConfig` already defines excluded paths that align with the public endpoint list. Spring Security's `SecurityFilterChain` replaces the need for hand-rolled authorization logic -- endpoints are protected by default, with explicit `permitAll()` for health, register, and docs.
|
|
|
|
**Primary recommendation:** Use Nimbus JOSE+JWT (transitive via `spring-boot-starter-security`) with HMAC-SHA256 for JWTs, JDK built-in `KeyPairGenerator.getInstance("Ed25519")` for signing keypairs, and a single `SecurityFilterChain` bean with a custom `JwtAuthenticationFilter extends OncePerRequestFilter` added before `UsernamePasswordAuthenticationFilter`.
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
- Single shared token from `CAMELEER_AUTH_TOKEN` env var -- no config file fallback
|
|
- Agent passes bootstrap token via `Authorization: Bearer <token>` header on `POST /register`
|
|
- Server returns `401 Unauthorized` when token is missing or invalid -- no detail about what's wrong
|
|
- Server fails fast on startup if `CAMELEER_AUTH_TOKEN` is not set -- prevents running insecure
|
|
- Hot rotation via dual-token overlap: support `CAMELEER_AUTH_TOKEN_PREVIOUS` env var, server accepts both during rotation window
|
|
- Access JWT expires after 1 hour
|
|
- Separate refresh token with 7-day expiry, issued alongside access JWT at registration
|
|
- Agent calls `POST /api/v1/agents/{id}/refresh` with refresh token to get new access JWT
|
|
- JWT claims: `sub` = agentId, custom claim for group
|
|
- Registration response includes both access JWT and refresh token (replaces current `serverPublicKey: null` placeholder with actual public key)
|
|
- Ephemeral keypair generated fresh each server startup -- no persistence needed
|
|
- Agents receive public key during registration; must re-register after server restart to get new key
|
|
- Signature included as a `signature` field in the SSE event data JSON -- agent verifies payload minus signature field
|
|
- All command types signed (config-update, deep-trace, replay) -- uniform security model
|
|
- Public (no JWT): `GET /health`, `POST /register` (uses bootstrap token), OpenAPI/Swagger UI docs
|
|
- Protected (JWT required): all other endpoints including ingestion (`/data/**`), search, agent management, commands
|
|
- SSE connections authenticated via JWT as query parameter: `/agents/{id}/events?token=<jwt>` (EventSource API doesn't support custom headers)
|
|
- Spring Security filter chain (`spring-boot-starter-security`) with custom `JwtAuthenticationFilter`
|
|
|
|
### Claude's Discretion
|
|
- JWT signing algorithm (HMAC with server secret vs Ed25519 for JWT too)
|
|
- Nimbus JOSE+JWT vs jjwt vs other JWT library
|
|
- Ed25519 implementation library (Bouncy Castle vs JDK built-in)
|
|
- Spring Security configuration details (SecurityFilterChain bean, permit patterns)
|
|
- Refresh token storage mechanism (in-memory map, agent registry, or stateless)
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
- User/UI authentication -- belongs with web UI in v2
|
|
- Role-based access control (admin vs agent vs viewer) -- future phase
|
|
- Token revocation list -- evaluate after v1 usage patterns
|
|
- Mutual TLS as additional transport security -- infrastructure concern, not application layer
|
|
- Key rotation API endpoint -- adds attack surface, stick with restart-based rotation for v1
|
|
</user_constraints>
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|-----------------|
|
|
| SECU-01 (#23) | All API endpoints (except health and register) require valid JWT Bearer token | Spring Security `SecurityFilterChain` with `permitAll()` for public paths, custom `JwtAuthenticationFilter` for JWT validation |
|
|
| SECU-02 (#24) | JWT refresh flow via `POST /api/v1/agents/{id}/refresh` | Nimbus JOSE+JWT for JWT creation/validation, stateless refresh tokens with longer expiry |
|
|
| SECU-03 (#25) | Server generates Ed25519 keypair; public key delivered at registration | JDK 17 built-in `KeyPairGenerator.getInstance("Ed25519")`, Base64-encoded public key in registration response |
|
|
| SECU-04 (#26) | All config-update and replay SSE payloads signed with Ed25519 private key | JDK 17 `Signature.getInstance("Ed25519")`, signing hook in `SseConnectionManager.onCommandReady()` |
|
|
| SECU-05 (#27) | Bootstrap token from `CAMELEER_AUTH_TOKEN` env var validates initial agent registration | `@Value` injection with startup validation, checked before registration logic |
|
|
</phase_requirements>
|
|
|
|
## Standard Stack
|
|
|
|
### Core
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| spring-boot-starter-security | 3.4.3 (managed) | Security filter chain, authentication framework | Spring Boot's standard security starter; brings Spring Security 6.4.3 |
|
|
| nimbus-jose-jwt | 9.37+ (transitive via spring-security-oauth2-jose) | JWT creation, signing, parsing, verification | Spring Security's own JWT library; already in the Spring ecosystem |
|
|
| JDK Ed25519 | JDK 17 built-in | Ed25519 keypair generation and signing | Native support since Java 15 via `java.security.KeyPairGenerator` and `java.security.Signature`; no external dependency needed |
|
|
|
|
### Supporting
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| spring-boot-starter-test | 3.4.3 (managed) | MockMvc with security context, `@WithMockUser` support | Already present; tests gain security testing support automatically |
|
|
|
|
### Alternatives Considered
|
|
| Instead of | Could Use | Tradeoff |
|
|
|------------|-----------|----------|
|
|
| Nimbus JOSE+JWT | JJWT (io.jsonwebtoken) | JJWT is simpler API but doesn't support JWE; Nimbus is already a Spring Security transitive dependency so adding it explicitly costs zero |
|
|
| JDK Ed25519 | Bouncy Castle | Bouncy Castle adds ~5MB dependency for something JDK 17 does natively; only needed if targeting Java < 15 |
|
|
| HMAC-SHA256 for JWT | Ed25519 for JWT too | HMAC is simpler for server-only JWT creation/validation (no key distribution needed); Ed25519 for JWT only matters if a third party validates JWTs |
|
|
|
|
**Discretion Recommendations:**
|
|
- **JWT signing algorithm:** Use HMAC-SHA256 (HS256). The server both creates and validates JWTs -- no external party needs to verify them. HMAC is simpler (one shared secret vs keypair), and the 256-bit secret can be generated randomly at startup (ephemeral, like the Ed25519 key). This keeps JWT signing separate from Ed25519 payload signing -- cleaner separation of concerns.
|
|
- **JWT library:** Use Nimbus JOSE+JWT. It is Spring Security's transitive dependency, so it costs nothing extra. Adding `spring-boot-starter-security` brings `spring-security-oauth2-jose` which includes Nimbus. Alternatively, add `com.nimbusds:nimbus-jose-jwt` directly if not pulling the full OAuth2 stack.
|
|
- **Ed25519 library:** Use JDK built-in. Zero external dependencies, native performance, well-tested in JDK 17+.
|
|
- **Refresh token storage:** Use stateless signed refresh tokens (also HMAC-signed JWTs with different claims/expiry). This avoids any in-memory storage for refresh tokens and scales naturally. The refresh token is just a JWT with `type=refresh`, `sub=agentId`, and 7-day expiry. On refresh, validate the refresh JWT, check agent still exists, issue new access JWT.
|
|
|
|
**Installation (add to cameleer-server-app pom.xml):**
|
|
```xml
|
|
<dependency>
|
|
<groupId>org.springframework.boot</groupId>
|
|
<artifactId>spring-boot-starter-security</artifactId>
|
|
</dependency>
|
|
<dependency>
|
|
<groupId>com.nimbusds</groupId>
|
|
<artifactId>nimbus-jose-jwt</artifactId>
|
|
<version>9.47</version>
|
|
</dependency>
|
|
```
|
|
|
|
Note: If `spring-boot-starter-security` brings Nimbus transitively (via `spring-security-oauth2-jose`), the explicit Nimbus dependency is optional. However, since we are NOT using Spring Security's OAuth2 resource server (we have a custom filter), adding Nimbus explicitly ensures it is available. Check if the starter alone suffices; if not, add Nimbus directly.
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
```
|
|
cameleer-server-core/src/main/java/com/cameleer/server/core/
|
|
security/
|
|
JwtService.java # Interface: createAccessToken, createRefreshToken, validateToken, extractAgentId
|
|
Ed25519SigningService.java # Interface: sign(payload) -> signature, getPublicKeyBase64()
|
|
|
|
cameleer-server-app/src/main/java/com/cameleer/server/app/
|
|
security/
|
|
JwtServiceImpl.java # Nimbus JOSE+JWT HMAC implementation
|
|
Ed25519SigningServiceImpl.java # JDK Ed25519 keypair + signing implementation
|
|
JwtAuthenticationFilter.java # OncePerRequestFilter: extract JWT, validate, set SecurityContext
|
|
BootstrapTokenValidator.java # Validates bootstrap token(s) from env vars
|
|
SecurityConfig.java # SecurityFilterChain bean, permit patterns
|
|
config/
|
|
SecurityProperties.java # @ConfigurationProperties for token expiry, etc.
|
|
```
|
|
|
|
### Pattern 1: SecurityFilterChain with Custom JWT Filter
|
|
**What:** Single `SecurityFilterChain` bean that permits public paths and requires authentication everywhere else, with a custom `JwtAuthenticationFilter` added before Spring's `UsernamePasswordAuthenticationFilter`.
|
|
**When to use:** Always -- this is the sole security configuration.
|
|
**Example:**
|
|
```java
|
|
// Source: Spring Security 6.4 official docs + Spring Boot 3.4 patterns
|
|
@Configuration
|
|
@EnableWebSecurity
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
|
JwtAuthenticationFilter jwtFilter) throws Exception {
|
|
http
|
|
.csrf(csrf -> csrf.disable()) // REST API, no browser forms
|
|
.sessionManagement(session -> session
|
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
.authorizeHttpRequests(auth -> auth
|
|
.requestMatchers("/api/v1/health").permitAll()
|
|
.requestMatchers("/api/v1/agents/register").permitAll()
|
|
.requestMatchers("/api/v1/api-docs/**").permitAll()
|
|
.requestMatchers("/api/v1/swagger-ui/**").permitAll()
|
|
.requestMatchers("/swagger-ui/**").permitAll()
|
|
.requestMatchers("/v3/api-docs/**").permitAll()
|
|
.anyRequest().authenticated()
|
|
)
|
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
|
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern 2: JwtAuthenticationFilter (OncePerRequestFilter)
|
|
**What:** Extracts JWT from `Authorization: Bearer <token>` header (or `token` query param for SSE), validates it, and sets a Spring Security `Authentication` object in the `SecurityContextHolder`.
|
|
**When to use:** Every authenticated request.
|
|
**Example:**
|
|
```java
|
|
// Custom filter pattern for Spring Security 6.x
|
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|
|
|
private final JwtService jwtService;
|
|
private final AgentRegistryService agentRegistry;
|
|
|
|
@Override
|
|
protected void doFilterInternal(HttpServletRequest request,
|
|
HttpServletResponse response,
|
|
FilterChain chain) throws ServletException, IOException {
|
|
String token = extractToken(request);
|
|
if (token != null) {
|
|
try {
|
|
String agentId = jwtService.validateAndExtractAgentId(token);
|
|
// Verify agent still exists
|
|
if (agentRegistry.findById(agentId) != null) {
|
|
var auth = new UsernamePasswordAuthenticationToken(
|
|
agentId, null, List.of());
|
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
|
}
|
|
} catch (Exception e) {
|
|
// Invalid token -- do not set authentication, Spring Security will reject
|
|
}
|
|
}
|
|
chain.doFilter(request, response);
|
|
}
|
|
|
|
private String extractToken(HttpServletRequest request) {
|
|
// 1. Check Authorization header
|
|
String header = request.getHeader("Authorization");
|
|
if (header != null && header.startsWith("Bearer ")) {
|
|
return header.substring(7);
|
|
}
|
|
// 2. Check query parameter (for SSE EventSource)
|
|
return request.getParameter("token");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern 3: Ed25519 Payload Signing in SSE Delivery
|
|
**What:** Before sending an SSE event, serialize the payload JSON, compute an Ed25519 signature, add the `signature` field to the JSON, then send.
|
|
**When to use:** Every SSE command delivery (config-update, deep-trace, replay).
|
|
**Example:**
|
|
```java
|
|
// JDK 17 built-in Ed25519 signing
|
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
|
KeyPair keyPair = keyGen.generateKeyPair();
|
|
|
|
// Signing
|
|
Signature signer = Signature.getInstance("Ed25519");
|
|
signer.initSign(keyPair.getPrivate());
|
|
signer.update(payloadJson.getBytes(StandardCharsets.UTF_8));
|
|
byte[] signatureBytes = signer.sign();
|
|
String signatureBase64 = Base64.getEncoder().encodeToString(signatureBytes);
|
|
|
|
// Public key for registration response
|
|
String publicKeyBase64 = Base64.getEncoder().encodeToString(
|
|
keyPair.getPublic().getEncoded()); // X.509 SubjectPublicKeyInfo DER encoding
|
|
```
|
|
|
|
### Pattern 4: Bootstrap Token Validation
|
|
**What:** Check `Authorization: Bearer <token>` on `POST /register` against `CAMELEER_AUTH_TOKEN` (and optionally `CAMELEER_AUTH_TOKEN_PREVIOUS`).
|
|
**When to use:** Only on the registration endpoint.
|
|
**Example:**
|
|
```java
|
|
// Startup validation in a @Component or @Bean init
|
|
@Value("${CAMELEER_AUTH_TOKEN:#{null}}")
|
|
private String bootstrapToken;
|
|
|
|
@PostConstruct
|
|
void validateBootstrapToken() {
|
|
if (bootstrapToken == null || bootstrapToken.isBlank()) {
|
|
throw new IllegalStateException(
|
|
"CAMELEER_AUTH_TOKEN environment variable must be set");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Anti-Patterns to Avoid
|
|
- **Registering JwtAuthenticationFilter as a @Bean without @Component exclusion:** If marked as `@Component`, Spring Boot will register it as a global servlet filter AND in the security chain, running it twice. Either do NOT annotate it as `@Component` (construct it manually in the `SecurityConfig` bean) or use `FilterRegistrationBean` to disable auto-registration.
|
|
- **Checking JWT on every request including permitAll paths:** The filter runs on all requests, but should gracefully skip validation for public paths (just call `chain.doFilter` if no token present -- Spring Security's authorization rules handle the rest).
|
|
- **Storing refresh tokens in-memory:** Unnecessarily complex and lost on restart. Stateless signed refresh tokens are sufficient.
|
|
- **Using Ed25519 for JWT signing:** Adds complexity (key distribution, asymmetric operations) for no benefit when only the server creates and validates JWTs.
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| JWT creation/validation | Custom token format or Base64 JSON | Nimbus JOSE+JWT `SignedJWT` + `MACSigner`/`MACVerifier` | Handles algorithm validation, claim parsing, expiry checks, type-safe builders |
|
|
| Request authentication | Custom servlet filter checking headers manually | Spring Security `SecurityFilterChain` + `OncePerRequestFilter` | Handles CORS, CSRF disabling, session management, exception handling, path matching |
|
|
| Ed25519 signing | Hand-rolled crypto or custom signature format | JDK `java.security.Signature` + `java.security.KeyPairGenerator` | Audited, constant-time, handles DER encoding properly |
|
|
| Constant-time token comparison | `String.equals()` for bootstrap token | `MessageDigest.isEqual()` | Prevents timing attacks on bootstrap token validation |
|
|
| Public key encoding | Custom byte formatting | `PublicKey.getEncoded()` + Base64 | Standard X.509 SubjectPublicKeyInfo DER format, interoperable with any Ed25519 library |
|
|
|
|
**Key insight:** Cryptographic code has an extraordinary surface area for subtle bugs (timing attacks, encoding mismatches, algorithm confusion). Every piece should use battle-tested library methods.
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Double Filter Registration
|
|
**What goes wrong:** Annotating `JwtAuthenticationFilter` with `@Component` causes Spring Boot to auto-register it as a global servlet filter AND Spring Security adds it to the filter chain, resulting in the filter executing twice per request.
|
|
**Why it happens:** Spring Boot auto-detects `@Component` classes that extend `Filter` and registers them globally.
|
|
**How to avoid:** Do NOT annotate the filter as `@Component`. Instead, construct it in `SecurityConfig` and pass it to `addFilterBefore()`. If you must use `@Component`, add a `FilterRegistrationBean` that disables auto-registration.
|
|
**Warning signs:** Filter logging messages appear twice per request; 401 responses on valid tokens (filter runs before SecurityFilterChain on second pass).
|
|
|
|
### Pitfall 2: Spring Security Blocking Existing Tests
|
|
**What goes wrong:** Adding `spring-boot-starter-security` immediately makes all endpoints return 401/403 in existing integration tests.
|
|
**Why it happens:** Spring Security's default configuration denies all requests. Existing tests don't include JWT tokens.
|
|
**How to avoid:** Two approaches: (1) Add `@WithMockUser` or test-specific security configuration for existing tests, or (2) set a test-profile application-test.yml property with a known bootstrap token and have test helpers generate valid JWTs. Prefer option (2) for realistic security testing.
|
|
**Warning signs:** All existing ITs start failing with 401 after adding the security starter.
|
|
|
|
### Pitfall 3: SSE Token in URL Logged/Cached
|
|
**What goes wrong:** JWT passed as query parameter `?token=<jwt>` appears in server access logs, proxy logs, and browser history.
|
|
**Why it happens:** Query parameters are part of the URL, which is logged by default.
|
|
**How to avoid:** Use short-lived access JWTs (1 hour is fine). Consider filtering the `token` parameter from access logs. The EventSource API limitation makes this unavoidable -- document it as a known tradeoff.
|
|
**Warning signs:** JWT tokens visible in plain text in log files.
|
|
|
|
### Pitfall 4: Timing Attack on Bootstrap Token
|
|
**What goes wrong:** Using `String.equals()` for bootstrap token comparison leaks token length/prefix via timing side-channel.
|
|
**Why it happens:** `String.equals()` short-circuits on first mismatch.
|
|
**How to avoid:** Use `MessageDigest.isEqual(a.getBytes(), b.getBytes())` for constant-time comparison.
|
|
**Warning signs:** None visible in normal operation -- this is a preventive measure.
|
|
|
|
### Pitfall 5: Ed25519 Signature Field Ordering
|
|
**What goes wrong:** Agent cannot verify signature because JSON field ordering differs between signing and verification.
|
|
**Why it happens:** JSON object field order is not guaranteed. If the server signs a different serialization than the agent verifies, signatures won't match.
|
|
**How to avoid:** Sign the JSON payload WITHOUT the `signature` field (sign the payload as-is before adding the signature). Document clearly: "signature is computed over the `data` field value of the SSE event, excluding the `signature` key". Use a canonical approach: sign the payload JSON string, then wrap it in an outer object with `data` and `signature` fields.
|
|
**Warning signs:** Signature verification fails intermittently or consistently on the agent side.
|
|
|
|
### Pitfall 6: Forgetting to Exclude Actuator/Springdoc Paths
|
|
**What goes wrong:** Health endpoint returns 401 because the SecurityFilterChain doesn't match the actuator path format.
|
|
**Why it happens:** Actuator's base path is configured as `/api/v1` in this project (see `management.endpoints.web.base-path`), so health is at `/api/v1/health`. Springdoc paths may also vary depending on configuration.
|
|
**How to avoid:** Ensure `requestMatchers` covers: `/api/v1/health`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**` (springdoc internal redirects).
|
|
**Warning signs:** Health checks fail, Swagger UI returns 401.
|
|
|
|
## Code Examples
|
|
|
|
### JWT Creation with Nimbus JOSE+JWT (HMAC-SHA256)
|
|
```java
|
|
// Source: https://connect2id.com/products/nimbus-jose-jwt/examples/jwt-with-hmac
|
|
import com.nimbusds.jose.*;
|
|
import com.nimbusds.jose.crypto.*;
|
|
import com.nimbusds.jwt.*;
|
|
import java.util.Date;
|
|
|
|
// Generate a random 256-bit secret at startup
|
|
byte[] secret = new byte[32];
|
|
new java.security.SecureRandom().nextBytes(secret);
|
|
|
|
// Create access token
|
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
|
.subject(agentId) // sub = agentId
|
|
.claim("group", group) // custom claim
|
|
.claim("type", "access") // distinguish from refresh
|
|
.issueTime(new Date())
|
|
.expirationTime(new Date(System.currentTimeMillis() + 3600_000)) // 1 hour
|
|
.build();
|
|
|
|
SignedJWT jwt = new SignedJWT(
|
|
new JWSHeader(JWSAlgorithm.HS256),
|
|
claims);
|
|
jwt.sign(new MACSigner(secret));
|
|
String tokenString = jwt.serialize();
|
|
|
|
// Validate token
|
|
SignedJWT parsed = SignedJWT.parse(tokenString);
|
|
boolean valid = parsed.verify(new MACVerifier(secret));
|
|
// Then check: claims.getExpirationTime().after(new Date())
|
|
// Then check: claims.getStringClaim("type").equals("access")
|
|
```
|
|
|
|
### Ed25519 Keypair Generation and Signing (JDK 17)
|
|
```java
|
|
// Source: https://howtodoinjava.com/java15/java-eddsa-example/
|
|
import java.security.*;
|
|
import java.util.Base64;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
// Generate ephemeral keypair at startup
|
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
|
KeyPair keyPair = keyGen.generateKeyPair();
|
|
|
|
// Export public key as Base64 (X.509 SubjectPublicKeyInfo DER)
|
|
String publicKeyBase64 = Base64.getEncoder().encodeToString(
|
|
keyPair.getPublic().getEncoded());
|
|
|
|
// Sign a payload
|
|
Signature signer = Signature.getInstance("Ed25519");
|
|
signer.initSign(keyPair.getPrivate());
|
|
signer.update(payloadJson.getBytes(StandardCharsets.UTF_8));
|
|
byte[] sig = signer.sign();
|
|
String signatureBase64 = Base64.getEncoder().encodeToString(sig);
|
|
```
|
|
|
|
### SecurityFilterChain Configuration
|
|
```java
|
|
// Source: Spring Security 6.4 reference docs
|
|
@Configuration
|
|
@EnableWebSecurity
|
|
public class SecurityConfig {
|
|
|
|
@Bean
|
|
public SecurityFilterChain filterChain(HttpSecurity http,
|
|
JwtService jwtService,
|
|
AgentRegistryService registry) throws Exception {
|
|
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtService, registry);
|
|
|
|
http
|
|
.csrf(AbstractHttpConfigurer::disable)
|
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
.authorizeHttpRequests(auth -> auth
|
|
.requestMatchers(
|
|
"/api/v1/health",
|
|
"/api/v1/agents/register",
|
|
"/api/v1/api-docs/**",
|
|
"/api/v1/swagger-ui/**",
|
|
"/swagger-ui/**",
|
|
"/v3/api-docs/**"
|
|
).permitAll()
|
|
.anyRequest().authenticated()
|
|
)
|
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
|
|
|
return http.build();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Bootstrap Token Validation with Constant-Time Comparison
|
|
```java
|
|
import java.security.MessageDigest;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
public boolean validateBootstrapToken(String provided) {
|
|
byte[] providedBytes = provided.getBytes(StandardCharsets.UTF_8);
|
|
byte[] expectedBytes = bootstrapToken.getBytes(StandardCharsets.UTF_8);
|
|
boolean match = MessageDigest.isEqual(providedBytes, expectedBytes);
|
|
|
|
if (!match && previousBootstrapToken != null) {
|
|
byte[] previousBytes = previousBootstrapToken.getBytes(StandardCharsets.UTF_8);
|
|
match = MessageDigest.isEqual(providedBytes, previousBytes);
|
|
}
|
|
return match;
|
|
}
|
|
```
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| `WebSecurityConfigurerAdapter` | `SecurityFilterChain` bean | Spring Security 5.7 / Spring Boot 3.0 | Must use lambda-style `HttpSecurity` configuration |
|
|
| `antMatchers()` | `requestMatchers()` | Spring Security 6.0 | Method name changed; old code won't compile |
|
|
| Ed25519 via Bouncy Castle | JDK built-in Ed25519 | Java 15 (JEP 339) | No external dependency needed for EdDSA |
|
|
| Session-based auth | Stateless JWT | Architectural pattern | `SessionCreationPolicy.STATELESS` mandatory for REST APIs |
|
|
|
|
**Deprecated/outdated:**
|
|
- `WebSecurityConfigurerAdapter`: Removed in Spring Security 6.0. Use `SecurityFilterChain` bean instead.
|
|
- `antMatchers()` / `mvcMatchers()`: Replaced by `requestMatchers()` in Spring Security 6.0.
|
|
- `authorizeRequests()`: Replaced by `authorizeHttpRequests()` in Spring Security 6.0.
|
|
|
|
## Open Questions
|
|
|
|
1. **Nimbus JOSE+JWT transitive availability**
|
|
- What we know: `spring-boot-starter-security` brings Spring Security 6.4.3. If `spring-security-oauth2-jose` is on the classpath, Nimbus is available transitively.
|
|
- What's unclear: Whether the base `spring-boot-starter-security` (without OAuth2 resource server) includes Nimbus.
|
|
- Recommendation: Add `com.nimbusds:nimbus-jose-jwt` explicitly as a dependency. This costs nothing if already transitive and ensures availability if not. Version 9.47 is current and compatible.
|
|
|
|
2. **Existing test adaptation scope**
|
|
- What we know: 21 existing integration tests use `TestRestTemplate` without any auth headers. All will fail when security is enabled.
|
|
- What's unclear: Exact effort to adapt all tests.
|
|
- Recommendation: Create a test utility class that generates valid test JWTs and bootstrap tokens. Set `CAMELEER_AUTH_TOKEN=test-token` in `application-test.yml`. Add JWT header to all test HTTP calls via a shared helper method.
|
|
|
|
## Validation Architecture
|
|
|
|
### Test Framework
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Framework | JUnit 5 + Spring Boot Test (spring-boot-starter-test) |
|
|
| Config file | `cameleer-server-app/src/test/resources/application-test.yml` |
|
|
| Quick run command | `mvn test -pl cameleer-server-app -Dtest=Security*Test -Dsurefire.reuseForks=false` |
|
|
| Full suite command | `mvn clean verify` |
|
|
|
|
### Phase Requirements to Test Map
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
|--------|----------|-----------|-------------------|-------------|
|
|
| SECU-01 | Protected endpoints reject requests without JWT; public endpoints accessible | integration | `mvn test -pl cameleer-server-app -Dtest=SecurityFilterIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| SECU-02 | Refresh endpoint issues new access JWT from valid refresh token | integration | `mvn test -pl cameleer-server-app -Dtest=JwtRefreshIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| SECU-03 | Ed25519 keypair generated at startup; public key in registration response | integration | `mvn test -pl cameleer-server-app -Dtest=RegistrationSecurityIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| SECU-04 | SSE payloads carry valid Ed25519 signature | integration | `mvn test -pl cameleer-server-app -Dtest=SseSigningIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| SECU-05 | Bootstrap token required for registration; rejects invalid/missing tokens | integration | `mvn test -pl cameleer-server-app -Dtest=BootstrapTokenIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| N/A | JWT creation, validation, expiry logic | unit | `mvn test -pl cameleer-server-app -Dtest=JwtServiceTest -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
| N/A | Ed25519 signing and verification roundtrip | unit | `mvn test -pl cameleer-server-app -Dtest=Ed25519SigningServiceTest -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
|
|
|
### Sampling Rate
|
|
- **Per task commit:** `mvn test -pl cameleer-server-app -Dsurefire.reuseForks=false`
|
|
- **Per wave merge:** `mvn clean verify`
|
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
|
|
|
### Wave 0 Gaps
|
|
- [ ] `SecurityFilterIT.java` -- covers SECU-01 (protected/public endpoint access)
|
|
- [ ] `JwtRefreshIT.java` -- covers SECU-02 (refresh flow)
|
|
- [ ] `RegistrationSecurityIT.java` -- covers SECU-03 + SECU-05 (bootstrap token + public key)
|
|
- [ ] `SseSigningIT.java` -- covers SECU-04 (Ed25519 SSE signing)
|
|
- [ ] `BootstrapTokenIT.java` -- covers SECU-05 (bootstrap token validation)
|
|
- [ ] `JwtServiceTest.java` -- unit test for JWT creation/validation
|
|
- [ ] `Ed25519SigningServiceTest.java` -- unit test for Ed25519 signing roundtrip
|
|
- [ ] Update `application-test.yml` with `CAMELEER_AUTH_TOKEN: test-token` and security-related test config
|
|
- [ ] Update ALL existing ITs to include JWT auth headers (21 test files affected)
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- [Spring Security 6.4 Official Docs](https://docs.spring.io/spring-security/reference/servlet/architecture.html) - SecurityFilterChain configuration, filter ordering
|
|
- [Spring Security OAuth2 Resource Server JWT](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html) - JWT handling patterns
|
|
- [Nimbus JOSE+JWT Official Site](https://connect2id.com/products/nimbus-jose-jwt) - Library capabilities, HMAC examples
|
|
- [Nimbus JOSE+JWT HMAC Examples](https://connect2id.com/products/nimbus-jose-jwt/examples/jwt-with-hmac) - JWT creation/verification code
|
|
- [Java EdDSA (Ed25519) - HowToDoInJava](https://howtodoinjava.com/java15/java-eddsa-example/) - JDK built-in Ed25519 API
|
|
- [JDK 17 X509EncodedKeySpec](https://docs.oracle.com/en/java/javase/17/docs/api//java.base/java/security/spec/X509EncodedKeySpec.html) - Public key encoding format
|
|
- Spring Boot 3.4.3 BOM - Confirms Spring Security 6.4.3 managed version
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Baeldung Custom Filter](https://www.baeldung.com/spring-security-custom-filter) - Custom filter registration patterns, double-registration pitfall
|
|
- [Bootiful Spring Boot 3.4: Security](https://spring.io/blog/2024/11/24/bootiful-34-security/) - Spring Boot 3.4 security features overview
|
|
- [Bootify REST API with JWT](https://bootify.io/spring-security/rest-api-spring-security-with-jwt.html) - JWT filter pattern validation
|
|
|
|
### Tertiary (LOW confidence)
|
|
- None -- all findings verified against primary sources
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH - Spring Security 6.4.3 confirmed managed by Spring Boot 3.4.3, Nimbus well-documented, JDK Ed25519 verified for Java 17
|
|
- Architecture: HIGH - SecurityFilterChain pattern is the documented standard for Spring Security 6.x, existing codebase has clear integration points
|
|
- Pitfalls: HIGH - Double filter registration and test breakage are well-documented issues with Spring Security adoption; Ed25519 signing concerns are from domain knowledge
|
|
|
|
**Research date:** 2026-03-11
|
|
**Valid until:** 2026-04-11 (stable -- Spring Boot 3.4.x LTS, JDK 17 LTS)
|