docs(04-security): create phase plan

3 plans in 2 waves covering all 5 SECU requirements:
- Plan 01 (W1): Security service foundation (JWT, Ed25519, bootstrap token)
- Plan 02 (W2): Spring Security filter chain, endpoint protection, test adaptation
- Plan 03 (W2): SSE payload signing with Ed25519

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 19:51:22 +01:00
parent cb788def43
commit b7c35037e6
4 changed files with 687 additions and 3 deletions

View File

@@ -76,10 +76,12 @@ Plans:
2. Agents can refresh expired JWTs via the refresh endpoint without re-registering
3. Server generates an Ed25519 keypair at startup, delivers the public key during registration, and all config-update and replay SSE payloads carry a valid Ed25519 signature
4. Bootstrap token from CAMELEER_AUTH_TOKEN environment variable is required for initial agent registration
**Plans**: TBD
**Plans:** 3 plans
Plans:
- [ ] 04-01: JWT authentication filter, refresh flow, Ed25519 keypair generation and config signing, bootstrap token validation
- [ ] 04-01-PLAN.md -- Security service foundation: JwtService, Ed25519SigningService, BootstrapTokenValidator, Maven deps, config
- [ ] 04-02-PLAN.md -- Spring Security filter chain, JWT auth filter, registration/refresh integration, existing test adaptation
- [ ] 04-03-PLAN.md -- Ed25519 signing of SSE command payloads (config-update, deep-trace, replay)
## Progress
@@ -92,4 +94,4 @@ Note: Phases 2 and 3 both depend only on Phase 1 and could execute in parallel.
| 1. Ingestion Pipeline + API Foundation | 3/3 | Complete | 2026-03-11 |
| 2. Transaction Search + Diagrams | 3/4 | Gap Closure | |
| 3. Agent Registry + SSE Push | 2/2 | Complete | 2026-03-11 |
| 4. Security | 0/1 | Not started | - |
| 4. Security | 0/3 | Not started | - |

View File

