Files
qbit-email/src/main/java/com/kingsrook/qbits/email/phi/EmailBodyEncryption.java
T
james.maesandClaude Opus 5.5 10e96115f9 fix: review findings on templates, PHI and preview; add a local demo
- Template sends keep the template's category.
- Render budget (10,000 iterations, 1 MiB), nesting 8, linear parsing.
- URL attributes in HTML and MJML accept only http, https or mailto;
  unquoted attribute expressions are refused.
- MJML includes refused and disabled; the compiler runs with PATH only.
- Record variables load only referenced fields; records without a
  classification source are tokenized in snapshots.
- Approved templates keep their brand; brand URLs are https and image
  hosts are restricted for PHI mail.
- Viewers never trigger an MJML compile from the preview.
- Button links match a single link.
- PHI ciphertexts are bound to table, column and message (AES-GCM AAD).
- EmailDemoServer (test scope) seeds 24 hours of real traffic for
  screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:36:49 -05:00

138 lines
5.7 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.email.phi;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import com.kingsrook.qbits.email.EmailRuntime;
/*******************************************************************************
** Stateless AES-256-GCM body protection. Only the dispatcher decrypts content.
*******************************************************************************/
public final class EmailBodyEncryption
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EmailBodyEncryption()
{
}
/*******************************************************************************
** Resolve the deployment key, never retaining it between operations.
*******************************************************************************/
private static SecretKeySpec key() throws Exception
{
String secret = EmailRuntime.config().getSecretResolver().apply("QQQ_EMAIL_BODY_KEY");
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
return (new SecretKeySpec(MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)), "AES"));
}
/*******************************************************************************
** Encrypt with a fresh 96-bit nonce and 128-bit authentication tag.
*******************************************************************************/
public static String encrypt(String value, String column, String messageId)
{
try
{
SecretKeySpec key = key();
if(value == null)
{
return (null);
}
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
return ("enc:v1:" + Base64.getUrlEncoder().withoutPadding().encodeToString(ByteBuffer.allocate(iv.length + encrypted.length).put(iv).put(encrypted).array()));
}
catch(Exception e)
{
throw (new IllegalArgumentException("PHI email needs QQQ_EMAIL_BODY_KEY"));
}
}
/*******************************************************************************
** Authenticate before exposing plaintext. Failures never echo ciphertext.
*******************************************************************************/
public static String decrypt(String value, String column, String messageId)
{
if(value == null || !value.startsWith("enc:v1:"))
{
return (value);
}
try
{
ByteBuffer bytes = ByteBuffer.wrap(Base64.getUrlDecoder().decode(value.substring(7)));
byte[] iv = new byte[12];
bytes.get(iv);
byte[] encrypted = new byte[bytes.remaining()];
bytes.get(encrypted);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv));
cipher.updateAAD(aad(column, messageId));
return (new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Encrypted email body could not be opened"));
}
}
/*******************************************************************************
** Bind ciphertext to its storage table, column and message identity.
*******************************************************************************/
private static byte[] aad(String column, String messageId)
{
String storageColumn = switch(column)
{
case "subject" -> "subject";
case "htmlBody" -> "html_body";
case "textBody" -> "text_body";
default -> throw (new IllegalArgumentException("Unknown protected email column"));
};
return (("email_message\n" + storageColumn + "\n" + java.util.Objects.requireNonNull(messageId)).getBytes(StandardCharsets.UTF_8));
}
/*******************************************************************************
** Authenticate and rebind retained ciphertext without exposing plaintext to
** the resend caller. Only delivery callers receive decrypted content.
*******************************************************************************/
public static String rebind(String value, String column, String oldMessageId, String newMessageId)
{
return (encrypt(decrypt(value, column, oldMessageId), column, newMessageId));
}
}