fix: derive Ed25519 signing key from JWT secret, no DB storage
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m11s
CI / docker (push) Successful in 42s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-03 17:18:43 +02:00
parent 81f13396a0
commit a9ec424d52
2 changed files with 31 additions and 69 deletions

View File

@@ -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.
* <p>
* 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<Map<String, Object>> 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

View File

@@ -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