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 bb5e13a6..b90ad463 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 @@ -1,39 +1,116 @@ 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 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.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.List; +import java.util.Map; /** * JDK 17 Ed25519 signing implementation. *

- * Generates an ephemeral keypair at construction time. The public key is made - * available as Base64-encoded X.509 SubjectPublicKeyInfo DER bytes. Agents receive - * this key during registration and use it to verify signed SSE payloads. + * 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. */ public class Ed25519SigningServiceImpl implements Ed25519SigningService { + private static final Logger log = LoggerFactory.getLogger(Ed25519SigningServiceImpl.class); + private static final String CONFIG_KEY = "ed25519_signing_key"; + private final PrivateKey privateKey; private final PublicKey publicKey; - public Ed25519SigningServiceImpl() { + public Ed25519SigningServiceImpl(JdbcTemplate jdbcTemplate) { + KeyPair keyPair = loadOrGenerate(jdbcTemplate); + this.privateKey = keyPair.getPrivate(); + this.publicKey = keyPair.getPublic(); + } + + /** Ephemeral key pair — for tests only, not persisted. */ + public static Ed25519SigningServiceImpl ephemeral() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); KeyPair keyPair = generator.generateKeyPair(); - this.privateKey = keyPair.getPrivate(); - this.publicKey = keyPair.getPublic(); + return new Ed25519SigningServiceImpl(keyPair); } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to generate Ed25519 keypair", e); } } + private Ed25519SigningServiceImpl(KeyPair keyPair) { + this.privateKey = keyPair.getPrivate(); + 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 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); + } + @Override public String sign(String payload) { try { 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 d8e70958..657ff2fa 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,6 +4,7 @@ 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 @@ -21,8 +22,8 @@ public class SecurityBeanConfig { } @Bean - public Ed25519SigningServiceImpl ed25519SigningService() { - return new Ed25519SigningServiceImpl(); + public Ed25519SigningServiceImpl ed25519SigningService(JdbcTemplate jdbcTemplate) { + return new Ed25519SigningServiceImpl(jdbcTemplate); } @Bean diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java index 8bc00d1c..0cbee16a 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java @@ -29,7 +29,7 @@ class SsePayloadSignerTest { @BeforeEach void setUp() { - signingService = new Ed25519SigningServiceImpl(); + signingService = Ed25519SigningServiceImpl.ephemeral(); objectMapper = new ObjectMapper(); signer = new SsePayloadSigner(signingService, objectMapper); } diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java index 6d8875db..9a71c4fa 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java @@ -22,7 +22,7 @@ class Ed25519SigningServiceTest { @BeforeEach void setUp() { - signingService = new Ed25519SigningServiceImpl(); + signingService = Ed25519SigningServiceImpl.ephemeral(); } @Test