refactor: 각 어댑터터별 리펙토링 진행

This commit is contained in:
DongHyeonka
2026-08-24 18:26:40 +09:00
parent e98b56eb03
commit 0137263441
439 changed files with 31935 additions and 4719 deletions
+17 -2
View File
@@ -20,8 +20,13 @@ dependencies {
implementation 'io.projectreactor:reactor-core'
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
// messaging adapter already depends on rather than a second implementation of the same spec.
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
// parser onto the runtime classpath would add attack surface for a format nothing reads.
//
// Jackson's YAML dataformat is excluded because schemas arrive as JSON strings and a second
// parser for a format this leaf never reads is surface for nothing. It does not remove YAML from
// the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how
// Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and
// the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now
// states the claim the build can check.
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
// starter, which would drag a view resolver and a servlet integration onto an outbound
// adapter that renders strings and never serves a request.
@@ -37,3 +42,13 @@ dependencies {
testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The exclusion above, stated as something the build verifies rather than something a comment
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
dependencyPolicy {
absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml',
because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' +
'this leaf never reads'
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
}
@@ -86,13 +86,19 @@ public record NotificationPlatformSettings(
/**
* The largest body the platform can retain, derived rather than chosen.
*
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a
* 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration
* allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything
* over 65,536 *after* encryption — so a body of exactly the configured maximum passed every
* check above the database and failed the CHECK constraint, having already been acknowledged.
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and the envelope adds
* a version byte, a key id, a nonce and a tag. Three layers each enforced a different number:
* configuration allowed a mebibyte, the MVC controller hard-coded 65,536, and the database
* rejected anything over 65,536 *after* encryption — so a body of exactly the configured
* maximum passed every check above the database and failed the CHECK constraint, having already
* been acknowledged.
*
* <p>Read from the protector rather than restated, because a number written twice is a number
* that drifts the first time the envelope gains a field.
*/
private static final long MAX_BODY_CEILING = 65_536L - 28L;
private static final long MAX_BODY_CEILING =
dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES;
public Callbacks {
Objects.requireNonNull(replaySkew, "replaySkew");
@@ -100,7 +106,12 @@ public record NotificationPlatformSettings(
throw new IllegalArgumentException(
"max-body-bytes must be 1.."
+ MAX_BODY_CEILING
+ "; the ciphertext column holds 65536 bytes and encryption adds 28");
+ "; the ciphertext column holds "
+ dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES
+ " bytes and the envelope adds "
+ dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.ENVELOPE_OVERHEAD_BYTES);
}
if (replaySkew.isNegative()) {
throw new IllegalArgumentException("replay-skew must not be negative");
@@ -109,8 +120,8 @@ public record NotificationPlatformSettings(
/** Conservative defaults. */
public static Callbacks defaults() {
// The storable maximum, not the column size: encryption adds 28 bytes, so a default of
// 65,536 was a default that could not be stored.
// The storable maximum, not the column size: the envelope adds a version byte, a key id, a
// nonce and a tag, so a default of 65,536 was a default that could not be stored.
return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5));
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.util.EnumSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Which key purposes a given configuration actually needs.
*
* <p>Startup demanded all eight, always. That is fail-closed in the wrong direction: it made every
* deployment provision and rotate keys for capabilities it had switched off — a Web Push signing
* key for a platform with no Web Push profile, a callback signing key for a platform with no
* callback endpoint — and a key that exists but is never used is a key nobody notices leaking. It
* also made the eight look equally load-bearing, so nothing distinguished the four that every mode
* needs from the four that follow a capability.
*
* <p>The direction that must not weaken is the other one: a capability that is switched <em>on</em>
* and whose key is missing still refuses the boot, because the alternative is discovering it on a
* user's notification. This class decides only what is required; validation of whatever is supplied
* happens regardless, so an unused key that is configured is still checked rather than trusted.
*/
public final class NotificationSecretRequirements {
private NotificationSecretRequirements() {}
/**
* The purposes this configuration must supply.
*
* @param settings the bound platform configuration
* @return the required purposes, never empty
*/
public static Set<SecretPurpose> requiredBy(NotificationPlatformSettings settings) {
Objects.requireNonNull(settings, "settings");
Set<SecretPurpose> required = EnumSet.copyOf(ALWAYS);
if (settings.callbacks().enabled()) {
// The callback endpoint verifies a provider signature and fingerprints the payload for
// deduplication. Both happen on the first callback that arrives, so neither key can be
// deferred to "when it is needed".
required.add(SecretPurpose.CALLBACK_SIGNING);
required.add(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
for (Map.Entry<String, NotificationPlatformSettings.Provider> entry :
settings.providers().entrySet()) {
NotificationPlatformSettings.Provider profile = entry.getValue();
if (!profile.enabled()) {
continue;
}
ProviderType type = ProviderType.parse(entry.getKey(), profile.type());
if (authenticatesWithAPlatformCredential(type)) {
required.add(SecretPurpose.PROVIDER_CREDENTIAL);
}
if (type == ProviderType.WEB_PUSH) {
required.add(SecretPurpose.VAPID_SIGNING);
}
if (profile.callbackSigningSecretRef() != null
&& !profile.callbackSigningSecretRef().isBlank()) {
// A profile that names a signing key ref intends to verify or produce signatures whatever
// the platform-wide callback switch says.
required.add(SecretPurpose.CALLBACK_SIGNING);
}
}
return Set.copyOf(required);
}
/**
* Whether a family authenticates to its provider with a key this platform holds.
*
* <p>SMTP does not: its relay address, user and password come from Spring's own {@code
* spring.mail.*} through the injected mail sender, which is why {@code
* SmtpProviderRuntimeAssembler} never touches the secret store. Demanding a provider credential
* for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate
* to.
*/
private static boolean authenticatesWithAPlatformCredential(ProviderType type) {
return type != ProviderType.SMTP;
}
/**
* The purposes every mode needs, including {@code INGEST_ONLY}.
*
* <p>Each of these is on the accept path rather than the dispatch path, so switching every
* provider off does not switch any of them off. Contact points are encrypted and their lookup
* hashes computed when a recipient is resolved; notification variables are encrypted at rest by
* the record mapper, which takes the protection as a constructor argument with no fallback; and
* provider request ids are hashed by the attempt store and the event ledger on every row they
* write.
*/
private static final Set<SecretPurpose> ALWAYS =
Set.of(
SecretPurpose.CONTACT_ENCRYPTION,
SecretPurpose.CONTACT_LOOKUP_HMAC,
SecretPurpose.PAYLOAD_ENCRYPTION,
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Opens the attachments an email submission declares.
*
* <p>Every email provider needs the same three things in the same order — resolve, verify, close —
* and each of them is a silent failure when a second copy gets one wrong: an unresolved attachment
* becomes a mail missing the document it is about, an unverified one becomes bytes nobody approved,
* and an unclosed one becomes a leaked stream that only shows up under load.
*
* <p>The integrity check runs before the provider call, not after. A digest or size that does not
* match what the caller pinned at submit time means these are not the approved bytes, and finding
* that out once the mail has left is finding it out too late.
*/
public final class EmailAttachments {
private EmailAttachments() {}
/**
* Resolves and verifies everything the content declares.
*
* <p>The caller closes the result on every path, including the failing ones. Content that is not
* email, or email that declares nothing, resolves to an empty list rather than to a failure —
* having no attachment is the normal case.
*/
public static List<ResolvedAttachment> open(
AttachmentIntegrityGuard guard, ProviderSubmission submission) {
Objects.requireNonNull(guard, "guard");
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof EmailContent email)
|| email.attachments().isEmpty()) {
return List.of();
}
AttachmentAccessContext context =
new AttachmentAccessContext(
new TenantId(submission.profile().environment()), submission.attemptId());
List<ResolvedAttachment> resolved = new ArrayList<>(email.attachments().size());
try {
for (var reference : email.attachments()) {
resolved.add(guard.resolve(reference, context));
}
return List.copyOf(resolved);
} catch (RuntimeException failure) {
// Everything already opened is closed before the failure propagates. Half a resolution is
// still half a set of open streams.
closeAll(resolved);
throw failure;
}
}
/** Closes everything that was opened, whatever the send did. */
public static void closeAll(List<ResolvedAttachment> attachments) {
Objects.requireNonNull(attachments, "attachments");
for (ResolvedAttachment attachment : attachments) {
try {
attachment.close();
} catch (Exception ignored) {
// A stream that will not close is not a reason to change the send's outcome, and a failed
// send is exactly when a leaked one would otherwise go unnoticed.
}
}
}
}
@@ -1,24 +1,28 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -42,25 +46,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
private final NotificationHttpGateway gateway;
private final SesRequestMapper mapper;
private final AttachmentIntegrityGuard attachmentGuard;
private final SesFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
private final String accessKeyId;
private final Clock clock;
public SesNotificationProviderAdapter(
NotificationHttpGateway gateway,
SesRequestMapper mapper,
AttachmentIntegrityGuard attachmentGuard,
SesFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets,
ProviderCredentialManager credentials,
String accessKeyId,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -77,8 +84,21 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
@Override
public ProviderCapabilities capabilities() {
// The payload ceiling is the mapper's own constant rather than a second copy of the number:
// the runtime plans against what is declared here and the mapper refuses against what it holds,
// and two spellings of the same limit is one of them being wrong.
return new ProviderCapabilities(
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
false,
false,
true,
false,
false,
false,
false,
false,
1,
SesRequestMapper.MAX_MESSAGE_BYTES,
Duration.ofDays(1));
}
@Override
@@ -97,13 +117,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
throw new IllegalArgumentException("SES requires an email contact point");
}
var request =
mapper.map(
submission,
address.normalized(),
accessKeyId,
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
clock.instant());
// Resolved and verified before the request is shaped, and closed as soon as it is. An
// attachment only reaches the wire through the raw MIME message the mapper builds from these
// streams, so they have to be open for exactly that long and no longer.
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
NotificationHttpRequest request;
try {
request =
mapper.map(
submission,
address.normalized(),
opened,
accessKeyId,
// This profile's credential at the generation the submission was planned against, not
// the platform's one current provider credential. Sharing a single key across every
// profile made one leaked SES account's key a leak of every provider account, and
// made a per-profile rotation inexpressible.
credentials.materialFor(
submission.profile().profileId(), submission.profile().credentialGeneration()),
clock.instant());
} finally {
EmailAttachments.closeAll(opened);
}
try {
NotificationHttpResponse response = gateway.exchange(request);
@@ -20,7 +20,11 @@ public record SesProviderProperties(
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(configurationSet, "configurationSet");
Objects.requireNonNull(timeout, "timeout");
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
// See WebhookSubscription for why this is the stronger guard now. An SES endpoint is operator
// configured rather than user supplied, so loopback stays available for local and contract
// profiles; what is refused is a configured endpoint that resolves into the deployment's own
// network.
NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
if (region.isBlank() || senderIdentity.isBlank()) {
throw new IllegalArgumentException("region and senderIdentity must not be blank");
}
@@ -2,54 +2,98 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import jakarta.mail.MessagingException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Builds the signed SES v2 send request. */
/**
* Builds the signed SES v2 send request.
*
* <p>Two content shapes, chosen by what the notification actually carries. {@code Simple} is a
* subject and two bodies and nothing else, so an email that declares an attachment is assembled as
* a MIME message and sent as {@code Raw} content instead. Content that declared an attachment used
* to be sent as {@code Simple} regardless: the document was silently absent from the mail SES sent
* and the attempt was still recorded as delivered, which is a recipient told to read something that
* is not there.
*
* <p>The MIME message is built by the factory the SMTP provider already uses, rather than by a
* second assembly of the same rendered email. Multipart layout, UTF-8 and the header-separator
* rejection that keeps a subject from turning a notification into someone else's mail are decided
* once; two builders would be two places for those answers to drift apart.
*
* <p>What the raw path still cannot express is refused before the request is signed, so nothing has
* been sent when it happens: content that is not email, an attachment the resolver did not hand
* back, and a message larger than SES will accept.
*/
public final class SesRequestMapper {
/**
* The largest message SES accepts, measured on the bytes that go on the wire.
*
* <p>Measured after assembly rather than against the declared attachment sizes, because base64
* transfer encoding adds a third: a set of parts that clears the limit before encoding and
* exceeds it after would be rejected by SES with the attempt already made, and an attempt made is
* an attempt the evidence model has to reason about.
*/
public static final long MAX_MESSAGE_BYTES = 10_000_000L;
private static final String PATH = "/v2/email/outbound-emails";
private final SesProviderProperties properties;
private final AwsSignatureV4Signer signer;
private final SmtpMimeMessageFactory mimeFactory;
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
public SesRequestMapper(
SesProviderProperties properties,
AwsSignatureV4Signer signer,
SmtpMimeMessageFactory mimeFactory) {
this.properties = Objects.requireNonNull(properties, "properties");
this.signer = Objects.requireNonNull(signer, "signer");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
}
/** Map one submission into a signed request. */
/**
* Map one submission into a signed request.
*
* @param attachments the already resolved and verified attachments, open for the length of this
* call; the caller closes them
*/
public NotificationHttpRequest map(
ProviderSubmission submission,
String recipientAddress,
List<ResolvedAttachment> attachments,
String accessKeyId,
byte[] secretAccessKey,
Instant signedAt) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(recipientAddress, "recipientAddress");
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw new IllegalArgumentException("SES requires email content");
}
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("FromEmailAddress", properties.senderIdentity());
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
payload.put("Content", Map.of("Simple", simple));
payload.put("Destination", Map.of("ToAddresses", List.of(recipientAddress)));
payload.put("Content", content(submission, email, recipientAddress, attachments));
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
byte[] body =
@@ -83,4 +127,83 @@ public final class SesRequestMapper {
body,
properties.timeout());
}
/**
* The content shape this email needs.
*
* <p>The decision is made from what the content <em>declares</em>, not from what was handed in:
* an email that declares an attachment and arrives with fewer than it declared must not fall back
* to {@code Simple}, because that is precisely the send that leaves the document behind and
* reports success.
*/
private Map<String, Object> content(
ProviderSubmission submission,
EmailContent email,
String recipientAddress,
List<ResolvedAttachment> attachments) {
if (email.attachments().isEmpty()) {
return Map.of("Simple", simple(email));
}
if (attachments.size() != email.attachments().size()) {
throw rejection(
new IllegalStateException(
"the submission declares "
+ email.attachments().size()
+ " attachments and "
+ attachments.size()
+ " were opened"));
}
return Map.of("Raw", Map.of("Data", rawMessage(submission, recipientAddress, attachments)));
}
/** The MIME message, base64 encoded as the SES v2 JSON binding requires for a blob. */
private String rawMessage(
ProviderSubmission submission,
String recipientAddress,
List<ResolvedAttachment> attachments) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try {
mimeFactory
.create(submission, recipientAddress, properties.senderIdentity(), attachments)
.writeTo(buffer);
} catch (IOException | MessagingException failure) {
// The cause, never the content: which step of assembly failed is what an operator needs, and
// the bytes it failed on are the recipient's document.
throw rejection(failure);
}
byte[] message = buffer.toByteArray();
if (message.length > MAX_MESSAGE_BYTES) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
return Base64.getEncoder().encodeToString(message);
}
private static Map<String, Object> simple(EmailContent email) {
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
return simple;
}
/**
* The refusal, carrying what caused it.
*
* <p>One descriptor for every shaping failure — it is what ends up on the delivery row, and a
* per-check code there is a metric-cardinality problem. The cause is what tells an operator which
* check fired.
*/
private static NotificationValidationException rejection(Throwable cause) {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
cause);
}
}
@@ -10,6 +10,8 @@ import dev.caskeleton.application.notification.platform.provider.ResolvedAttachm
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
@@ -72,8 +74,16 @@ public final class SmtpMimeMessageFactory {
helper.setText(email.textBody(), false);
}
for (ResolvedAttachment attachment : attachments) {
byte[] bytes = read(attachment);
// A source that can be read again, not the resolver's one-shot stream. JavaMail reads an
// attachment twice — once to choose the part's transfer encoding, once to write the part —
// and the second read of an already drained stream returned nothing. The part that went out
// announced a filename and carried no bytes, so the mail arrived with an empty attachment
// and the attempt was still recorded as accepted.
helper.addAttachment(
attachment.displayName(), () -> attachment.content(), attachment.contentType());
attachment.displayName(),
() -> new ByteArrayInputStream(bytes),
attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey(), "approved header name");
@@ -86,6 +96,30 @@ public final class SmtpMimeMessageFactory {
}
}
/**
* Reads the attachment into memory once.
*
* <p>Bounded by the size the integrity guard already pinned against the reference, and the read
* is checked against it: a stream that turns out to be longer or shorter than the size that was
* verified is not the content that was approved, whatever its reported digest said.
*/
private static byte[] read(ResolvedAttachment attachment) {
byte[] bytes;
try {
bytes = attachment.content().readAllBytes();
} catch (IOException unreadable) {
throw rejection(unreadable);
}
if (bytes.length != attachment.size()) {
// The count, never the bytes: how much was read is diagnostic, what was read is the
// recipient's document.
throw rejection(
new IllegalStateException(
"attachment declared " + attachment.size() + " bytes and read " + bytes.length));
}
return bytes;
}
private static void requireHeaderSafe(String value, String field) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
// The field name, never the value: a header-injection attempt is exactly the payload that
@@ -1,10 +1,11 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
@@ -38,8 +39,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
private final ContactPointProtector protector;
private final SmtpProviderProperties properties;
private final Executor executor;
private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
attachmentGuard;
private final AttachmentIntegrityGuard attachmentGuard;
public SmtpNotificationProviderAdapter(
SmtpDispatch dispatch,
@@ -48,8 +48,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
ContactPointProtector protector,
SmtpProviderProperties properties,
Executor executor,
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
attachmentGuard) {
AttachmentIntegrityGuard attachmentGuard) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
@@ -96,7 +95,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
// Resolved, verified and closed around the send. The factory was handed List.of() whatever the
// content asked for, so an email with attachments went out without them — the caller was told
// it was accepted, and the recipient received a message missing the thing it was about.
List<ResolvedAttachment> opened = resolve(submission);
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
try {
dispatch.send(
mimeFactory.create(
@@ -105,51 +104,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
} catch (SmtpDispatchException failure) {
return classifier.classify(failure, elapsedSince(startedNanos));
} finally {
// Closed on every path. A resolver hands back an open stream, and a failed send is exactly
// when a leaked one goes unnoticed.
opened.forEach(SmtpNotificationProviderAdapter::closeQuietly);
}
}
/**
* Resolves and verifies every attachment the content declares.
*
* <p>The integrity guard runs before the provider call, not after: a digest or size that does not
* match what the caller declared means the bytes are not the bytes that were approved, and
* discovering that after the mail has left is discovering it too late.
*/
private List<ResolvedAttachment> resolve(ProviderSubmission submission) {
if (!(submission.content().content() instanceof EmailContent email)
|| email.attachments().isEmpty()) {
return List.of();
}
List<ResolvedAttachment> resolved = new java.util.ArrayList<>(email.attachments().size());
try {
for (var reference : email.attachments()) {
// The guard resolves and verifies size and digest in one step, so an attachment whose
// bytes are not the approved bytes never reaches the MIME factory.
resolved.add(
attachmentGuard.resolve(
reference,
new dev.caskeleton.application.notification.platform.provider
.AttachmentAccessContext(
new dev.caskeleton.application.notification.platform.api.TenantId(
submission.profile().environment()),
submission.attemptId())));
}
return List.copyOf(resolved);
} catch (RuntimeException failure) {
// Everything already opened is closed before the failure propagates.
resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly);
throw failure;
}
}
private static void closeQuietly(ResolvedAttachment attachment) {
try {
attachment.close();
} catch (Exception ignored) {
// A stream that will not close is not a reason to change the send's outcome.
EmailAttachments.closeAll(opened);
}
}
@@ -52,7 +52,7 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
properties.canonicalCallbackUrl(),
parameters,
request.header("x-twilio-signature").orElse(null),
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
signingKey());
return valid
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
@@ -65,6 +65,26 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
}
/**
* The key this profile's callbacks are signed with.
*
* <p>Verification used the platform's one current callback signing key, so every Twilio profile
* in a deployment shared it: a subaccount whose token leaked could forge status callbacks for any
* other, and a profile could not be rotated on its own. {@code callbackSigningKeyRef} is the
* profile's own reference, and a reference naming a key issued for another purpose is a
* configuration fault rather than a signature that quietly never matches.
*/
private byte[] signingKey() {
var key = secrets.keyById(properties.callbackSigningKeyRef());
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
throw new IllegalStateException(
"twilio profile for account "
+ properties.accountSid()
+ " names a key that is not a callback signing key");
}
return key.material();
}
private static Map<String, String> parseForm(byte[] body) {
Map<String, String> parameters = new LinkedHashMap<>();
String raw = new String(body, StandardCharsets.UTF_8);
@@ -12,6 +12,10 @@ import java.util.Optional;
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
* failed verification".
*
* <p>{@code callbackSigningKeyRef} is this profile's own signing key, mirroring the profile's
* {@code callback-signing-secret-ref} setting. Verification used the platform's single current
* callback signing key, so every profile shared one secret.
*/
public record TwilioProviderProperties(
URI endpoint,
@@ -19,6 +23,7 @@ public record TwilioProviderProperties(
Optional<String> messagingServiceSid,
Optional<String> fromNumber,
String canonicalCallbackUrl,
String callbackSigningKeyRef,
Duration timeout,
Duration maxReconciliationAge) {
@@ -28,11 +33,17 @@ public record TwilioProviderProperties(
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
Objects.requireNonNull(fromNumber, "fromNumber");
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
Objects.requireNonNull(callbackSigningKeyRef, "callbackSigningKeyRef");
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
if (accountSid.isBlank()) {
throw new IllegalArgumentException("accountSid");
}
if (callbackSigningKeyRef.isBlank()) {
// Blank would fall back to whatever key is current, which is the platform-wide sharing this
// reference exists to end.
throw new IllegalArgumentException("callbackSigningKeyRef");
}
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
throw new IllegalArgumentException(
"exactly one of messagingServiceSid or fromNumber must be configured");
@@ -5,13 +5,12 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.http.Notif
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
@@ -37,19 +36,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
private final NotificationHttpGateway gateway;
private final TwilioProviderProperties properties;
private final TwilioStatusNormalizer normalizer;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
private final Clock clock;
public TwilioReconciliationCapability(
NotificationHttpGateway gateway,
TwilioProviderProperties properties,
TwilioStatusNormalizer normalizer,
SecretMaterialProvider secrets,
ProviderCredentialManager credentials,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.properties = Objects.requireNonNull(properties, "properties");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -75,7 +74,8 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
}
try {
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
NotificationHttpResponse response =
gateway.exchange(statusRequest(attempt, messageSid.get()));
if (!response.isSuccessful()) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed(
@@ -111,14 +111,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
};
}
private NotificationHttpRequest statusRequest(String messageSid) {
String credentials =
private NotificationHttpRequest statusRequest(
DeliveryAttemptSnapshot attempt, String messageSid) {
// The credential the attempt was made with, not whichever one is current. A status query is a
// question about work that already happened, and asking it with a newer generation's token
// fails once a rotation has landed — precisely when reconciliation matters most.
String authorization =
Base64.getEncoder()
.encodeToString(
(properties.accountSid()
+ ":"
+ new String(
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
credentials.materialFor(
attempt.providerProfileId(), attempt.credentialGeneration()),
StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
@@ -131,7 +136,7 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
+ "/Messages/"
+ messageSid
+ ".json"),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + authorization)),
new byte[0],
properties.timeout());
}
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderRe
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
@@ -15,8 +16,6 @@ import dev.caskeleton.application.notification.platform.provider.ProviderSubmiss
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
@@ -40,19 +39,19 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
private final TwilioRequestMapper mapper;
private final TwilioFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
public TwilioSmsProviderAdapter(
NotificationHttpGateway gateway,
TwilioRequestMapper mapper,
TwilioFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets) {
ProviderCredentialManager credentials) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
}
@Override
@@ -93,7 +92,11 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
mapper.map(
submission,
phone.e164(),
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material());
// This profile's auth token at the generation the submission was planned against. One
// platform-wide provider credential meant a leak of one Twilio account's token was a
// leak of every profile's, whichever provider they belonged to.
credentials.materialFor(
submission.profile().profileId(), submission.profile().credentialGeneration()));
try {
NotificationHttpResponse response = gateway.exchange(request);
@@ -10,6 +10,8 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
@@ -82,10 +84,23 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
return Set.of(Channel.WEBHOOK);
}
/** The largest body this adapter will put on the wire. */
public static final long MAX_BODY_BYTES = 1_000_000L;
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1));
false,
false,
false,
false,
false,
false,
false,
false,
1,
MAX_BODY_BYTES,
Duration.ofHours(1));
}
@Override
@@ -130,6 +145,14 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
NotificationJsonMapper.mapper()
.writeValueAsString(envelope)
.getBytes(StandardCharsets.UTF_8);
// Measured on the bytes that will be sent. The capability declared a ceiling and nothing
// enforced it, so an oversized body was discovered by the receiver rejecting it — after the
// request had been made, which for a webhook is after the receiver may already have acted.
if (body.length > MAX_BODY_BYTES) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
Map<String, String> headers = new LinkedHashMap<>();
headers.put("content-type", "application/json");
@@ -139,8 +162,7 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond()));
headers.put(
WebhookSignatureStrategy.SIGNATURE_HEADER,
signatures.sign(
body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()));
signatures.sign(body, timestamp, signingKey(subscription)));
}
NotificationHttpRequest request =
@@ -182,6 +204,29 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
}
}
/**
* The key a subscription's signature is computed with.
*
* <p>{@code signingKeyRef} was read only to decide whether to sign at all, and the signature was
* then computed with the platform's current callback signing key. Every trusted subscription
* therefore shared one secret: a receiver holding its own key could verify — and forge —
* deliveries meant for any other, and rotating one subscription's key rotated all of them.
*
* @throws IllegalStateException if the reference names a key that is not a callback signing key,
* which is a configuration fault and is raised before the request is made rather than
* producing a signature the receiver will reject
*/
private byte[] signingKey(WebhookSubscription subscription) {
var key = secrets.keyById(subscription.signingKeyRef().orElseThrow());
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
throw new IllegalStateException(
"webhook subscription "
+ subscription.subscriptionId()
+ " names a key that is not a callback signing key");
}
return key.material();
}
/**
* The receiver's own backoff hint, when it sent a usable one.
*
@@ -11,6 +11,10 @@ import java.util.Optional;
* <p>{@code trusted} decides which gateway carries the call. A trusted subscription is operator
* configured and may use platform credentials; a dynamic one comes from user input and must not
* inherit anything, because that is how a webhook feature becomes an SSRF credential-relay.
*
* <p>It decides the loopback allowance for the same reason. An operator naming a local endpoint is
* describing their own deployment; a client naming one is asking the platform to deliver a message
* body to an interface the client cannot otherwise reach.
*/
public record WebhookSubscription(
String subscriptionId, URI target, boolean trusted, Optional<String> signingKeyRef) {
@@ -22,7 +26,27 @@ public record WebhookSubscription(
if (subscriptionId.isBlank()) {
throw new IllegalArgumentException("subscriptionId");
}
NotificationEndpoints.requireSecureOrLoopback(target, "webhook target");
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
// passed. The stronger guard was written for exactly this call site and then called from
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
// weaker check.
//
// The loopback allowance is `trusted`, not a constant. It was `true` for every caller, which
// left one case open: a client-supplied target naming `localhost` reached the loopback
// interface. Closing it was deferred on the grounds that the allowance had to become a decision
// the caller states and no caller existed to state it — but the decision is this record's first
// boolean, and two lines below it already decides whether the target may inherit a platform
// signing key. A subscription an operator configured may address a local endpoint, because the
// operator profiles and the contract harness do exactly that. One that came from user input may
// not, for the same reason it may not inherit credentials: it is not the deployment's own
// address to name.
//
// What remains open is narrower and belongs to the guard, not here: the target is resolved once
// at construction and re-resolved independently by the HTTP client, so a name that changes its
// answer between the two is refused only if the first lookup already shows an internal address.
// NTF-012, docs/reviews/2026-08-14-notification-module-code-review.md.
NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
if (!trusted && signingKeyRef.isPresent()) {
throw new IllegalArgumentException(
"a dynamic target may not be paired with a platform signing key");
@@ -4,18 +4,16 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.TreeMap;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
@@ -25,35 +23,36 @@ import javax.crypto.spec.SecretKeySpec;
* provider actually sent — but it routinely contains addresses and message metadata, so it is
* encrypted and truncated rather than stored as received.
*
* <p>The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the
* fingerprint is keyed so that providers without an event id still get collision-resistant,
* <p>The stored bytes are the same versioned, key-identified envelope the notification payload
* column uses, and for the same reason. This class used to write a bare nonce and ciphertext: the
* day the payload encryption key rotated, every retained callback became unreadable and nothing in
* the row could say which key it had needed. A retention format whose whole justification is later
* diagnosis has to survive the rotation that happens in between.
*
* <p>The fingerprint is keyed so that providers without an event id still get collision-resistant,
* non-enumerable duplicate detection.
*/
public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort {
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
private final NotificationPayloadProtection payloads;
private final int maxRetainedBytes;
public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) {
this(secrets, new SecureRandom(), maxRetainedBytes);
}
AesGcmCallbackPayloadProtection(
SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) {
public AesGcmCallbackPayloadProtection(
SecretMaterialProvider secrets,
NotificationPayloadProtection payloads,
int maxRetainedBytes) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.random = Objects.requireNonNull(random, "random");
this.payloads = Objects.requireNonNull(payloads, "payloads");
if (maxRetainedBytes < 1) {
throw new IllegalArgumentException("maxRetainedBytes");
}
if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) {
// The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a
// 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes
// over it, so a callback of exactly the configured maximum was accepted by every layer above
// and then rejected by a CHECK constraint after the provider had been told it was stored.
// The database check constrains the *ciphertext*, and encryption adds a version byte, a key
// id, a nonce and a GCM tag. Truncating the plaintext to the ciphertext bound produced a
// value larger than it, so a callback of exactly the configured maximum was accepted by
// every layer above and then rejected by a CHECK constraint after the provider had been told
// it was stored.
throw new IllegalArgumentException(
"callback retention of "
+ maxRetainedBytes
@@ -75,8 +74,15 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
*/
public static final int MAX_CIPHERTEXT_BYTES = 65_536;
/** The nonce and GCM tag every encryption adds. */
public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8;
/**
* The most the envelope adds: version, key id, nonce and GCM tag.
*
* <p>Reserved at the largest key id the envelope allows rather than measured against the current
* one, because a rotation to a longer id would otherwise push a body that fit yesterday past the
* column's check constraint.
*/
public static final int ENVELOPE_OVERHEAD_BYTES =
AesGcmNotificationPayloadProtection.MAX_ENVELOPE_OVERHEAD_BYTES;
/** The largest plaintext that still fits the column once encrypted. */
public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES;
@@ -86,22 +92,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
Objects.requireNonNull(rawBody, "rawBody");
byte[] bounded =
rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes);
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
byte[] ciphertext = cipher.doFinal(bounded);
byte[] stored = new byte[nonce.length + ciphertext.length];
System.arraycopy(nonce, 0, stored, 0, nonce.length);
System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length);
return stored;
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("callback payload encryption failed", failure);
}
// Delegated rather than reimplemented so the retained callback and the retained notification
// payload are one format with one reader. The alternative is two envelopes that drift, and the
// one that drifts is always the one nothing reads until an incident.
return payloads.protect(bounded);
}
@Override
@@ -50,6 +50,16 @@ public final class AesGcmNotificationPayloadProtection implements NotificationPa
private static final int TAG_BITS = 128;
private static final int MAX_KEY_ID_BYTES = 255;
/**
* The most this envelope can add to a plaintext.
*
* <p>The header is variable — a key id is one to 255 bytes — so anything that has to guarantee a
* ciphertext fits a fixed column reserves the largest header rather than the current one. A bound
* computed from today's key id stops holding the moment a rotation picks a longer one.
*/
static final int MAX_ENVELOPE_OVERHEAD_BYTES =
2 + MAX_KEY_ID_BYTES + NONCE_BYTES + TAG_BITS / Byte.SIZE;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
@@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
@@ -14,6 +15,15 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks which credential generation is current for each provider profile.
*
* <p>A rotation is a window, not an instant. Activating a new generation supersedes the previous
* one but keeps it resolvable for a bounded drain period, because work planned against the old
* generation is already in flight when the rotation lands: an attempt whose request may already
* have reached the provider cannot simply be failed, and cannot be replayed either. Callers ask for
* the generation their work was planned with — {@code ProviderProfileSnapshot} and {@code
* DeliveryAttemptSnapshot} both carry it — so the window covers exactly that work and nothing else.
* Past the window the old credential stops resolving, because a superseded credential that stays
* usable indefinitely is not a rotation, it is two live credentials.
*
* <p>Two rotations are deliberately <em>not</em> handled here, because treating them as ordinary
* credential swaps would silently lose data or delivery:
*
@@ -31,13 +41,46 @@ public final class ProviderCredentialManager {
private final SecretMaterialProvider secrets;
private final Clock clock;
private final Duration drainWindow;
private final Map<ProviderProfileId, CredentialGeneration> current = new ConcurrentHashMap<>();
private final Map<ProviderProfileId, Map<Long, Draining>> draining = new ConcurrentHashMap<>();
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
/**
* Creates the manager.
*
* @param secrets the key store
* @param clock the clock the drain window is measured against
* @param drainWindow how long a superseded generation keeps serving the work that started on it
*/
public ProviderCredentialManager(
SecretMaterialProvider secrets, Clock clock, Duration drainWindow) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.clock = Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(drainWindow, "drainWindow");
if (drainWindow.isNegative()) {
throw new IllegalArgumentException("drainWindow");
}
this.drainWindow = drainWindow;
}
/**
* The default drain window: long enough for an in-flight attempt, short enough to be a window.
*/
public static final Duration DEFAULT_DRAIN_WINDOW = Duration.ofMinutes(15);
/**
* Creates the manager with the default drain window.
*
* @param secrets the key store
* @param clock the clock the drain window is measured against
*/
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
this(secrets, clock, DEFAULT_DRAIN_WINDOW);
}
/** A superseded generation and the instant it stops being usable. */
private record Draining(CredentialGeneration generation, Instant usableUntil) {}
/**
* Record the generation a profile starts on.
*
@@ -63,10 +106,76 @@ public final class ProviderCredentialManager {
if (existing != null && !generation.supersedes(existing)) {
throw new IllegalArgumentException("generation does not supersede the active one");
}
if (existing != null) {
// Superseded, not deleted. An attempt that was planned against the previous generation
// is already in flight when the rotation lands, and retiring the credential the instant
// the new one arrives fails exactly that work — the requests nobody can replay, because
// the provider may already have acted on them. The window bounds it: an old credential
// that stays usable forever is not a rotation, it is two live credentials.
retire(profileId, existing, activatedAt);
}
return generation.activatedAt(activatedAt);
});
}
private void retire(
ProviderProfileId profileId, CredentialGeneration superseded, Instant supersededAt) {
draining
.computeIfAbsent(profileId, id -> new ConcurrentHashMap<>())
.put(superseded.generation(), new Draining(superseded, supersededAt.plus(drainWindow)));
}
/**
* Credential material for one profile at the generation the work was planned against.
*
* <p>Every adapter resolved {@code activeKey(PROVIDER_CREDENTIAL)} instead: one credential for
* every profile in the deployment, so a leak of one provider account's key was a leak of all of
* them, and a per-profile rotation was not expressible at all. The generation is not a parameter
* a caller invents — {@code ProviderProfileSnapshot.credentialGeneration()} and {@code
* DeliveryAttemptSnapshot.credentialGeneration()} already carry the number the work was planned
* with, which is what makes the drain window mean something rather than being a grace period
* nobody claims.
*
* @param profileId the profile the work belongs to
* @param generation the generation the work was planned against
* @return the material
* @throws IllegalStateException if the profile has no active generation, or the requested one is
* neither current nor still inside its drain window
*/
public byte[] materialFor(ProviderProfileId profileId, long generation) {
Objects.requireNonNull(profileId, "profileId");
CredentialGeneration active = current.get(profileId);
if (active == null) {
throw new IllegalStateException(
"provider profile " + profileId.value() + " has no activated credential generation");
}
if (active.generation() == generation) {
return material(active).material();
}
Draining retired = draining.getOrDefault(profileId, Map.of()).get(generation);
if (retired == null) {
throw new IllegalStateException(
"provider profile "
+ profileId.value()
+ " has no credential generation "
+ generation
+ "; the active generation is "
+ active.generation());
}
if (!clock.instant().isBefore(retired.usableUntil())) {
// Dropped rather than served: past the window, work still asking for the old generation is
// work that has been stuck long enough that using a retired credential is the larger risk.
draining.getOrDefault(profileId, Map.of()).remove(generation);
throw new IllegalStateException(
"credential generation "
+ generation
+ " for provider profile "
+ profileId.value()
+ " finished draining; it is no longer usable");
}
return material(retired.generation()).material();
}
/** Current generation of a profile. */
public Optional<CredentialGeneration> current(ProviderProfileId profileId) {
return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId")));
@@ -10,7 +10,6 @@ import java.util.Map;
* renderer per engine is how two implementations end up computing different digests for the same
* template, which silently breaks the retry equality the digest exists to prove.
*/
@FunctionalInterface
public interface NotificationTemplateEngine {
/**
@@ -27,12 +26,17 @@ public interface NotificationTemplateEngine {
* <p>The mode is required rather than inferred: the same template text is safe in a text part and
* dangerous in an HTML one, and only the caller knows which it is filling.
*
* <p>Abstract, not a {@code default} that forwards to the single-argument overload. It was that
* default, and one of the two engines never overrode it — so selecting that engine silently
* dropped every slot to the unescaped path: a subject could carry CR/LF, a deep link could carry
* a {@code javascript:} scheme, and plain text had its ampersands HTML-escaped on the wire. A
* default that discards its own argument is not a fallback; it is the rule not applying, and the
* engine that skipped it looked complete because the interface compiled.
*
* @param mode what the rendered value will become
* @param source the template text
* @param variables the values to substitute
* @return the rendered slot
*/
default String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
return render(source, variables);
}
String render(TemplateSlotMode mode, String source, Map<String, Object> variables);
}
@@ -61,79 +61,8 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi
/** Escapes one substituted value for its destination. */
private static String escape(TemplateSlotMode mode, String value) {
return switch (mode) {
case TEXT -> value;
case SUBJECT -> requireSingleLine(value);
case HTML_TEXT -> escapeHtml(value);
case URI -> requireAllowedScheme(value);
};
// The rules moved to TemplateSlotPolicy so the other engine could reach them. They were private
// here, which is why that engine had none.
return TemplateSlotPolicy.escape(mode, value);
}
/**
* Refuses a value that would split a header.
*
* <p>A carriage return or newline in a subject is header injection: everything after it is read
* as a new header by the receiving agent.
*/
private static String requireSingleLine(String value) {
for (int index = 0; index < value.length(); index++) {
if (value.charAt(index) < 0x20) {
throw new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED,
FailureCategory.TEMPLATE_FAILURE));
}
}
return value;
}
/**
* Escapes for HTML text and attribute content.
*
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
* start an event handler — {@code " onerror="} needs no angle bracket at all.
*/
private static String escapeHtml(String value) {
StringBuilder escaped = new StringBuilder(value.length() + 16);
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '&' -> escaped.append("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
default -> escaped.append(character);
}
}
return escaped.toString();
}
/**
* Allows only schemes a notification may legitimately link to.
*
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
*/
private static String requireAllowedScheme(String value) {
String normalized = value.trim().toLowerCase(java.util.Locale.ROOT);
boolean allowed =
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
if (!allowed) {
throw new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
}
return value;
}
/**
* The schemes a rendered link may use.
*
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
* in a notification is followed by a person who has no way to check it.
*/
private static final java.util.Set<String> ALLOWED_URI_SCHEMES =
java.util.Set.of("https", "caskeleton");
}
@@ -0,0 +1,129 @@
package dev.caskeleton.adapter.outbound.notification.platform.template;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
import java.util.Locale;
import java.util.Set;
/**
* What each slot mode means, in one place both engines use.
*
* <p>These rules lived as private helpers inside {@code PlaceholderTemplateEngine}, and the other
* engine had no equivalent — it did not override the mode-aware render at all, so selecting it
* dropped every slot to the unescaped path. One engine enforced the policy and the other did not
* have access to it.
*
* <p>Nothing here is engine-specific: a subject may not carry a control character whoever produced
* it, and a deep link may not use {@code javascript:} whoever rendered it.
*/
public final class TemplateSlotPolicy {
/**
* The schemes a rendered link may use.
*
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
* in a notification is followed by a person who has no way to check it.
*/
private static final Set<String> ALLOWED_URI_SCHEMES = Set.of("https", "caskeleton");
private TemplateSlotPolicy() {}
/**
* Escapes one substituted value for its destination.
*
* @param mode the slot being filled
* @param value the value to escape
* @return the escaped value
*/
public static String escape(TemplateSlotMode mode, String value) {
return switch (mode) {
case TEXT -> value;
case SUBJECT -> requireSingleLine(value);
case HTML_TEXT -> escapeHtml(value);
case URI -> requireAllowedScheme(value);
};
}
/**
* Checks a whole rendered slot, for an engine that substitutes internally.
*
* <p>An engine that does its own substitution cannot escape per value, so the guarantee is
* applied to what it produced. For SUBJECT and URI that is the stronger statement: no control
* character anywhere in the subject, and the finished link uses an allowed scheme. Escaping modes
* are the engine's own job — asking it to render HTML and then escaping the result would escape
* the operator's markup too.
*
* @param mode the slot that was filled
* @param rendered the engine's output
* @return the output, unchanged when it satisfies the slot
*/
public static String verifyRendered(TemplateSlotMode mode, String rendered) {
return switch (mode) {
case TEXT, HTML_TEXT -> rendered;
case SUBJECT -> requireSingleLine(rendered);
case URI -> requireAllowedScheme(rendered);
};
}
/**
* Refuses a value that would split a header.
*
* <p>A carriage return or newline in a subject is header injection: everything after it is read
* as a new header by the receiving agent.
*/
private static String requireSingleLine(String value) {
for (int index = 0; index < value.length(); index++) {
if (value.charAt(index) < 0x20) {
throw refuse();
}
}
return value;
}
/**
* Escapes for HTML text and attribute content.
*
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
* start an event handler — {@code " onerror="} needs no angle bracket at all.
*/
private static String escapeHtml(String value) {
StringBuilder escaped = new StringBuilder(value.length() + 16);
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '&' -> escaped.append("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
default -> escaped.append(character);
}
}
return escaped.toString();
}
/**
* Allows only schemes a notification may legitimately link to.
*
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
*/
private static String requireAllowedScheme(String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
boolean allowed =
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
if (!allowed) {
throw refuse();
}
return value;
}
private static TemplateRenderingException refuse() {
return new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
}
}
@@ -44,6 +44,12 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
private final TemplateEngine engine;
/** Whether the constructor-supplied engine is the HTML one. */
private final boolean htmlMode;
/** The text-mode engine, for every slot that is not HTML. */
private final TemplateEngine textEngine = engineFor(TemplateMode.TEXT);
/** HTML-escaping engine, which is the safe default for email bodies. */
public ThymeleafStringTemplateEngine() {
this(TemplateMode.HTML);
@@ -54,12 +60,25 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
*/
public ThymeleafStringTemplateEngine(TemplateMode mode) {
Objects.requireNonNull(mode, "mode");
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(mode);
resolver.setCacheable(false);
TemplateEngine created = new TemplateEngine();
created.setTemplateResolver(resolver);
this.engine = created;
this.htmlMode = mode == TemplateMode.HTML;
this.engine = engineFor(mode);
}
@Override
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
Objects.requireNonNull(mode, "mode");
// One engine per Thymeleaf template mode, chosen by what the slot is.
//
// This class used to implement only the mode-less overload and inherit a `default` that threw
// the mode away, so every slot rendered under TemplateMode.HTML: a subject could carry CR/LF, a
// deep link could carry `javascript:`, and plain text — an SMS body — had its `&` turned into
// `&amp;` on the wire. The interface compiled, so nothing said the policy was not applying.
//
// HTML_TEXT keeps the HTML engine, which is what escapes substituted values. Everything else
// renders as text and is then checked: Thymeleaf substitutes internally, so a per-value escape
// is not available, and verifying the finished slot is the stronger statement anyway.
String rendered = engineFor(mode).process(source, contextFor(source, variables));
return TemplateSlotPolicy.verifyRendered(mode, rendered);
}
@Override
@@ -68,10 +87,8 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
Objects.requireNonNull(variables, "variables");
requireEveryReferencedVariable(source, variables);
Context context = new Context();
variables.forEach(context::setVariable);
try {
return engine.process(source, context);
return engine.process(source, contextFor(source, variables));
} catch (RuntimeException failure) {
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
// template expression contains the variable it failed on — which for this platform is a
@@ -105,4 +122,41 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
}
}
}
/** The engine whose template mode matches the slot. */
private TemplateEngine engineFor(TemplateSlotMode mode) {
return mode == TemplateSlotMode.HTML_TEXT ? htmlEngine() : textEngine;
}
/**
* The HTML engine.
*
* <p>The constructor-supplied engine when this instance was built for HTML, and a dedicated one
* otherwise — a deployment that constructed the text engine still has HTML slots to render, and
* rendering them as text would emit an operator's markup unescaped.
*/
private TemplateEngine htmlEngine() {
return htmlMode ? engine : HTML_ENGINE;
}
private static final TemplateEngine HTML_ENGINE = engineFor(TemplateMode.HTML);
private static TemplateEngine engineFor(TemplateMode mode) {
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(mode);
resolver.setCacheable(false);
TemplateEngine created = new TemplateEngine();
created.setTemplateResolver(resolver);
return created;
}
/** Builds the variable context, refusing an absent variable rather than rendering it away. */
private Context contextFor(String source, Map<String, Object> variables) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(variables, "variables");
requireEveryReferencedVariable(source, variables);
Context context = new Context();
variables.forEach(context::setVariable);
return context;
}
}
@@ -0,0 +1,173 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Which keys a deployment is actually asked for.
*
* <p>Startup demanded all of them, always, so an SMTP-only platform with callbacks switched off had
* to provision and rotate a Web Push signing key, a provider credential and two callback keys that
* nothing in that configuration could reach. Keys that exist and are never used are the ones nobody
* notices leaking, and requiring them made the four purposes every mode genuinely needs
* indistinguishable from the four that follow a capability.
*/
class NotificationSecretRequirementsTest {
@Test
@DisplayName("the accept path's keys are required in every configuration")
void theAcceptPathKeysAreAlwaysRequired() {
var required = NotificationSecretRequirements.requiredBy(settings(false, Map.of()));
assertThat(required)
.as(
"contact points are protected, variables are encrypted at rest and provider request "
+ "ids are hashed whenever the platform runs, providers or no providers")
.containsExactlyInAnyOrder(
SecretPurpose.CONTACT_ENCRYPTION,
SecretPurpose.CONTACT_LOOKUP_HMAC,
SecretPurpose.PAYLOAD_ENCRYPTION,
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
}
@Test
@DisplayName("an SMTP-only platform is not asked for a provider credential")
void anSmtpOnlyPlatformIsNotAskedForAProviderCredential() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
assertThat(required)
.as("SMTP authenticates through spring.mail.*, so this platform holds no SMTP credential")
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("a disabled profile does not demand its family's keys")
void aDisabledProfileDoesNotDemandItsKeys() {
var required =
NotificationSecretRequirements.requiredBy(
settings(false, Map.of("push", disabled(webPush()))));
assertThat(required)
.doesNotContain(SecretPurpose.VAPID_SIGNING)
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("an enabled Web Push profile demands a VAPID key and a provider credential")
void anEnabledWebPushProfileDemandsItsKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("push", webPush())));
assertThat(required).contains(SecretPurpose.VAPID_SIGNING, SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("callbacks switched off do not demand the callback keys")
void callbacksOffDoNotDemandTheCallbackKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
assertThat(required)
.doesNotContain(SecretPurpose.CALLBACK_SIGNING)
.doesNotContain(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
@Test
@DisplayName("callbacks switched on demand both callback keys")
void callbacksOnDemandBothCallbackKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(true, Map.of("mail", smtp())));
assertThat(required)
.as("verification and dedupe both run on the first callback that arrives")
.contains(SecretPurpose.CALLBACK_SIGNING, SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
@Test
@DisplayName("a profile naming its own signing key ref demands the signing purpose")
void aProfileNamingASigningRefDemandsTheSigningPurpose() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("sms", twilio())));
assertThat(required)
.as("a profile that names a signing key intends to verify signatures with it")
.contains(SecretPurpose.CALLBACK_SIGNING);
}
private static NotificationPlatformSettings settings(
boolean callbacksEnabled, Map<String, NotificationPlatformSettings.Provider> providers) {
return new NotificationPlatformSettings(
true,
NotificationPlatformMode.SERVING,
null,
new NotificationPlatformSettings.Callbacks(callbacksEnabled, 1024, Duration.ofMinutes(5)),
providers);
}
private static NotificationPlatformSettings.Provider smtp() {
return new NotificationPlatformSettings.Provider(
"SMTP",
true,
true,
"PRODUCTION",
"smtp-main",
null,
null,
null,
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider webPush() {
return new NotificationPlatformSettings.Provider(
"WEB_PUSH",
true,
true,
"PRODUCTION",
"webpush-main",
null,
"BPublicKey",
null,
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider twilio() {
return new NotificationPlatformSettings.Provider(
"TWILIO",
true,
true,
"PRODUCTION",
"twilio-main",
null,
null,
"twilio-callback-2026-08",
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider disabled(
NotificationPlatformSettings.Provider provider) {
return new NotificationPlatformSettings.Provider(
provider.type(),
false,
provider.primaryForChannel(),
provider.environment(),
provider.credentialProfile(),
provider.topic(),
provider.vapidPublicKey(),
provider.callbackSigningSecretRef(),
provider.timeout(),
provider.maxConcurrency(),
provider.ratePerSecond());
}
}
@@ -301,5 +301,24 @@ class LeaseRecoveryServiceTest {
transitions.add(Map.entry(id, state));
return null;
}
@Override
public Optional<RecipientDeliveryRecord> saveHeldBy(
RecipientDeliveryRecord record,
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
return Optional.of(save(record));
}
@Override
public Optional<RecipientDeliveryRecord> transitionHeldBy(
RecipientDeliveryId id,
RecipientDeliveryState state,
Optional<Instant> nextDispatchAt,
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
// This fake belongs to lease *recovery*, which runs for jobs whose holder is gone; the fenced
// variants are the dispatch path's and are not exercised here.
transition(id, state, nextDispatchAt);
return Optional.empty();
}
}
}
@@ -0,0 +1,126 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties;
import dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription;
import java.net.URI;
import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The endpoint guard is reached from the places that need it.
*
* <p>{@code EndpointRoutabilityTest} already proves {@code requireExternallyRoutable} rejects the
* metadata service, RFC 1918, link-local and the rest. It proved that for months while the function
* had no caller: both sites it was written for — a webhook target and an SES endpoint — kept
* calling {@code requireSecureOrLoopback}, which reads the scheme and nothing else. A green test on
* a control nothing invokes is the shape this repository keeps finding, and testing the helper
* again would not have caught it.
*
* <p>So these assertions go through the constructors an operator and a caller actually reach.
*
* <p>The loopback allowance is part of that. It was a constant {@code true} at both call sites,
* which left a client-supplied target naming {@code localhost} accepted — the residue this finding
* carried until the allowance became {@code trusted}, the flag the record already used to decide
* whether the same target may inherit a platform signing key.
*/
class EndpointGuardCallSiteTest {
private static final URI METADATA = URI.create("https://169.254.169.254/latest/meta-data/");
private static final URI PRIVATE_NETWORK = URI.create("https://10.0.0.5/hook");
@Test
@DisplayName("a webhook target on the cloud metadata service is refused")
void aWebhookTargetOnTheMetadataServiceIsRefused() {
assertThatThrownBy(() -> new WebhookSubscription("sub-1", METADATA, false, Optional.empty()))
.as("a client-supplied target that fetches instance credentials is the SSRF this guards")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a webhook target inside the deployment's own network is refused")
void aWebhookTargetOnAPrivateAddressIsRefused() {
assertThatThrownBy(
() -> new WebhookSubscription("sub-1", PRIVATE_NETWORK, true, Optional.empty()))
.as("trusted decides credential inheritance, not whether an internal address is reachable")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a webhook target carrying userinfo is refused")
void aWebhookTargetWithUserinfoIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1",
URI.create("https://evil.example.com@127.0.0.1/hook"),
true,
Optional.empty()))
.as("the text before '@' is what a log reader takes for the host")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("an SES endpoint inside the deployment's own network is refused")
void anSesEndpointOnAPrivateAddressIsRefused() {
assertThatThrownBy(
() ->
new SesProviderProperties(
PRIVATE_NETWORK,
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a client-supplied webhook target on the loopback interface is refused")
void aDynamicWebhookTargetOnLoopbackIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1", URI.create("https://localhost/hook"), false, Optional.empty()))
.as(
"the loopback allowance was a constant `true`, so the one case the guard could not "
+ "cover was a user-supplied target that simply named localhost")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a client-supplied webhook target on 127.0.0.1 is refused")
void aDynamicWebhookTargetOnTheLoopbackAddressIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1", URI.create("http://127.0.0.1:8080/hook"), false, Optional.empty()))
.as("naming the address rather than the host must not be the way around the refusal")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("loopback stays available, because local and contract profiles address it")
void loopbackIsStillAccepted() {
assertThatCode(
() ->
new WebhookSubscription(
"sub-1", URI.create("http://127.0.0.1:8080/hook"), true, Optional.empty()))
.as(
"this is the case the allowance exists for, and it is the reason the refusal above "
+ "has to be conditional rather than absolute")
.doesNotThrowAnyException();
assertThatCode(
() ->
new SesProviderProperties(
URI.create("http://localhost:4566"),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3)))
.doesNotThrowAnyException();
}
}
@@ -1,25 +1,49 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition;
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.content.EmailOptions;
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException;
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -39,21 +63,8 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
@Override
protected NotificationProviderAdapter adapter() {
var properties =
new SesProviderProperties(
harness.baseUri(),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3));
return new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
new SesFailureClassifier(),
protector,
SecurityFixtures.keys(),
"AKIAEXAMPLE",
CLOCK);
return adapter(
SecurityFixtures.credentials("ses-primary"), new UnconfiguredAttachmentResolver());
}
@Override
@@ -101,4 +112,241 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent();
assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL);
}
@Test
void contentWithNoAttachmentStaysOnTheSimpleShape() {
harness.respondWith(200, successBody(), Map.of());
adapter().submit(submission()).toCompletableFuture().join();
var content = requestContent(0);
assertThat(content.get("Simple")).isNotNull();
assertThat(content.get("Raw")).isNull();
}
@Test
void aDeclaredAttachmentIsCarriedAsRawMimeContent() {
byte[] bytes = documentBytes();
harness.respondWith(200, successBody(), Map.of());
var result =
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(bytes))
.submit(submissionWithAttachment(bytes))
.toCompletableFuture()
.join();
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
var content = requestContent(0);
assertThat(content.get("Simple"))
.as("Simple content has no MIME part, so the declared attachment was simply not sent")
.isNull();
String mime =
new String(
Base64.getDecoder().decode(content.get("Raw").get("Data").asString()),
StandardCharsets.UTF_8);
assertThat(mime).contains("Contract subject");
assertThat(mime).contains("invoice.pdf");
assertThat(mime)
.as("the document itself, base64 encoded as a binary part rather than merely named")
.contains(Base64.getEncoder().encodeToString(bytes));
assertThat(harness.received().get(0).header("Authorization").orElseThrow())
.startsWith("AWS4-HMAC-SHA256");
}
@Test
void anAttachmentThatCannotBeResolvedIsRefusedBeforeAnythingIsSent() {
byte[] bytes = documentBytes();
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() -> adapter().submit(submissionWithAttachment(bytes)).toCompletableFuture().join())
.as("a mail that silently loses its attachment is worse than one that is not sent")
.isInstanceOf(AttachmentUnavailableException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void attachmentBytesThatAreNotTheApprovedBytesAreRefusedBeforeAnythingIsSent() {
byte[] approved = "invoice-bytes".getBytes(StandardCharsets.UTF_8);
byte[] substituted = "1nvo1ce-bytes".getBytes(StandardCharsets.UTF_8);
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(substituted))
.submit(submissionWithAttachment(approved))
.toCompletableFuture()
.join())
.as("same size, different bytes: only the digest separates them")
.isInstanceOf(AttachmentIntegrityException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void aMessageLargerThanSesAcceptsIsRefusedBeforeItIsSigned() {
// Base64 transfer encoding adds a third, so this clears the limit as bytes and exceeds it as a
// message — which is exactly the case a check against the declared attachment size misses.
byte[] oversized = new byte[8 * 1_000_000];
assertThat(oversized.length).isLessThan((int) SesRequestMapper.MAX_MESSAGE_BYTES);
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(oversized))
.submit(submissionWithAttachment(oversized))
.toCompletableFuture()
.join())
.isInstanceOf(ProviderPayloadLimitException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void twoProfilesAreSignedWithTheirOwnCredential() {
// One submission, sent twice: the SES request body is derived from content and recipient alone,
// so anything that differs between the two signatures is the credential.
var submission = submission();
harness.respondWith(200, successBody(), Map.of());
adapter(
SecurityFixtures.credentials("ses-primary", "cred-1"),
new UnconfiguredAttachmentResolver())
.submit(submission)
.toCompletableFuture()
.join();
harness.respondWith(200, successBody(), Map.of());
adapter(
SecurityFixtures.credentials("ses-primary", "cred-2"),
new UnconfiguredAttachmentResolver())
.submit(submission)
.toCompletableFuture()
.join();
assertThat(harness.received().get(1).header("Authorization"))
.as(
"every profile signed with the platform's one current provider credential, so a leak "
+ "of one SES account's key was a leak of every provider account")
.isNotEqualTo(harness.received().get(0).header("Authorization"));
}
@Test
void aProfileWithNoActivatedCredentialIsRefusedBeforeTheRequest() {
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(
SecurityFixtures.credentials("some-other-profile"),
new UnconfiguredAttachmentResolver())
.submit(submission())
.toCompletableFuture()
.join())
.isInstanceOf(IllegalStateException.class);
assertThat(harness.received()).isEmpty();
}
private SesNotificationProviderAdapter adapter(
ProviderCredentialManager credentials, AttachmentResolver attachments) {
var properties =
new SesProviderProperties(
harness.baseUri(),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3));
return new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(
properties,
new AwsSignatureV4Signer(),
new SmtpMimeMessageFactory(Session.getInstance(new Properties()))),
new AttachmentIntegrityGuard(attachments),
new SesFailureClassifier(),
protector,
credentials,
"AKIAEXAMPLE",
CLOCK);
}
/** The {@code Content} object of a recorded request. */
private tools.jackson.databind.JsonNode requestContent(int index) {
return NotificationJsonMapper.mapper()
.readTree(harness.received().get(index).bodyAsString())
.get("Content");
}
/**
* A resolver that hands back exactly these bytes and describes them honestly.
*
* <p>The digest is computed from what is returned rather than copied from the reference, so a
* resolver that returns something other than the approved bytes is caught by the integrity guard
* instead of being waved through by a fixture that agrees with itself.
*/
private static AttachmentResolver resolverReturning(byte[] bytes) {
return (reference, context) ->
new ResolvedAttachment(
new ByteArrayInputStream(bytes),
bytes.length,
digestOf(bytes),
reference.contentType(),
reference.displayName());
}
private ProviderSubmission submissionWithAttachment(byte[] approvedBytes) {
return ProviderFixtures.submission(
ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL),
Channel.EMAIL,
new EmailContent(
"Contract subject",
"Contract body",
Optional.empty(),
List.of(
new AttachmentRef(
"storage://bucket/invoice.pdf",
"invoice.pdf",
"application/pdf",
approvedBytes.length,
digestOf(approvedBytes),
AttachmentDisposition.ATTACHMENT)),
EmailOptions.DEFAULT),
protector,
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
Optional.of(CLOCK.instant().plus(Duration.ofHours(1))));
}
/**
* A short binary document.
*
* <p>Binary rather than text on purpose: MIME encodes an ASCII part as {@code 7bit} and leaves it
* legible, which would let the assertion pass on a part that was never really encoded at all.
*/
private static byte[] documentBytes() {
return new byte[] {
'%',
'P',
'D',
'F',
'-',
'1',
'.',
'7',
'\n',
(byte) 0x80,
(byte) 0xC3,
0x00,
0x01,
0x02,
(byte) 0xFF
};
}
private static String digestOf(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required by every supported JRE", unavailable);
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.Properties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* That an attached document arrives with its bytes in it.
*
* <p>The factory handed JavaMail the resolver's stream directly. JavaMail reads an attachment twice
* — once to choose the part's transfer encoding, once to write the part — and the second read of an
* already drained stream returns nothing, so the message went out announcing a filename and
* carrying no content, and the attempt was recorded as accepted. Nothing noticed because every test
* asserted on the outcome of the send rather than on what was sent.
*
* <p>Asserted on the serialised message, because that is the only place the defect was visible: the
* part existed, its headers were right, and its body was empty.
*/
class SmtpAttachmentBodyTest {
private static final byte[] DOCUMENT = "invoice-body-bytes".getBytes(StandardCharsets.UTF_8);
private final ContactPointProtector protector =
new AesGcmContactPointProtector(SecurityFixtures.keys());
private final SmtpMimeMessageFactory factory =
new SmtpMimeMessageFactory(Session.getInstance(new Properties()));
@Test
@DisplayName("an attached document is written into the message, not just named by it")
void anAttachedDocumentCarriesItsBytes() throws Exception {
MimeMessage message =
factory.create(
submission(),
"recipient@example.test",
"sender@example.test",
List.of(attachment(DOCUMENT, DOCUMENT.length)));
assertThat(attachmentBytesOf(message))
.as("the part announced a filename and carried nothing")
.isEqualTo(DOCUMENT);
}
@Test
@DisplayName("content that is not the size the guard approved is refused")
void contentThatIsNotTheApprovedSizeIsRefused() {
// The integrity guard pins a size against the reference before the stream is handed over, so a
// stream that turns out to be a different length is not the document that was approved —
// whatever digest travelled with it.
assertThatThrownBy(
() ->
factory.create(
submission(),
"recipient@example.test",
"sender@example.test",
List.of(attachment(DOCUMENT, DOCUMENT.length + 1))))
.isInstanceOf(RuntimeException.class);
}
private static ResolvedAttachment attachment(byte[] content, long declaredSize) {
return new ResolvedAttachment(
new ByteArrayInputStream(content),
declaredSize,
"sha-256:not-checked-here",
"application/pdf",
"invoice.pdf");
}
/**
* The bytes of the attachment part, read back the way a receiving client reads them.
*
* <p>Read from the serialised message rather than from the part object, because the defect was
* exactly that the object described a part the serialisation could not fill: assertions taken
* before {@code writeTo} saw an attachment that was about to be written empty.
*/
private static byte[] attachmentBytesOf(MimeMessage message) throws Exception {
ByteArrayOutputStream wire = new ByteArrayOutputStream();
message.writeTo(wire);
MimeMessage received =
new MimeMessage(
Session.getInstance(new Properties()),
new java.io.ByteArrayInputStream(wire.toByteArray()));
jakarta.mail.internet.MimeMultipart parts =
(jakarta.mail.internet.MimeMultipart) received.getContent();
for (int index = 0; index < parts.getCount(); index++) {
jakarta.mail.BodyPart part = parts.getBodyPart(index);
if ("invoice.pdf".equals(part.getFileName())) {
return part.getInputStream().readAllBytes();
}
}
throw new AssertionError("the message carries no attachment part at all");
}
private ProviderSubmission submission() {
return ProviderFixtures.submission(
ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL),
Channel.EMAIL,
ProviderFixtures.email(),
protector,
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
Optional.empty());
}
}
@@ -29,6 +29,7 @@ class TwilioCallbackAndProjectionTest {
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
"cb-1",
java.time.Duration.ofSeconds(3),
java.time.Duration.ofHours(12));
@@ -54,6 +55,47 @@ class TwilioCallbackAndProjectionTest {
assertThat(events.get(0).providerRequestId()).contains("SM1");
}
@Test
void twoProfilesVerifyWithTheirOwnSigningKey() {
Map<String, String> parameters =
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
var request = callback(parameters, signature(parameters));
// Signed with cb-1, presented to a profile whose reference names cb-2. Verification used the
// platform's one current callback signing key, so every Twilio profile shared one secret and a
// subaccount whose token leaked could forge status callbacks for any other.
assertThat(adapterWithSigningRef("cb-2").verify(request).valid()).isFalse();
assertThat(adapterWithSigningRef("cb-1").verify(request).valid()).isTrue();
}
@Test
void aSigningRefNamingAKeyOfAnotherPurposeIsRefusedBeforeAVerdict() {
Map<String, String> parameters =
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
var request = callback(parameters, signature(parameters));
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> adapterWithSigningRef("enc-1").verify(request))
.as("a misfiled reference is a configuration fault, not a signature that never matches")
.isInstanceOf(IllegalStateException.class);
}
private TwilioCallbackAdapter adapterWithSigningRef(String signingKeyRef) {
return new TwilioCallbackAdapter(
new TwilioSignatureValidator(),
new TwilioStatusNormalizer(),
new TwilioProviderProperties(
java.net.URI.create("https://api.twilio.example"),
"AC123",
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
signingKeyRef,
java.time.Duration.ofSeconds(3),
java.time.Duration.ofHours(12)),
SecurityFixtures.keys());
}
@Test
void invalidSignatureIsRejected() {
Map<String, String> parameters =
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
@@ -39,6 +39,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
Optional.of("MG123"),
Optional.empty(),
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
}
@@ -50,7 +51,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
new TwilioRequestMapper(properties()),
new TwilioFailureClassifier(),
protector,
SecurityFixtures.keys());
SecurityFixtures.credentials("twilio-primary"));
}
@Override
@@ -1,23 +1,37 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.content.InAppContent;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -37,14 +51,52 @@ class WebhookNotificationProviderAdapterTest {
@Test
void dynamicTargetNeverInheritsTrustedCredentials() {
harness.respondWith(200, "{}", Map.of());
// Both gateway arguments used to be the same instance, so "the request carried no
// Authorization header" was a property of the test's own wiring rather than of the adapter:
// there was nothing in the graph that could have added one, and the assertion would have held
// just as well for a trusted subscription. What actually decides credential inheritance is
// which of the two gateways the adapter hands the request to, and that is only observable when
// they are distinguishable.
//
// The target is a routable literal rather than the local harness because a client-supplied
// target may no longer name the loopback interface (NTF-012). A literal address also keeps the
// constructor's resolution check off DNS.
RecordingGateway trusted = new RecordingGateway();
RecordingGateway dynamic = new RecordingGateway();
WebhookSubscription subscription =
new WebhookSubscription("sub-2", ROUTABLE_TARGET, false, Optional.empty());
adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join();
adapter(trusted, dynamic, submission -> subscription)
.submit(submission())
.toCompletableFuture()
.join();
var recorded = harness.received().get(0);
assertThat(recorded.header("Authorization")).isEmpty();
assertThat(recorded.header("Cookie")).isEmpty();
assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty();
assertThat(trusted.exchanged)
.as("a client-supplied target must not reach the gateway that carries platform credentials")
.isEmpty();
assertThat(dynamic.exchanged).hasSize(1);
assertThat(dynamic.exchanged.get(0).headers())
.doesNotContainKeys("authorization", "cookie", WebhookSignatureStrategy.SIGNATURE_HEADER);
}
@Test
void trustedTargetReachesTheCredentialedGateway() {
// The counterpart, so the assertion above cannot pass by the adapter never reaching either
// gateway, and so the routing rule is watched working in both directions.
RecordingGateway trusted = new RecordingGateway();
RecordingGateway dynamic = new RecordingGateway();
WebhookSubscription subscription =
new WebhookSubscription("sub-1", ROUTABLE_TARGET, true, Optional.of("cb-1"));
adapter(trusted, dynamic, submission -> subscription)
.submit(submission())
.toCompletableFuture()
.join();
assertThat(dynamic.exchanged).isEmpty();
assertThat(trusted.exchanged).hasSize(1);
assertThat(trusted.exchanged.get(0).headers())
.containsKey(WebhookSignatureStrategy.SIGNATURE_HEADER);
}
@Test
@@ -78,33 +130,160 @@ class WebhookNotificationProviderAdapterTest {
assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000);
}
@Test
void twoSubscriptionsWithDifferentKeyRefsAreSignedDifferently() {
// One submission for both deliveries: the attempt id is part of the body, so two submissions
// would differ in what was signed and the signatures would differ whatever key was used.
var submission = submission();
harness.respondWith(200, "{}", Map.of());
adapter(trustedSubscription("cb-1")).submit(submission).toCompletableFuture().join();
harness.respondWith(200, "{}", Map.of());
adapter(trustedSubscription("cb-2")).submit(submission).toCompletableFuture().join();
var first = harness.received().get(0).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
var second = harness.received().get(1).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
assertThat(first).isPresent();
assertThat(second)
.as(
"signingKeyRef only decided whether to sign; the signature came from one platform key, "
+ "so every trusted receiver could verify and forge every other receiver's webhook")
.isPresent()
.isNotEqualTo(first);
}
@Test
void aKeyRefThatIsNotACallbackSigningKeyIsRefusedBeforeTheRequest() {
assertThatThrownBy(
() ->
adapter(trustedSubscription("cred-1"))
.submit(submission())
.toCompletableFuture()
.join())
.isInstanceOf(IllegalStateException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void aBodyOverTheDeclaredCeilingIsRefusedBeforeTheRequest() {
assertThatThrownBy(
() ->
adapter(trustedSubscription("cb-1"))
.submit(oversizedSubmission())
.toCompletableFuture()
.join())
.isInstanceOf(ProviderPayloadLimitException.class);
assertThat(harness.received())
.as("the capability declared a ceiling and nothing measured the bytes against it")
.isEmpty();
}
/**
* A target outside every range the endpoint guard refuses, written as a literal.
*
* <p>TEST-NET-3, which is reserved for documentation and routes nowhere — and being a literal, it
* is never looked up, so the guard's resolution step does not make these tests depend on DNS.
*/
private static final URI ROUTABLE_TARGET = URI.create("https://203.0.113.10/hook");
/** Records what it was asked to send and answers 200, so nothing is dialled. */
private static final class RecordingGateway implements NotificationHttpGateway {
private final List<NotificationHttpRequest> exchanged = new ArrayList<>();
@Override
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
exchanged.add(request);
return new NotificationHttpResponse(
200, Map.of(), "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
}
private WebhookNotificationProviderAdapter adapter(
NotificationHttpGateway trustedGateway,
NotificationHttpGateway dynamicGateway,
Function<ProviderSubmission, WebhookSubscription> subscriptions) {
return new WebhookNotificationProviderAdapter(
trustedGateway,
dynamicGateway,
new WebhookSignatureStrategy(),
keys(),
subscriptions,
Duration.ofSeconds(3),
CLOCK);
}
private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) {
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
return new WebhookNotificationProviderAdapter(
gateway,
gateway,
new WebhookSignatureStrategy(),
SecurityFixtures.keys(),
keys(),
submission -> subscription,
Duration.ofSeconds(3),
CLOCK);
}
private WebhookSubscription trustedSubscription() {
return new WebhookSubscription(
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
/**
* Two callback signing keys, so a per-subscription reference has something to distinguish.
*
* <p>{@code cred-1} is present as well: a reference naming a key issued for another purpose is a
* configuration fault this adapter has to catch rather than sign with.
*/
private static SecretMaterialProvider keys() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.CALLBACK_SIGNING,
new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33)),
SecretPurpose.CONTACT_ENCRYPTION,
new SecretKeyMaterial("enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11)),
SecretPurpose.CONTACT_LOOKUP_HMAC,
new SecretKeyMaterial("mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22))),
Map.of(
"cb-2",
new SecretKeyMaterial("cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34)),
"cred-1",
new SecretKeyMaterial(
"cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
}
private WebhookSubscription dynamicSubscription() {
private static byte[] filled(byte value) {
byte[] material = new byte[32];
java.util.Arrays.fill(material, value);
return material;
}
private WebhookSubscription trustedSubscription() {
return trustedSubscription("cb-1");
}
private WebhookSubscription trustedSubscription(String signingKeyRef) {
return new WebhookSubscription(
"sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty());
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of(signingKeyRef));
}
private ProviderSubmission submission() {
return submission(ProviderFixtures.webhook());
}
private ProviderSubmission oversizedSubmission() {
return submission(
new InAppContent(
"Order shipped",
"x".repeat((int) WebhookNotificationProviderAdapter.MAX_BODY_BYTES + 1),
Optional.empty(),
java.util.List.of(),
"order"));
}
private ProviderSubmission submission(InAppContent content) {
return ProviderFixtures.submission(
ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK),
Channel.WEBHOOK,
ProviderFixtures.webhook(),
content,
protector,
new InAppRecipientRef("user-1"),
Optional.empty());
@@ -49,7 +49,7 @@ class CallbackPayloadBoundTest {
() ->
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES))
.as("this is exactly the configuration that produced 65,564 bytes of ciphertext")
.isInstanceOf(IllegalArgumentException.class)
@@ -62,7 +62,7 @@ class CallbackPayloadBoundTest {
AesGcmCallbackPayloadProtection protection =
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
byte[] stored =
@@ -78,7 +78,7 @@ class CallbackPayloadBoundTest {
AesGcmCallbackPayloadProtection protection =
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
byte[] stored =
@@ -96,8 +96,13 @@ class CallbackPayloadBoundTest {
() ->
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES))
.doesNotThrowAnyException();
}
private static AesGcmNotificationPayloadProtection payloads() {
return new AesGcmNotificationPayloadProtection(
SecurityFixtures.keys(), new java.security.SecureRandom());
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What a retained callback is worth after the payload key rotates.
*
* <p>The retained raw body exists for one reason: a normalization bug is only diagnosable against
* what the provider actually sent. The stored bytes were a nonce and a ciphertext and nothing else,
* so the first rotation of the payload encryption key turned every retained callback into bytes
* that no key could be matched to — the retention outlived the key but not the ability to name it,
* which is the same as not retaining it.
*/
class CallbackPayloadRotationTest {
private static final byte[] RAW =
"{\"MessageId\":\"m-1\",\"eventType\":\"Delivery\"}".getBytes(StandardCharsets.UTF_8);
@Test
@DisplayName("a callback retained before a rotation is still readable after it")
void aCallbackRetainedBeforeARotationIsStillReadableAfterIt() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
byte[] revealed =
new AesGcmNotificationPayloadProtection(afterRotation(), new SecureRandom()).reveal(stored);
assertThat(revealed)
.as("the envelope names the key it used, so the retired key can be asked for by id")
.isEqualTo(RAW);
}
@Test
@DisplayName("the retained bytes name the key that encrypted them")
void theRetainedBytesNameTheKeyThatEncryptedThem() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
int keyIdLength = Byte.toUnsignedInt(stored[1]);
String keyId = new String(stored, 2, keyIdLength, StandardCharsets.UTF_8);
assertThat(stored[0])
.as("a format that cannot say which format it is can only change by rewriting every row")
.isEqualTo(AesGcmNotificationPayloadProtection.VERSION);
assertThat(keyId).isEqualTo("payload-1");
}
@Test
@DisplayName("the retained bytes are not the payload")
void theRetainedBytesAreNotThePayload() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
assertThat(new String(stored, StandardCharsets.UTF_8)).doesNotContain("MessageId");
}
private static AesGcmCallbackPayloadProtection protection(SecretMaterialProvider keys) {
return new AesGcmCallbackPayloadProtection(
keys,
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
}
/** The key store as it stood when the callback arrived. */
private static SecretMaterialProvider beforeRotation() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.PAYLOAD_ENCRYPTION,
payloadKey("payload-1", (byte) 0x55),
SecretPurpose.CALLBACK_FINGERPRINT_HMAC,
new SecretKeyMaterial(
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88))),
Map.of());
}
/** The key store after the payload key was replaced and the old one retired. */
private static SecretMaterialProvider afterRotation() {
return new SettingsSecretMaterialProvider(
Map.of(SecretPurpose.PAYLOAD_ENCRYPTION, payloadKey("payload-2", (byte) 0x56)),
Map.of("payload-1", payloadKey("payload-1", (byte) 0x55)));
}
private static SecretKeyMaterial payloadKey(String keyId, byte fill) {
return new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, filled(fill));
}
private static byte[] filled(byte value) {
byte[] material = new byte[32];
Arrays.fill(material, value);
return material;
}
}
@@ -0,0 +1,171 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* A rotation as a window rather than an instant.
*
* <p>A superseded generation used to be overwritten outright. The attempts planned against it are
* already in flight when the rotation lands, and they are the ones that cannot simply be failed and
* cannot be replayed either — the provider may already have acted on the request. Retiring the
* credential at the moment the new one arrives fails exactly that work.
*
* <p>The opposite mistake is keeping it forever, which is not a rotation but two live credentials.
* The window is what makes the retirement real, so it is asserted from both ends.
*/
class CredentialDrainWindowTest {
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
private static final ProviderProfileId OTHER = new ProviderProfileId("ses-secondary");
private static final Instant START = Instant.parse("2026-08-19T00:00:00Z");
private static final Duration WINDOW = Duration.ofMinutes(15);
private final MovableClock clock = new MovableClock(START);
private final ProviderCredentialManager manager =
new ProviderCredentialManager(credentialKeys(), clock, WINDOW);
@Test
@DisplayName("a rotation switches new work to the new generation")
void aRotationSwitchesNewWorkToTheNewGeneration() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2);
assertThat(manager.materialFor(PROFILE, 2)).isEqualTo(filled((byte) 0x44));
}
@Test
@DisplayName("in-flight work keeps the generation it was planned against")
void inFlightWorkKeepsTheGenerationItWasPlannedAgainst() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
clock.advance(WINDOW.minusSeconds(1));
assertThat(manager.materialFor(PROFILE, 1))
.as("an attempt the provider may already have acted on cannot be failed or replayed")
.isEqualTo(filled((byte) 0x33));
}
@Test
@DisplayName("the retired generation stops resolving once the window closes")
void theRetiredGenerationStopsResolvingOnceTheWindowCloses() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
clock.advance(WINDOW);
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
.as("a superseded credential that never expires is not a rotation, it is two live keys")
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("finished draining");
}
@Test
@DisplayName("a generation that was never activated is refused, drained or not")
void aGenerationThatWasNeverActivatedIsRefused() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
assertThatThrownBy(() -> manager.materialFor(PROFILE, 7))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no credential generation 7");
assertThatThrownBy(() -> manager.materialFor(OTHER, 1))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no activated credential generation");
}
@Test
@DisplayName("two profiles resolve different material")
void twoProfilesResolveDifferentMaterial() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(OTHER, 1, "cred-2"));
assertThat(manager.materialFor(PROFILE, 1))
.as("one platform-wide provider credential made a leak of one account a leak of all")
.isNotEqualTo(manager.materialFor(OTHER, 1));
}
@Test
@DisplayName("a handle naming a key of another purpose never becomes a credential")
void aHandleNamingAKeyOfAnotherPurposeIsRefused() {
assertThatThrownBy(
() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "callback-1")))
.as("refused at the rotation, so no dispatch can ever resolve it")
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("a negative drain window is refused")
void aNegativeDrainWindowIsRefused() {
assertThatThrownBy(
() -> new ProviderCredentialManager(credentialKeys(), clock, Duration.ofMinutes(-1)))
.isInstanceOf(IllegalArgumentException.class);
}
/** Two provider credentials and one key of another purpose, to prove the separation holds. */
private static SecretMaterialProvider credentialKeys() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.PROVIDER_CREDENTIAL,
new SecretKeyMaterial("cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x33)),
SecretPurpose.CALLBACK_SIGNING,
new SecretKeyMaterial(
"callback-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x55))),
Map.of(
"cred-2",
new SecretKeyMaterial(
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
}
private static byte[] filled(byte value) {
byte[] material = new byte[32];
Arrays.fill(material, value);
return material;
}
/** A clock the test moves, so the window is asserted rather than waited out. */
private static final class MovableClock extends Clock {
private Instant now;
private MovableClock(Instant now) {
this.now = now;
}
private void advance(Duration by) {
now = now.plus(by);
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
}
@@ -35,7 +35,54 @@ public final class SecurityFixtures {
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88, 32)),
SecretPurpose.VAPID_SIGNING,
new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))),
Map.of());
// A second provider credential, so a fixture can give two profiles genuinely different
// material rather than asserting per-profile binding against one shared key.
Map.of(
"cred-2",
new SecretKeyMaterial(
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x45, 32)),
"cb-2",
new SecretKeyMaterial(
"cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34, 32))));
}
/**
* A credential manager holding generation 1 of each named profile.
*
* <p>Adapters resolve a profile's credential rather than the platform's one current provider key,
* so a contract test has to say which profile it is speaking for — which is the point: a fixture
* that could not name a profile was a fixture proving a shape the platform no longer has.
*
* @param profileIds the profiles to activate
* @return the manager
*/
public static ProviderCredentialManager credentials(String... profileIds) {
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
for (String profileId : profileIds) {
activate(manager, profileId, "cred-1");
}
return manager;
}
/**
* A credential manager holding generation 1 of one profile, backed by a named key.
*
* @param profileId the profile to activate
* @param keyId which provider credential it is bound to
* @return the manager
*/
public static ProviderCredentialManager credentials(String profileId, String keyId) {
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
activate(manager, profileId, keyId);
return manager;
}
private static void activate(ProviderCredentialManager manager, String profileId, String keyId) {
manager.activate(
CredentialGeneration.candidate(
new dev.caskeleton.application.notification.platform.api.ProviderProfileId(profileId),
1,
keyId));
}
public static SecretMaterialProvider keysWithSameMaterial() {
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.template;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.thymeleaf.templatemode.TemplateMode;
/**
* Every engine honours the slot mode, not just the one the tests happened to instantiate.
*
* <p>{@code SlotAwareRenderingTest} constructs {@code PlaceholderTemplateEngine} and only that, and
* the mode-aware method was a {@code default} that forwarded to the unescaped overload. The
* Thymeleaf engine never overrode it, so with {@code template.engine=thymeleaf} — a supported,
* documented value — a subject could carry CR/LF and a deep link could carry a {@code javascript:}
* scheme. Nothing failed, because the interface compiled and the one engine under test was the one
* that implemented the rule.
*
* <p>Parameterized over the engines for that reason: a rule that only holds for the implementation
* somebody remembered to test is not a rule the platform has.
*/
class BothEnginesHonourSlotModeTest {
static Stream<Arguments> engines() {
return Stream.of(
Arguments.of("placeholder", new PlaceholderTemplateEngine()),
Arguments.of("thymeleaf-html", new ThymeleafStringTemplateEngine(TemplateMode.HTML)),
Arguments.of("thymeleaf-text", new ThymeleafStringTemplateEngine(TemplateMode.TEXT)));
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("a newline in a subject is refused, whichever engine renders it")
void aSubjectMayNotCarryAControlCharacter(String name, NotificationTemplateEngine engine) {
assertThatThrownBy(
() ->
engine.render(
TemplateSlotMode.SUBJECT,
subjectTemplate(name),
Map.of("code", "123\r\nBcc: attacker@example.com")))
.as("everything after a CR/LF is read as a new header by the receiving agent")
.isInstanceOf(TemplateRenderingException.class);
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("a javascript: deep link is refused, whichever engine renders it")
void aDeepLinkMayNotUseAScriptScheme(String name, NotificationTemplateEngine engine) {
assertThatThrownBy(
() ->
engine.render(
TemplateSlotMode.URI,
linkTemplate(name),
Map.of("link", "javascript:alert(1)")))
.isInstanceOf(TemplateRenderingException.class);
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("an https deep link is accepted, so the rule is a filter and not a refusal")
void anHttpsDeepLinkIsAccepted(String name, NotificationTemplateEngine engine) {
// Without this, the assertion above is satisfied by an engine that refuses every URI slot.
assertThat(
engine.render(
TemplateSlotMode.URI, linkTemplate(name), Map.of("link", "https://example.com/a")))
.contains("https://example.com/a");
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("markup in an HTML slot is escaped, whichever engine renders it")
void markupInAnHtmlSlotIsEscaped(String name, NotificationTemplateEngine engine) {
assertThat(
engine.render(
TemplateSlotMode.HTML_TEXT,
bodyTemplate(name),
Map.of("name", "<script>x</script>")))
.as("a substituted value must not become markup")
.doesNotContain("<script>");
}
/** Each engine's own placeholder syntax; the rule under test is the escaping, not the syntax. */
private static String subjectTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "code [[${code}]]" : "code {{code}}";
}
private static String linkTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "[[${link}]]" : "{{link}}";
}
private static String bodyTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "hello [[${name}]]" : "hello {{name}}";
}
}
@@ -107,10 +107,18 @@ public final class ContractAdapters {
var adapter =
new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
new SesRequestMapper(
properties,
new AwsSignatureV4Signer(),
new dev.caskeleton.adapter.outbound.notification.platform.provider.smtp
.SmtpMimeMessageFactory(
jakarta.mail.Session.getInstance(new java.util.Properties()))),
new dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard(
new dev.caskeleton.adapter.outbound.notification.platform.provider
.UnconfiguredAttachmentResolver()),
new SesFailureClassifier(),
protector,
SecurityFixtures.keys(),
SecurityFixtures.credentials("ses-primary"),
"AKIAEXAMPLE",
CLOCK);
return new Case(
@@ -133,6 +141,7 @@ public final class ContractAdapters {
Optional.of("MG123"),
Optional.empty(),
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
var adapter =
@@ -141,7 +150,7 @@ public final class ContractAdapters {
new TwilioRequestMapper(properties),
new TwilioFailureClassifier(),
protector,
SecurityFixtures.keys());
SecurityFixtures.credentials("twilio-primary"));
return new Case(
"twilio",
adapter,
@@ -218,9 +227,11 @@ public final class ContractAdapters {
private static Case webhook(ProviderFaultHarness harness, ContactPointProtector protector) {
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
// The id of a real callback signing key, because a subscription is now signed with the key its
// reference names rather than with whatever the platform's current one happens to be.
var subscription =
new WebhookSubscription(
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("cb-1"));
var adapter =
new WebhookNotificationProviderAdapter(
gateway,