From 2fd14165bcf0fc88831be005ad622459cefa092f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:21:16 +0200 Subject: [PATCH] feat: add SigningKeyService for Ed25519 keypair management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../saas/license/SigningKeyEntity.java | 47 ++++++++++ .../saas/license/SigningKeyRepository.java | 11 +++ .../saas/license/SigningKeyService.java | 88 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java new file mode 100644 index 0000000..383bf6a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java @@ -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; } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java new file mode 100644 index 0000000..abc0fea --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java @@ -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 { + + Optional findByActiveTrue(); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java new file mode 100644 index 0000000..f4f5974 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java @@ -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); + } + } +}