@@ -0,0 +1,203 @@
---
phase: 04-security
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- cameleer3-server-app/pom.xml
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
- cameleer3-server-app/src/main/resources/application.yml
- cameleer3-server-app/src/test/resources/application-test.yml
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
autonomous: true
requirements:
- SECU-03
- SECU-05
must_haves:
truths:
- "Ed25519 keypair is generated at server startup and public key is available as Base64"
- "JwtService can create access tokens (1h expiry) and refresh tokens (7d expiry) with agentId and group claims"
- "JwtService can validate tokens and extract agentId, distinguishing access vs refresh type"
- "BootstrapTokenValidator accepts CAMELEER_AUTH_TOKEN and optionally CAMELEER_AUTH_TOKEN_PREVIOUS using constant-time comparison"
- "Server fails fast on startup if CAMELEER_AUTH_TOKEN is not set"
artifacts:
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java"
provides: "JWT service interface with createAccessToken, createRefreshToken, validateAndExtractAgentId"
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java"
provides: "Ed25519 signing interface with sign(payload) and getPublicKeyBase64()"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java"
provides: "Nimbus JOSE+JWT HMAC-SHA256 implementation"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java"
provides: "JDK 17 Ed25519 KeyPairGenerator implementation"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java"
provides: "Constant-time bootstrap token validation with dual-token rotation"
key_links:
- from: "JwtServiceImpl"
to: "Nimbus JOSE+JWT MACSigner/MACVerifier"
via: "HMAC-SHA256 signing with ephemeral 256-bit secret"
pattern: "MACSigner|MACVerifier|SignedJWT"
- from: "Ed25519SigningServiceImpl"
to: "JDK KeyPairGenerator/Signature"
via: "Ed25519 algorithm from java.security"
pattern: "KeyPairGenerator\\.getInstance.*Ed25519"
- from: "BootstrapTokenValidator"
to: "SecurityProperties"
via: "reads token values from config properties"
pattern: "MessageDigest\\.isEqual"
---
<objective>
Create the security service foundation: interfaces in core module, implementations in app module, Maven dependencies, and configuration properties. This provides all cryptographic building blocks (JWT creation/validation, Ed25519 signing, bootstrap token validation) that the filter chain and endpoint integration plans depend on.
Purpose: Establishes the security primitives before they are wired into Spring Security and controllers.
Output: Working JwtService, Ed25519SigningService, BootstrapTokenValidator with passing unit tests.
</objective>
<execution_context>
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-security/04-CONTEXT.md
@.planning/phases/04-security/04-RESEARCH.md
@.planning/phases/04-security/04-VALIDATION.md
@cameleer3-server-app/pom.xml
@cameleer3-server-app/src/main/resources/application.yml
@cameleer3-server-app/src/test/resources/application-test.yml
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java
<interfaces>
<!-- Existing patterns to follow: core module = interfaces/domain, app module = Spring implementations -->
From core/agent/AgentRegistryService.java:
```java
// Plain class in core module, wired as bean by app module config
public class AgentRegistryService {
public AgentInfo register(String id, String name, String group, ...);
public AgentInfo findById(String id);
}
```
From app/config/AgentRegistryConfig.java:
```java
@ConfigurationProperties(prefix = "agent-registry")
public class AgentRegistryConfig { ... }
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Core interfaces + app implementations + Maven deps</name>
<files>
cameleer3-server-app/pom.xml,
cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java,
cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java,
cameleer3-server-app/src/main/resources/application.yml,
cameleer3-server-app/src/test/resources/application-test.yml,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
</files>
<behavior>
JwtService tests:
- createAccessToken(agentId, group) returns a signed JWT string with sub=agentId, claim "group"=group, claim "type"="access", expiry ~1h from now
- createRefreshToken(agentId, group) returns a signed JWT string with sub=agentId, claim "type"="refresh", expiry ~7d from now
- validateAndExtractAgentId(validAccessToken) returns the agentId
- validateAndExtractAgentId(expiredToken) throws exception
- validateAndExtractAgentId(refreshToken) throws exception (wrong type for access validation)
- validateRefreshToken(validRefreshToken) returns the agentId
- validateRefreshToken(accessToken) throws exception (wrong type)
Ed25519SigningService tests:
- getPublicKeyBase64() returns non-null Base64 string
- sign(payload) returns Base64 signature string
- Signature verifies against public key using JDK Signature.getInstance("Ed25519")
- Different payloads produce different signatures
- Tampered payload fails verification
BootstrapTokenValidator tests:
- validate(correctToken) returns true
- validate(wrongToken) returns false
- validate(previousToken) returns true when CAMELEER_AUTH_TOKEN_PREVIOUS is set
- validate(null) returns false
- Uses constant-time comparison (MessageDigest.isEqual)
</behavior>
<action>
1. Add Maven dependencies to cameleer3-server-app/pom.xml:
- `spring-boot-starter-security` (managed version)
- `com.nimbusds:nimbus-jose-jwt:9.47` (explicit, may not be transitive without OAuth2 resource server)
- `spring-security-test` scope test (managed version)
2. Create core module interfaces:
- `JwtService` interface: `createAccessToken(String agentId, String group)`, `createRefreshToken(String agentId, String group)`, `validateAndExtractAgentId(String token)` (access only), `validateRefreshToken(String token)` (refresh only). Returns String tokens, throws `InvalidTokenException` (new checked or runtime exception in core).
- `Ed25519SigningService` interface: `sign(String payload)` returns Base64 signature string, `getPublicKeyBase64()` returns Base64-encoded X.509 SubjectPublicKeyInfo DER public key.
3. Create app module implementations:
- `SecurityProperties` as `@ConfigurationProperties(prefix = "security")` with fields: `accessTokenExpiryMs` (default 3600000), `refreshTokenExpiryMs` (default 604800000), `bootstrapToken` (from env CAMELEER_AUTH_TOKEN), `bootstrapTokenPrevious` (from env CAMELEER_AUTH_TOKEN_PREVIOUS, nullable).
- `JwtServiceImpl`: Generate random 256-bit HMAC secret in constructor (`new SecureRandom().nextBytes(secret)`). Use Nimbus `MACSigner`/`MACVerifier` with `JWSAlgorithm.HS256`. Claims: `sub`=agentId, `group`=group, `type`="access"|"refresh", `iat`=now, `exp`=now+expiry. Validation checks: signature valid, not expired, correct `type` claim.
- `Ed25519SigningServiceImpl`: Generate `KeyPair` via `KeyPairGenerator.getInstance("Ed25519")` in constructor. `sign()` uses `Signature.getInstance("Ed25519")`, `initSign(privateKey)`, returns Base64-encoded signature bytes. `getPublicKeyBase64()` returns `Base64.getEncoder().encodeToString(publicKey.getEncoded())`.
- `BootstrapTokenValidator`: Constructor takes `SecurityProperties`. `validate(String provided)` returns boolean. Uses `MessageDigest.isEqual(provided.getBytes(UTF_8), expected.getBytes(UTF_8))`. If first token fails and previousToken is non-null, tries previousToken. Returns false for null/blank input.
- `SecurityBeanConfig` as `@Configuration` with `@EnableConfigurationProperties(SecurityProperties.class)`. Creates beans for `JwtServiceImpl`, `Ed25519SigningServiceImpl`, `BootstrapTokenValidator`. Add `@PostConstruct` or `InitializingBean` validation: if `SecurityProperties.bootstrapToken` is null or blank, throw `IllegalStateException("CAMELEER_AUTH_TOKEN environment variable must be set")`.
4. Update application.yml: Add `security.access-token-expiry-ms: 3600000`, `security.refresh-token-expiry-ms: 604800000`. Map env vars: `security.bootstrap-token: ${CAMELEER_AUTH_TOKEN:}`, `security.bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:}`.
5. Update application-test.yml: Add `security.bootstrap-token: test-bootstrap-token`, `security.bootstrap-token-previous: old-bootstrap-token`. Also set `CAMELEER_AUTH_TOKEN: test-bootstrap-token` as an env override if needed.
6. IMPORTANT: Adding spring-boot-starter-security will break ALL existing tests immediately (401 on all endpoints). To prevent this during Plan 01 (before the security filter chain is configured in Plan 02), add a temporary test security config class `src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java` annotated `@TestConfiguration` that creates a `SecurityFilterChain` permitting all requests. This keeps existing tests green while security services are built. Plan 02 will replace this with real security config and update tests.
7. Write unit tests per the behavior spec above. Tests should NOT require Spring context -- construct implementations directly with test SecurityProperties.
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest="JwtServiceTest,Ed25519SigningServiceTest,BootstrapTokenValidatorTest" -Dsurefire.reuseForks=false</automated>
</verify>
<done>
- JwtService creates and validates access/refresh JWTs with correct claims and expiry
- Ed25519SigningService generates keypair, signs payloads, signatures verify with public key
- BootstrapTokenValidator uses constant-time comparison, supports dual-token rotation
- Server startup fails if CAMELEER_AUTH_TOKEN is not set (tested via SecurityBeanConfig @PostConstruct)
- All existing tests still pass (TestSecurityConfig permits all requests temporarily)
- Maven compiles with new dependencies
</done>
</task>
</tasks>
<verification>
mvn clean verify
All new unit tests pass. All existing integration tests still pass (no 401 regressions).
</verification>
<success_criteria>
- JwtServiceImpl creates signed JWTs with correct HMAC-SHA256, validates them, and rejects expired/wrong-type tokens
- Ed25519SigningServiceImpl generates ephemeral keypair, signs payloads with verifiable signatures
- BootstrapTokenValidator performs constant-time comparison with dual-token support
- SecurityProperties loaded from application.yml with env var mapping
- Startup fails fast when CAMELEER_AUTH_TOKEN is missing
- Existing test suite remains green via TestSecurityConfig permit-all
</success_criteria>
<output>
After completion, create `.planning/phases/04-security/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,293 @@
---
phase: 04-security
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
autonomous: true
requirements:
- SECU-01
- SECU-02
- SECU-05
must_haves:
truths:
- "All API endpoints except health, register, and docs reject requests without valid JWT"
- "POST /register requires bootstrap token in Authorization header, returns JWT + refresh token + Ed25519 public key"
- "POST /agents/{id}/refresh accepts refresh token and returns new access JWT"
- "SSE endpoint accepts JWT via ?token= query parameter"
- "Health endpoint and Swagger UI remain publicly accessible"
artifacts:
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java"
provides: "OncePerRequestFilter extracting JWT from header or query param"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java"
provides: "SecurityFilterChain with permitAll for public paths, authenticated for rest"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java"
provides: "Updated register endpoint with bootstrap token validation, JWT issuance, public key"
key_links:
- from: "JwtAuthenticationFilter"
to: "JwtService.validateAndExtractAgentId"
via: "Filter delegates JWT validation to service"
pattern: "jwtService\\.validateAndExtractAgentId"
- from: "SecurityConfig"
to: "JwtAuthenticationFilter"
via: "addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)"
pattern: "addFilterBefore"
- from: "AgentRegistrationController.register"
to: "BootstrapTokenValidator.validate"
via: "Validates bootstrap token before processing registration"
pattern: "bootstrapTokenValidator\\.validate"
- from: "AgentRegistrationController.register"
to: "JwtService.createAccessToken + createRefreshToken"
via: "Issues tokens in registration response"
pattern: "jwtService\\.create(Access|Refresh)Token"
---
<objective>
Wire Spring Security into the application: JWT authentication filter, SecurityFilterChain configuration, bootstrap token validation on registration, JWT issuance in registration response, refresh endpoint, and SSE query-parameter authentication. Update existing tests to work with security enabled.
Purpose: Protects all endpoints with JWT authentication while keeping public endpoints accessible and providing the full agent registration-to-authentication flow.
Output: Working security filter chain with protected/public endpoints, registration returns JWT + public key, refresh flow works, all tests pass.
</objective>
<execution_context>
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-security/04-CONTEXT.md
@.planning/phases/04-security/04-RESEARCH.md
@.planning/phases/04-security/04-VALIDATION.md
@.planning/phases/04-security/04-01-SUMMARY.md
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
<interfaces>
<!-- From Plan 01 (will exist after execution): -->
From core/security/JwtService.java:
```java
public interface JwtService {
String createAccessToken(String agentId, String group);
String createRefreshToken(String agentId, String group);
String validateAndExtractAgentId(String token); // access tokens only
String validateRefreshToken(String token); // refresh tokens only
}
```
From core/security/Ed25519SigningService.java:
```java
public interface Ed25519SigningService {
String sign(String payload);
String getPublicKeyBase64();
}
```
From app/security/BootstrapTokenValidator.java:
```java
public class BootstrapTokenValidator {
public boolean validate(String provided);
}
```
From app/security/SecurityProperties.java:
```java
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
long accessTokenExpiryMs; // default 3600000
long refreshTokenExpiryMs; // default 604800000
String bootstrapToken; // from CAMELEER_AUTH_TOKEN env
String bootstrapTokenPrevious; // from CAMELEER_AUTH_TOKEN_PREVIOUS env, nullable
}
```
From core/agent/AgentRegistryService.java:
```java
public class AgentRegistryService {
public AgentInfo register(String id, String name, String group, String version, List<String> routeIds, Map<String, Object> capabilities);
public AgentInfo findById(String id);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration</name>
<files>
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
</files>
<action>
1. Create `JwtAuthenticationFilter extends OncePerRequestFilter` (NOT annotated @Component -- constructed in SecurityConfig to avoid double registration):
- Constructor takes `JwtService` and `AgentRegistryService`
- `doFilterInternal`: extract token via `extractToken(request)`, if token present: call `jwtService.validateAndExtractAgentId(token)`, verify agent exists via `agentRegistry.findById(agentId)`, if valid set `UsernamePasswordAuthenticationToken(agentId, null, List.of())` in `SecurityContextHolder`. If any exception, log debug and do NOT set auth (Spring Security rejects). Always call `chain.doFilter(request, response)`.
- `extractToken(request)`: first check `Authorization` header for `Bearer ` prefix, then check `request.getParameter("token")` for SSE query param. Return null if neither.
2. Create `SecurityConfig` as `@Configuration @EnableWebSecurity`:
- Single `@Bean SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService)`:
- `csrf(AbstractHttpConfigurer::disable)` -- REST API, no browser forms
- `sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))`
- `authorizeHttpRequests`: permitAll for `/api/v1/health`, `/api/v1/agents/register`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/swagger-ui.html`. `anyRequest().authenticated()`.
- `addFilterBefore(new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class)`
- Also disable default form login and httpBasic: `.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)`
3. Update `AgentRegistrationController.register()`:
- Add `BootstrapTokenValidator`, `JwtService`, `Ed25519SigningService` as constructor dependencies
- Before processing registration body, extract bootstrap token from `Authorization: Bearer <token>` header (use `@RequestHeader("Authorization")` or extract from HttpServletRequest). If missing or invalid (`bootstrapTokenValidator.validate()` returns false), return `401 Unauthorized` with no detail body.
- After successful registration, generate tokens: `jwtService.createAccessToken(agentId, group)` and `jwtService.createRefreshToken(agentId, group)`
- Update response map: replace `"serverPublicKey", null` with `"serverPublicKey", ed25519SigningService.getPublicKeyBase64()`. Add `"accessToken"` and `"refreshToken"` fields.
4. Add a new refresh endpoint in `AgentRegistrationController` (or a new controller -- keep it in the same controller since it's agent auth flow):
- `POST /api/v1/agents/{id}/refresh` with request body containing `{"refreshToken": "..."}`.
- Validate refresh token via `jwtService.validateRefreshToken(token)`, extract agentId, verify it matches path `{id}`, verify agent exists.
- Return new access token: `{"accessToken": "..."}`.
- Return 401 for invalid/expired refresh token, 404 for unknown agent.
- NOTE: This endpoint must be AUTHENTICATED (requires valid JWT OR the refresh token itself). Per the user decision, the refresh endpoint uses the refresh token for auth, so add `/api/v1/agents/*/refresh` to permitAll in SecurityConfig, and validate the refresh token in the controller itself.
5. Update `AgentSseController.events()`:
- The SSE endpoint uses `?token=<jwt>` query parameter. The `JwtAuthenticationFilter` already handles this (extracts from query param). No changes needed to the controller itself -- Spring Security handles auth via the filter.
- However, verify the SSE endpoint path `/api/v1/agents/{id}/events` is NOT in permitAll (it should require JWT auth).
6. Update `WebConfig` if needed: The `ProtocolVersionInterceptor` excluded paths should align with Spring Security public paths. The SSE events path is already excluded from protocol version check (Phase 3 decision). Verify no conflicts.
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean compile -pl cameleer3-server-app</automated>
</verify>
<done>
- SecurityConfig creates stateless filter chain with correct public/protected path split
- JwtAuthenticationFilter extracts JWT from header or query param, validates, sets SecurityContext
- Registration endpoint requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
- Refresh endpoint issues new access token from valid refresh token
- Application compiles with all security wiring
</done>
</task>
<task type="auto">
<name>Task 2: Security integration tests + existing test adaptation</name>
<files>
cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenApiIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/HealthControllerIT.java
</files>
<action>
1. Replace the Plan 01 temporary `TestSecurityConfig` (permit-all) with real security active in tests. Remove the permit-all override so tests run with actual security enforcement.
2. Create `TestSecurityHelper` utility class in test root:
- Autowire `JwtService` and `AgentRegistryService`
- `registerTestAgent(String agentId)`: calls `registryService.register(agentId, "test", "test-group", "1.0", List.of(), Map.of())` and returns `jwtService.createAccessToken(agentId, "test-group")`
- `authHeaders(String jwt)`: returns HttpHeaders with `Authorization: Bearer <jwt>` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
- `bootstrapHeaders()`: returns HttpHeaders with `Authorization: Bearer test-bootstrap-token` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
- Make it a Spring `@Component` so it can be autowired in test classes
3. Update ALL existing IT classes (17 files) to use JWT authentication:
- Autowire `TestSecurityHelper`
- In `@BeforeEach` or at test start, call `helper.registerTestAgent("test-agent-<testclass>")` to get a JWT
- Replace all `protocolHeaders()` calls with headers that include the JWT Bearer token
- For HealthControllerIT and OpenApiIT: verify these still work WITHOUT JWT (they're public endpoints)
- For AgentRegistrationControllerIT: update `registerAgent()` helper to use bootstrap token header, verify response now includes `accessToken`, `refreshToken`, `serverPublicKey` (non-null)
4. Create new security-specific integration tests:
`SecurityFilterIT` (extends AbstractClickHouseIT):
- Test: GET /api/v1/agents without JWT returns 401 or 403
- Test: GET /api/v1/agents with valid JWT returns 200
- Test: GET /api/v1/health without JWT returns 200 (public)
- Test: POST /api/v1/data/executions without JWT returns 401 or 403
- Test: Request with expired JWT returns 401 or 403
- Test: Request with malformed JWT returns 401 or 403
`BootstrapTokenIT` (extends AbstractClickHouseIT):
- Test: POST /register without bootstrap token returns 401
- Test: POST /register with wrong bootstrap token returns 401
- Test: POST /register with correct bootstrap token returns 200 with tokens
- Test: POST /register with previous bootstrap token returns 200 (dual-token rotation)
`RegistrationSecurityIT` (extends AbstractClickHouseIT):
- Test: Registration response contains non-null `serverPublicKey` (Base64 string)
- Test: Registration response contains `accessToken` and `refreshToken`
- Test: Access token from registration can be used to access protected endpoints
`JwtRefreshIT` (extends AbstractClickHouseIT):
- Test: POST /agents/{id}/refresh with valid refresh token returns new access token
- Test: POST /agents/{id}/refresh with expired refresh token returns 401
- Test: POST /agents/{id}/refresh with access token (wrong type) returns 401
- Test: POST /agents/{id}/refresh with mismatched agent ID returns 401
- Test: New access token from refresh can access protected endpoints
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify</automated>
</verify>
<done>
- All 17 existing ITs pass with JWT authentication
- SecurityFilterIT: protected endpoints reject unauthenticated requests, public endpoints remain open
- BootstrapTokenIT: registration requires valid bootstrap token, supports dual-token rotation
- RegistrationSecurityIT: registration returns public key + tokens
- JwtRefreshIT: refresh flow issues new access tokens, rejects invalid refresh tokens
- Full `mvn clean verify` is green
</done>
</task>
</tasks>
<verification>
mvn clean verify
All existing tests pass with JWT auth. New security ITs validate protected/public endpoint split, bootstrap token flow, registration security, and refresh flow.
</verification>
<success_criteria>
- Protected endpoints return 401/403 without JWT, 200 with valid JWT
- Public endpoints (health, register, docs) remain accessible without JWT
- Registration requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
- Refresh endpoint issues new access JWT from valid refresh token
- SSE endpoint accepts JWT via query parameter
- All 17 existing ITs adapted and passing
- 4 new security ITs passing
</success_criteria>
<output>
After completion, create `.planning/phases/04-security/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,186 @@
---
phase: 04-security
plan: 03
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java
autonomous: true
requirements:
- SECU-04
must_haves:
truths:
- "All config-update, deep-trace, and replay SSE events carry a valid Ed25519 signature in the data JSON"
- "Signature is computed over the payload JSON without the signature field, then added as a 'signature' field"
- "Agent can verify the signature using the public key received at registration"
artifacts:
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java"
provides: "Component that signs SSE command payloads before delivery"
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java"
provides: "Updated onCommandReady with signing before sendEvent"
key_links:
- from: "SseConnectionManager.onCommandReady"
to: "SsePayloadSigner.signPayload"
via: "Signs payload before SSE delivery"
pattern: "ssePayloadSigner\\.signPayload"
- from: "SsePayloadSigner"
to: "Ed25519SigningService.sign"
via: "Delegates signing to Ed25519 service"
pattern: "ed25519SigningService\\.sign"
---
<objective>
Add Ed25519 signature to all SSE command payloads (config-update, deep-trace, replay) before delivery. The signature is computed over the data JSON and included as a `signature` field in the event data, enabling agents to verify payload integrity using the server's public key.
Purpose: Ensures all pushed configuration and commands are integrity-protected, so agents can trust the payloads they receive.
Output: All SSE command events carry verifiable Ed25519 signatures.
</objective>
<execution_context>
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-security/04-CONTEXT.md
@.planning/phases/04-security/04-RESEARCH.md
@.planning/phases/04-security/04-01-SUMMARY.md
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
<interfaces>
<!-- From Plan 01 (will exist after execution): -->
From core/security/Ed25519SigningService.java:
```java
public interface Ed25519SigningService {
String sign(String payload); // Returns Base64-encoded signature
String getPublicKeyBase64(); // Returns Base64-encoded X.509 public key
}
```
From app/agent/SseConnectionManager.java:
```java
@Component
public class SseConnectionManager implements AgentEventListener {
// Key method to modify:
public void onCommandReady(String agentId, AgentCommand command) {
String eventType = command.type().name().toLowerCase().replace('_', '-');
boolean sent = sendEvent(agentId, command.id(), eventType, command.payload());
// command.payload() is a String (JSON)
}
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
// data is sent via SseEmitter.event().data(data, MediaType.APPLICATION_JSON)
}
}
```
From core/agent/AgentCommand.java:
```java
public record AgentCommand(String id, CommandType type, String payload, String agentId, Instant createdAt, CommandStatus status) {
// payload is a JSON string
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: SsePayloadSigner + signing integration in SseConnectionManager</name>
<files>
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java,
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java,
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java
</files>
<behavior>
SsePayloadSigner unit tests:
- signPayload(jsonString) returns a new JSON string containing all original fields plus a "signature" field
- The "signature" field is a Base64-encoded Ed25519 signature
- The signature is computed over the ORIGINAL JSON string (without signature field)
- Signature verifies against the public key from Ed25519SigningService
- null or empty payload returns the payload unchanged (defensive)
SseSigningIT integration test:
- Register an agent (with bootstrap token), get public key from response
- Open SSE connection (with JWT query param)
- Send a config-update command to the agent
- Receive the SSE event and verify it contains a "signature" field
- Verify the signature against the public key using JDK Ed25519 Signature.getInstance("Ed25519")
</behavior>
<action>
1. Create `SsePayloadSigner` as a `@Component`:
- Constructor takes `Ed25519SigningService` and `ObjectMapper`
- `signPayload(String jsonPayload)` method:
a. The payload JSON string IS the data to sign (sign the exact string)
b. Compute signature: `ed25519SigningService.sign(jsonPayload)` returns Base64 signature
c. Parse the JSON payload, add `"signature": signatureBase64` field, serialize back
d. Return the signed JSON string
- Handle edge cases: if payload is null or empty, return as-is with a log warning
2. Update `SseConnectionManager`:
- Add `SsePayloadSigner` as a constructor dependency
- In `onCommandReady()`, sign the payload before sending:
```java
String signedPayload = ssePayloadSigner.signPayload(command.payload());
boolean sent = sendEvent(agentId, command.id(), eventType, signedPayload);
```
- The `sendEvent` method already sends `data` as `MediaType.APPLICATION_JSON`. Since `signedPayload` is already a JSON string, the SseEmitter will serialize it. IMPORTANT: Since the payload is already a JSON string and SseEmitter will try to JSON-serialize it (wrapping in quotes), we need to send it as a pre-serialized value. Change `sendEvent` to use `.data(signedPayload)` without MediaType for signed payloads, OR parse it to a JsonNode/Map first so Jackson serializes it correctly. The cleanest approach: parse the signed JSON string into a `JsonNode` via `objectMapper.readTree(signedPayload)` and pass that as the data object -- Jackson will serialize the tree correctly.
3. Write `SsePayloadSignerTest` (unit test, no Spring context):
- Create a real `Ed25519SigningServiceImpl` and `ObjectMapper` for testing
- Test cases per behavior spec above
- Verify signature by using JDK `Signature.getInstance("Ed25519")` with the public key
4. Write `SseSigningIT` (extends AbstractClickHouseIT):
- Register agent using bootstrap token (from application-test.yml)
- Extract `serverPublicKey` from registration response
- Get JWT from registration response
- Open SSE connection via `java.net.http.HttpClient` async API (same pattern as AgentSseControllerIT) with `?token=<jwt>`
- Use the agent command endpoint to push a config-update command to the agent
- Read the SSE event from the stream
- Parse the event data JSON, extract the `signature` field
- Reconstruct the unsigned payload (remove signature field, serialize)
- Verify signature using `Signature.getInstance("Ed25519")` with the public key decoded from Base64
- NOTE: This test depends on Plan 02's bootstrap token and JWT auth being in place. If Plan 03 executes before Plan 02, the test will need the TestSecurityHelper or a different auth approach. Since both are Wave 2 but independent, document this: "If Plan 02 is not yet complete, use TestSecurityHelper from Plan 01's temporary permit-all config."
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest="SsePayloadSignerTest,SseSigningIT" -Dsurefire.reuseForks=false</automated>
</verify>
<done>
- SsePayloadSigner signs JSON payloads with Ed25519 and adds signature field
- SseConnectionManager signs all command payloads before SSE delivery
- Unit tests verify signature roundtrip (sign + verify with public key)
- Integration test verifies end-to-end: command sent -> SSE event received with valid signature
- Existing SSE tests still pass (ping events are not signed, only command events)
</done>
</task>
</tasks>
<verification>
mvn clean verify
SsePayloadSigner unit tests pass. SseSigningIT integration test verifies end-to-end Ed25519 signing of SSE command events.
</verification>
<success_criteria>
- All SSE command events (config-update, deep-trace, replay) include a "signature" field
- Signature verifies against the server's Ed25519 public key
- Signature is computed over the payload JSON without the signature field
- Ping keepalive events are NOT signed (they are SSE comments, not data events)
- Existing SSE functionality unchanged (connection, ping, delivery tracking)
</success_criteria>
<output>
After completion, create `.planning/phases/04-security/04-03-SUMMARY.md`
</output>