From a9ec424d52028f3f0379d6e2fbde36ff00f9a623 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:18:43 +0200 Subject: [PATCH] fix: derive Ed25519 signing key from JWT secret, no DB storage Replace DB-persisted keypair with deterministic derivation from CAMELEER_JWT_SECRET via HMAC-SHA256 seed + seeded SHA1PRNG KeyPairGenerator. Same secret = same key pair across restarts, no private key in the database. Closes #121 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../security/Ed25519SigningServiceImpl.java | 95 ++++++------------- .../app/security/SecurityBeanConfig.java | 5 +- 2 files changed, 31 insertions(+), 69 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java index b90ad463..d1b9240d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java @@ -3,44 +3,48 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.security.Ed25519SigningService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.JdbcTemplate; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; -import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.SecureRandom; import java.security.Signature; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; +import java.security.spec.NamedParameterSpec; import java.util.Base64; -import java.util.List; -import java.util.Map; /** * JDK 17 Ed25519 signing implementation. *

- * Persists the keypair to PostgreSQL {@code server_config} table so it survives - * server restarts. Agents cache the public key at registration — if the key - * changes, all agents reject commands until they re-register. + * Derives the keypair deterministically from the JWT secret via HMAC-SHA256, + * so the same key pair is produced on every server startup. No database storage + * of private key material is needed. */ public class Ed25519SigningServiceImpl implements Ed25519SigningService { private static final Logger log = LoggerFactory.getLogger(Ed25519SigningServiceImpl.class); - private static final String CONFIG_KEY = "ed25519_signing_key"; + private static final String DERIVATION_INFO = "cameleer3-ed25519-signing"; private final PrivateKey privateKey; private final PublicKey publicKey; - public Ed25519SigningServiceImpl(JdbcTemplate jdbcTemplate) { - KeyPair keyPair = loadOrGenerate(jdbcTemplate); - this.privateKey = keyPair.getPrivate(); - this.publicKey = keyPair.getPublic(); + public Ed25519SigningServiceImpl(String jwtSecret) { + try { + byte[] seed = deriveSeed(jwtSecret); + KeyPair keyPair = generateFromSeed(seed); + this.privateKey = keyPair.getPrivate(); + this.publicKey = keyPair.getPublic(); + log.info("Ed25519 signing key derived from JWT secret"); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to derive Ed25519 keypair", e); + } } - /** Ephemeral key pair — for tests only, not persisted. */ + /** Ephemeral random key pair — for tests only. */ public static Ed25519SigningServiceImpl ephemeral() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); @@ -56,59 +60,18 @@ public class Ed25519SigningServiceImpl implements Ed25519SigningService { this.publicKey = keyPair.getPublic(); } - private static KeyPair loadOrGenerate(JdbcTemplate jdbc) { - try { - List> rows = jdbc.queryForList( - "SELECT config_val FROM server_config WHERE config_key = ?", CONFIG_KEY); - if (!rows.isEmpty()) { - String json = rows.get(0).get("config_val").toString(); - // Parse {"privateKey":"...","publicKey":"..."} - KeyPair restored = deserializeKeyPair(json); - log.info("Ed25519 signing key loaded from database"); - return restored; - } - } catch (Exception e) { - log.warn("Could not load Ed25519 key from database, generating new one: {}", e.getMessage()); - } - - // Generate new key pair and persist - try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); - KeyPair keyPair = generator.generateKeyPair(); - persist(jdbc, keyPair); - log.info("Ed25519 signing key generated and persisted to database"); - return keyPair; - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to generate Ed25519 keypair", e); - } + private static byte[] deriveSeed(String secret) throws GeneralSecurityException { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(DERIVATION_INFO.getBytes(StandardCharsets.UTF_8)); } - private static void persist(JdbcTemplate jdbc, KeyPair keyPair) { - String privB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); - String pubB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); - String json = String.format("{\"privateKey\":\"%s\",\"publicKey\":\"%s\"}", privB64, pubB64); - jdbc.update(""" - INSERT INTO server_config (config_key, config_val, updated_by) - VALUES (?, ?::jsonb, 'system') - ON CONFLICT (config_key) DO UPDATE SET config_val = EXCLUDED.config_val, updated_at = now() - """, CONFIG_KEY, json); - } - - private static KeyPair deserializeKeyPair(String json) throws GeneralSecurityException { - // Minimal JSON parsing — format is {"privateKey":"...","publicKey":"..."} - String privB64 = extractJsonValue(json, "privateKey"); - String pubB64 = extractJsonValue(json, "publicKey"); - KeyFactory kf = KeyFactory.getInstance("Ed25519"); - PrivateKey priv = kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privB64))); - PublicKey pub = kf.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubB64))); - return new KeyPair(pub, priv); - } - - private static String extractJsonValue(String json, String key) { - String search = "\"" + key + "\":\""; - int start = json.indexOf(search) + search.length(); - int end = json.indexOf("\"", start); - return json.substring(start, end); + private static KeyPair generateFromSeed(byte[] seed) throws GeneralSecurityException { + SecureRandom sr = SecureRandom.getInstance("SHA1PRNG"); + sr.setSeed(seed); + KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); + kpg.initialize(new NamedParameterSpec("Ed25519"), sr); + return kpg.generateKeyPair(); } @Override diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java index 657ff2fa..ef3adb54 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java @@ -4,7 +4,6 @@ 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; -import org.springframework.jdbc.core.JdbcTemplate; /** * Configuration class that creates security service beans and validates @@ -22,8 +21,8 @@ public class SecurityBeanConfig { } @Bean - public Ed25519SigningServiceImpl ed25519SigningService(JdbcTemplate jdbcTemplate) { - return new Ed25519SigningServiceImpl(jdbcTemplate); + public Ed25519SigningServiceImpl ed25519SigningService(SecurityProperties properties) { + return new Ed25519SigningServiceImpl(properties.getJwtSecret()); } @Bean