diff --git a/readme.md b/readme.md index b271861..39195fd 100644 --- a/readme.md +++ b/readme.md @@ -97,9 +97,9 @@ Currently active rules cover: cycles, controller→repository direction, reposit ### Security -- **Drop the shared `JWT_SECRET`.** Today both Auth and Core hold the same HMAC secret, which means a Core compromise lets an attacker forge tokens for Auth. Once Auth ships EdDSA signing (see Auth README roadmap), Core should hold only the **public key** (`JWT_PUBLIC_KEY`) and verify with `Jwts.parser().verifyWith(publicKey).build()`. Touches `JwtConfig` (rename `secret` → `publicKey`), `JwtService` (replace `Keys.hmacShaKeyFor` with `KeyFactory.getInstance("Ed25519")`), and the deployment env wiring. -- **Consume a JWKS endpoint** once Auth exposes one (`GET https://auth.tbp.andreas-dahm.eu/.well-known/jwks.json`). Cache keys at startup, refresh on `kid` miss. Avoids redeploying Core when keys rotate. -- **Add a `JwtServiceTest`.** No verification tests exist for Core's JWT parser today; adding one is also a natural place to add the EdDSA key fixture. +- ~~**Drop the shared `JWT_SECRET`.**~~ Done. Core now holds only `JWT_PUBLIC_KEY` and verifies with the Ed25519 public key via `Jwts.parser().verifyWith(publicKey).build()`. `JwtConfig.secret` removed. +- ~~**Add a `JwtServiceTest`.**~~ Done. `src/test/java/core_service/configuration/security/JwtServiceTest.java` covers extract / valid / wrong-key paths with an Ed25519 fixture. +- **Consume a JWKS endpoint** once Auth exposes one (`GET https://auth.tbp.andreas-dahm.eu/.well-known/jwks.json`). Cache keys at startup, refresh on `kid` miss. Avoids redeploying Core when keys rotate. With the `kid` header now present in issued tokens (`JWT_KID`), this is unblocked. ### Testing diff --git a/src/main/java/core_service/configuration/security/JwtConfig.java b/src/main/java/core_service/configuration/security/JwtConfig.java index 62ccd8c..3694d76 100644 --- a/src/main/java/core_service/configuration/security/JwtConfig.java +++ b/src/main/java/core_service/configuration/security/JwtConfig.java @@ -11,6 +11,6 @@ import org.springframework.context.annotation.Configuration; @ConfigurationProperties(prefix = "security.jwt") public class JwtConfig { - private String secret; + /** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */ + private String publicKey; } - diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index 8bbc969..963ce47 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -1,24 +1,37 @@ package core_service.configuration.security; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Service; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +/** + * Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key. + */ @Service public class JwtService { + private static final String ED25519 = "Ed25519"; + private final JwtConfig jwtConfig; + private PublicKey publicKey; public JwtService(JwtConfig jwtConfig) { this.jwtConfig = jwtConfig; } + @PostConstruct + void init() { + this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); + } + public String extractEmail(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -27,7 +40,7 @@ public class JwtService { public String extractUserId(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -36,7 +49,7 @@ public class JwtService { public Long extractSteamId(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -46,7 +59,7 @@ public class JwtService { public boolean isTokenValid(String token) { try { Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token); return true; @@ -55,10 +68,12 @@ public class JwtService { } } - private SecretKey getSigningKey() { - String secretKey = jwtConfig.getSecret(); - byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); + private static PublicKey decodePublicKey(String base64X509Der) { + try { + byte[] keyBytes = Base64.getDecoder().decode(base64X509Der); + return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to decode Ed25519 public key", e); + } } } - diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 73d90bd..ffe2660 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -16,4 +16,5 @@ spring: security: jwt: - secret: "NzC9tQP1eTp496SxoVYslH1bqn81Tbfz74NjkFqdcVe7wayjcRN8IeZvJWd1s6NEWgwEXe9rdccvOn2HtJ4fdq" \ No newline at end of file + # Dev-only Ed25519 public key. Must match Auth's dev private key. + public-key: "MCowBQYDK2VwAyEAPSmUISg0IUO1LrRPhF8RZAT/Yjh9HEVf5hzR84AL1Zw=" \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dd33a2d..68e4414 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,7 +36,7 @@ management: enabled: true security: jwt: - secret: ${JWT_SECRET} + public-key: ${JWT_PUBLIC_KEY} app: cors: allowed-origins: "https://tbp.andreas-dahm.eu" diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java new file mode 100644 index 0000000..b862360 --- /dev/null +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -0,0 +1,102 @@ +package core_service.configuration.security; + +import core_service.shared.test.AbstractUnitTest; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.util.Base64; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; + +/** + * Tests Core's verify-only JwtService against tokens signed with a matching Ed25519 private key. + */ +class JwtServiceTest extends AbstractUnitTest { + + @Mock + private JwtConfig jwtConfig; + + private JwtService jwtService; + private PrivateKey signingKey; + + @BeforeEach + void setUp() throws Exception { + KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + signingKey = keyPair.getPrivate(); + String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + + lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64); + + jwtService = new JwtService(jwtConfig); + jwtService.init(); + } + + private String signToken(String userId, String email, Long steamId) { + var builder = Jwts.builder() + .subject(email) + .claim("userId", userId) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 60_000)); + if (steamId != null) { + builder.claim("steamId", steamId); + } + return builder.signWith(signingKey, Jwts.SIG.EdDSA).compact(); + } + + @Test + void extractEmail_Success() { + var email = "test@example.com"; + var token = signToken("0815", email, null); + + assertEquals(email, jwtService.extractEmail(token)); + } + + @Test + void extractUserId_Success() { + var userId = "0815"; + var token = signToken(userId, "test@example.com", null); + + assertEquals(userId, jwtService.extractUserId(token)); + } + + @Test + void extractSteamId_Success() { + var steamId = 123456789L; + var token = signToken("0815", "test@example.com", steamId); + + assertEquals(steamId, jwtService.extractSteamId(token)); + } + + @Test + void isTokenValid_Success() { + var token = signToken("0815", "test@example.com", null); + + assertTrue(jwtService.isTokenValid(token)); + } + + @Test + void isTokenValid_Failure_garbage() { + assertFalse(jwtService.isTokenValid("not-a-jwt")); + } + + @Test + void isTokenValid_Failure_wrongKey() throws Exception { + KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String token = Jwts.builder() + .subject("test@example.com") + .expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(other.getPrivate(), Jwts.SIG.EdDSA) + .compact(); + + assertFalse(jwtService.isTokenValid(token)); + } +}