|
|
|
|
@@ -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.
|
|
|
|
|
* <p>
|
|
|
|
|
* 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<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 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 {
|
|
|
|
|
|