From b8565af039c4b73a456f37ee72f50eaf880d9a32 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:13:57 +0200 Subject: [PATCH] feat(outbound): SecretCipher - AES-GCM with JWT-derived key for at-rest secret encryption Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/outbound/crypto/SecretCipher.java | 62 +++++++++++++++++++ .../app/outbound/crypto/SecretCipherTest.java | 42 +++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java new file mode 100644 index 00000000..3f7e4eb9 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java @@ -0,0 +1,62 @@ +package com.cameleer.server.app.outbound.crypto; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** Symmetric cipher for small at-rest secrets; key derived from JWT secret. */ +public class SecretCipher { + private static final String KDF_LABEL = "cameleer-outbound-secret-v1"; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec aesKey; + private final SecureRandom random = new SecureRandom(); + + public SecretCipher(String jwtSecret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] keyBytes = mac.doFinal(KDF_LABEL.getBytes(StandardCharsets.UTF_8)); + this.aesKey = new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new IllegalStateException("Failed to derive outbound secret key", e); + } + } + + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length); + buf.put(iv).put(ct); + return Base64.getEncoder().encodeToString(buf.array()); + } catch (Exception e) { + throw new IllegalStateException("Encryption failed", e); + } + } + + public String decrypt(String ciphertextB64) { + try { + byte[] full = Base64.getDecoder().decode(ciphertextB64); + if (full.length < IV_BYTES + 16) throw new IllegalArgumentException("ciphertext too short"); + byte[] iv = new byte[IV_BYTES]; + System.arraycopy(full, 0, iv, 0, IV_BYTES); + byte[] ct = new byte[full.length - IV_BYTES]; + System.arraycopy(full, IV_BYTES, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(ct); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Decryption failed", e); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java new file mode 100644 index 00000000..0ef070e9 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java @@ -0,0 +1,42 @@ +package com.cameleer.server.app.outbound.crypto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecretCipherTest { + private final SecretCipher cipher = new SecretCipher("test-jwt-secret-must-be-long-enough-to-derive"); + + @Test + void roundTrips() { + String plaintext = "my-hmac-secret-12345"; + String ct = cipher.encrypt(plaintext); + assertThat(ct).isNotEqualTo(plaintext); + assertThat(cipher.decrypt(ct)).isEqualTo(plaintext); + } + + @Test + void differentCiphertextsForSamePlaintext() { + String a = cipher.encrypt("x"); + String b = cipher.encrypt("x"); + assertThat(a).isNotEqualTo(b); + assertThat(cipher.decrypt(a)).isEqualTo(cipher.decrypt(b)); + } + + @Test + void decryptRejectsTamperedCiphertext() { + String ct = cipher.encrypt("abc"); + String tampered = ct.substring(0, ct.length() - 2) + "00"; + assertThatThrownBy(() -> cipher.decrypt(tampered)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void decryptRejectsWrongKey() { + String ct = cipher.encrypt("abc"); + SecretCipher other = new SecretCipher("some-other-jwt-secret-that-is-long-enough"); + assertThatThrownBy(() -> other.decrypt(ct)) + .isInstanceOf(IllegalArgumentException.class); + } +}