feat(outbound): SecretCipher - AES-GCM with JWT-derived key for at-rest secret encryption

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 16:13:57 +02:00
parent 46b8f63fd1
commit b8565af039
2 changed files with 104 additions and 0 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}