SquatchMail.SNS.MessageVerifier (SquatchMail v0.1.0)

Copy Markdown View Source

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

message()

@type message() :: %{optional(String.t()) => String.t()}

Functions

verify(message)

@spec verify(message()) :: :ok | {:error, term()}

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).