Hand-written verification of Amazon SNS message signatures.
SNS signs every HTTP(S) delivery (Notification, SubscriptionConfirmation,
UnsubscribeConfirmation) with a private key, and publishes the matching
X.509 certificate at a URL it includes in the message (SigningCertURL).
Verifying a message means: fetch that certificate (after validating the URL
actually points at AWS), extract its public key, rebuild the exact string
SNS signed, and check the signature against it with :public_key.verify/4.
This is intentionally dependency-free per CLAUDE.md - only :public_key,
:crypto, and Finch (already a dep, via the shared SquatchMail.Finch
pool) are used. No ex_aws_sns.
Canonical string-to-sign
SNS signs a newline-delimited string built from a fixed subset of the
message's own fields, in a fixed order - not the raw JSON. The fields
differ by message Type:
Notification-Message,MessageId,Subject(only if present),Timestamp,TopicArn,Type.SubscriptionConfirmation/UnsubscribeConfirmation-Message,MessageId,SubscribeURL,Timestamp,Token,TopicArn,Type.
Each field contributes two lines: its name, then its value ("key\nvalue\n").
Fields absent from the message (i.e. Subject when there is none) are
skipped entirely rather than contributing an empty line - this is the
"trailing newline gotcha" from the AJ Foster writeup: it's not about a
newline at the very end, it's that every present field contributes
its own trailing newline, and omitting a field must not leave a blank
line in its place.
Signature versions
SignatureVersion "1" uses SHA1withRSA, "2" uses SHA256withRSA. Both
are supported; verification dispatches on the message's own field.
Escape hatch
Set config :squatch_mail, verify_sns_signatures: false to skip
verification entirely (tests, local dev without real SNS traffic). A
warning is logged every time this bypass is exercised so it can't be
silently left on in a real deployment.
Summary
Functions
Verifies an SNS message's signature.
Types
Functions
Verifies an SNS message's signature.
message is the JSON-decoded SNS envelope (string keys, as delivered)
containing at least Type, Message, MessageId, Timestamp,
TopicArn, SignatureVersion, Signature, and SigningCertURL, plus
Subject (Notification, optional) or SubscribeURL/Token
(SubscriptionConfirmation/UnsubscribeConfirmation).
Returns :ok or {:error, reason}. reason is an atom or string
describing what failed (missing field, bad cert host, expired cert,
signature mismatch, fetch failure, etc).