refactor: 각 어댑터터별 리펙토링 진행
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
+20
-9
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -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);
|
||||
}
|
||||
+74
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -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);
|
||||
|
||||
+5
-1
@@ -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");
|
||||
}
|
||||
|
||||
+137
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -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
|
||||
|
||||
+6
-51
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -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);
|
||||
|
||||
+11
@@ -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");
|
||||
|
||||
+15
-10
@@ -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());
|
||||
}
|
||||
|
||||
+9
-6
@@ -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);
|
||||
|
||||
+48
-3
@@ -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.
|
||||
*
|
||||
|
||||
+25
-1
@@ -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");
|
||||
|
||||
+32
-38
@@ -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
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
|
||||
+110
-1
@@ -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")));
|
||||
|
||||
+8
-4
@@ -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);
|
||||
}
|
||||
|
||||
+3
-74
@@ -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("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
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");
|
||||
}
|
||||
|
||||
+129
@@ -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("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
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));
|
||||
}
|
||||
}
|
||||
+63
-9
@@ -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
|
||||
// `&` 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;
|
||||
}
|
||||
}
|
||||
|
||||
+173
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -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();
|
||||
}
|
||||
}
|
||||
+263
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
@@ -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 =
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
|
||||
Optional.of("MG123"),
|
||||
Optional.empty(),
|
||||
CALLBACK_URL,
|
||||
"cb-1",
|
||||
Duration.ofSeconds(3),
|
||||
Duration.ofHours(12));
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+192
-13
@@ -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());
|
||||
|
||||
+9
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+171
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-1
@@ -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() {
|
||||
|
||||
+100
@@ -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}}";
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user