feat: add SigningKeyService for Ed25519 keypair management

Entity, repository, and service for generating and storing Ed25519
signing keys. Lazy-initializes on first call — generates keypair
and persists to signing_keys table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 17:21:16 +02:00
parent 13bd03997a
commit 2fd14165bc
3 changed files with 146 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
package net.siegeln.cameleer.saas.license;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "signing_keys")
public class SigningKeyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "public_key_b64", nullable = false, columnDefinition = "text")
private String publicKeyB64;
@Column(name = "private_key_b64", nullable = false, columnDefinition = "text")
private String privateKeyB64;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
public UUID getId() { return id; }
public String getPublicKeyB64() { return publicKeyB64; }
public void setPublicKeyB64(String publicKeyB64) { this.publicKeyB64 = publicKeyB64; }
public String getPrivateKeyB64() { return privateKeyB64; }
public void setPrivateKeyB64(String privateKeyB64) { this.privateKeyB64 = privateKeyB64; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,11 @@
package net.siegeln.cameleer.saas.license;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface SigningKeyRepository extends JpaRepository<SigningKeyEntity, UUID> {
Optional<SigningKeyEntity> findByActiveTrue();
}

View File

@@ -0,0 +1,88 @@
package net.siegeln.cameleer.saas.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
@Service
public class SigningKeyService {
private static final Logger log = LoggerFactory.getLogger(SigningKeyService.class);
private final SigningKeyRepository signingKeyRepository;
public SigningKeyService(SigningKeyRepository signingKeyRepository) {
this.signingKeyRepository = signingKeyRepository;
}
/**
* Returns the active signing key, generating a new Ed25519 keypair on first call.
*/
public SigningKeyEntity getOrCreateActiveKey() {
return signingKeyRepository.findByActiveTrue()
.orElseGet(this::generateAndStoreKey);
}
/**
* Returns the base64-encoded public key (X.509 SPKI format).
*/
public String getPublicKeyBase64() {
return getOrCreateActiveKey().getPublicKeyB64();
}
/**
* Reconstructs the Ed25519 PrivateKey from the stored base64.
*/
public PrivateKey getPrivateKey() {
SigningKeyEntity key = getOrCreateActiveKey();
try {
byte[] keyBytes = Base64.getDecoder().decode(key.getPrivateKeyB64());
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
} catch (Exception e) {
throw new IllegalStateException("Failed to reconstruct Ed25519 private key", e);
}
}
/**
* Reconstructs the Ed25519 PublicKey from the stored base64.
*/
public PublicKey getPublicKey() {
SigningKeyEntity key = getOrCreateActiveKey();
try {
byte[] keyBytes = Base64.getDecoder().decode(key.getPublicKeyB64());
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
return KeyFactory.getInstance("Ed25519").generatePublic(spec);
} catch (Exception e) {
throw new IllegalStateException("Failed to reconstruct Ed25519 public key", e);
}
}
private SigningKeyEntity generateAndStoreKey() {
try {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
var entity = new SigningKeyEntity();
entity.setPublicKeyB64(pubB64);
entity.setPrivateKeyB64(privB64);
entity.setActive(true);
var saved = signingKeyRepository.save(entity);
log.info("Generated new Ed25519 signing keypair (id={})", saved.getId());
return saved;
} catch (Exception e) {
throw new IllegalStateException("Failed to generate Ed25519 keypair", e);
}
}
}