|
| 1 | +package com.babelqueue.gdpr; |
| 2 | + |
| 3 | +import java.nio.charset.StandardCharsets; |
| 4 | +import java.security.GeneralSecurityException; |
| 5 | +import java.security.SecureRandom; |
| 6 | +import java.util.Arrays; |
| 7 | +import java.util.Base64; |
| 8 | +import javax.crypto.spec.GCMParameterSpec; |
| 9 | +import javax.crypto.spec.SecretKeySpec; |
| 10 | + |
| 11 | +/** |
| 12 | + * A reference {@link Cipher} built ONLY on the JDK's {@code javax.crypto} ({@code AES/GCM/NoPadding}): |
| 13 | + * AES-GCM authenticated encryption with a fresh random 12-byte IV per call, the IV <b>prepended</b> |
| 14 | + * to the ciphertext, the whole thing Base64-encoded so it drops straight into a JSON string. The key |
| 15 | + * is the CALLER's — this type performs no key management, rotation or derivation; bind a KMS-backed |
| 16 | + * {@link Cipher} for that. |
| 17 | + * |
| 18 | + * <p>A 32-byte key selects AES-256-GCM (recommended); 24- and 16-byte keys select AES-192/128-GCM. |
| 19 | + * GCM authenticates the ciphertext, so {@link #decrypt} rejects any tampered or wrong-key input by |
| 20 | + * throwing (it never returns corrupt plaintext). It pulls no third-party crypto dependency (GR-7) |
| 21 | + * and is safe for concurrent use — {@code javax.crypto.Cipher} instances are created per call, and |
| 22 | + * the stored key material is only read. |
| 23 | + */ |
| 24 | +public final class AesGcmCipher implements Cipher { |
| 25 | + |
| 26 | + /** AES-GCM standard nonce/IV length, in bytes. A 12-byte IV is the recommended GCM size. */ |
| 27 | + private static final int IV_LENGTH = 12; |
| 28 | + |
| 29 | + /** GCM authentication-tag length, in bits (the full 128-bit tag). */ |
| 30 | + private static final int TAG_BITS = 128; |
| 31 | + |
| 32 | + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; |
| 33 | + private static final String ALGORITHM = "AES"; |
| 34 | + |
| 35 | + private final SecretKeySpec key; |
| 36 | + private final SecureRandom random = new SecureRandom(); |
| 37 | + |
| 38 | + /** |
| 39 | + * Build an AES-GCM reference cipher from a raw symmetric key. The key length selects the AES |
| 40 | + * variant: 32 bytes → AES-256-GCM (recommended), 24 → AES-192, 16 → AES-128. |
| 41 | + * |
| 42 | + * @param keyBytes the raw symmetric key (16, 24, or 32 bytes) |
| 43 | + * @throws InvalidKeySizeException if the key is not 16, 24, or 32 bytes |
| 44 | + */ |
| 45 | + public AesGcmCipher(byte[] keyBytes) { |
| 46 | + int len = keyBytes == null ? 0 : keyBytes.length; |
| 47 | + if (len != 16 && len != 24 && len != 32) { |
| 48 | + throw new InvalidKeySizeException(len); |
| 49 | + } |
| 50 | + this.key = new SecretKeySpec(keyBytes, ALGORITHM); |
| 51 | + } |
| 52 | + |
| 53 | + /** |
| 54 | + * Seals {@code plaintext} with a fresh random IV, prepends the IV, and Base64-encodes the result |
| 55 | + * ({@code Base64(iv || ciphertext || tag)}). |
| 56 | + */ |
| 57 | + @Override |
| 58 | + public String encrypt(byte[] plaintext) throws GeneralSecurityException { |
| 59 | + byte[] iv = new byte[IV_LENGTH]; |
| 60 | + random.nextBytes(iv); |
| 61 | + |
| 62 | + javax.crypto.Cipher gcm = javax.crypto.Cipher.getInstance(TRANSFORMATION); |
| 63 | + gcm.init(javax.crypto.Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); |
| 64 | + byte[] sealed = gcm.doFinal(plaintext); |
| 65 | + |
| 66 | + byte[] out = new byte[iv.length + sealed.length]; |
| 67 | + System.arraycopy(iv, 0, out, 0, iv.length); |
| 68 | + System.arraycopy(sealed, 0, out, iv.length, sealed.length); |
| 69 | + return Base64.getEncoder().encodeToString(out); |
| 70 | + } |
| 71 | + |
| 72 | + /** |
| 73 | + * Reverses {@link #encrypt}: Base64-decodes, splits off the prepended IV, and opens the GCM |
| 74 | + * ciphertext. A wrong key or tampered input fails GCM authentication and throws (never corrupt |
| 75 | + * plaintext). |
| 76 | + * |
| 77 | + * @throws MalformedCiphertextException if the input is not valid Base64 or is too short to hold |
| 78 | + * an IV (i.e. not something this cipher produced) |
| 79 | + * @throws GeneralSecurityException if GCM authentication fails (wrong key or tampering) |
| 80 | + */ |
| 81 | + @Override |
| 82 | + public byte[] decrypt(String ciphertext) throws GeneralSecurityException { |
| 83 | + byte[] raw; |
| 84 | + try { |
| 85 | + raw = Base64.getDecoder().decode(ciphertext.getBytes(StandardCharsets.UTF_8)); |
| 86 | + } catch (IllegalArgumentException ex) { |
| 87 | + throw new MalformedCiphertextException("not valid Base64", ex); |
| 88 | + } |
| 89 | + if (raw.length < IV_LENGTH) { |
| 90 | + throw new MalformedCiphertextException("shorter than the IV", null); |
| 91 | + } |
| 92 | + |
| 93 | + byte[] iv = Arrays.copyOfRange(raw, 0, IV_LENGTH); |
| 94 | + byte[] sealed = Arrays.copyOfRange(raw, IV_LENGTH, raw.length); |
| 95 | + |
| 96 | + javax.crypto.Cipher gcm = javax.crypto.Cipher.getInstance(TRANSFORMATION); |
| 97 | + gcm.init(javax.crypto.Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); |
| 98 | + // AEADBadTagException (a GeneralSecurityException) on wrong key / tampered input. |
| 99 | + return gcm.doFinal(sealed); |
| 100 | + } |
| 101 | +} |
0 commit comments