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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user