carlos / platform Public

mail: derive SES SMTP passwords from IAM secrets

The SMTP username is the access key id and the password is a pure function
of the secret and the region, so credentials need no second AWS call and
the secret needs no storage. Golden test uses AWS's own reference
implementation as the oracle — the property tests cannot distinguish a
correct signing chain from a transposed one.
Paul Campbell 69fbb1462aaae6c36e0132310666f71ecaa7f9a7 parent 87e7bb6
2 files changed, +98 −0
  • internal/mail/smtppass.go +43 −0
  • internal/mail/smtppass_test.go +55 −0
diff --git a/internal/mail/smtppass.go b/internal/mail/smtppass.go
new file mode 100644
index 000000000..8f8564bb1
--- /dev/null
+++ b/internal/mail/smtppass.go
@@ -0,0 +1,43 @@
+// SMTP credential derivation. SES's SMTP password is not issued by an API —
+// it is a deterministic function of the IAM secret access key and the
+// region, which is why this package can hand an app working SMTP
+// credentials without a second AWS call, and why the secret itself never
+// needs to be stored anywhere (spec §3, §4).
+package mail
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+)
+
+// The four constants of AWS's published derivation. smtpDate is a literal
+// "11111111" and NOT today's date — SES fixed it so the password does not
+// expire; using a real date silently produces a credential SES rejects.
+const (
+ smtpDate = "11111111"
+ smtpService = "ses"
+ smtpTerminal = "aws4_request"
+ smtpMessage = "SendRawEmail"
+ smtpVersion = 0x04
+)
+
+func hmac256(key []byte, data string) []byte {
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte(data))
+ return h.Sum(nil)
+}
+
+// SMTPPassword derives the SES SMTP password for an IAM secret access key
+// in region. The matching SMTP username is the access key ID, unmodified.
+//
+// The signing chain is date → region → service → terminal → message, seeded
+// with "AWS4"+secret, and the result is the version byte followed by the
+// HMAC. Every step is load-bearing and the order is not negotiable.
+func SMTPPassword(secretKey, region string) string {
+ sig := []byte("AWS4" + secretKey)
+ for _, step := range []string{smtpDate, region, smtpService, smtpTerminal, smtpMessage} {
+ sig = hmac256(sig, step)
+ }
+ return base64.StdEncoding.EncodeToString(append([]byte{smtpVersion}, sig...))
+}
diff --git a/internal/mail/smtppass_test.go b/internal/mail/smtppass_test.go
new file mode 100644
index 000000000..de2905ab9
--- /dev/null
+++ b/internal/mail/smtppass_test.go
@@ -0,0 +1,55 @@
+package mail
+
+import (
+ "encoding/base64"
+ "testing"
+)
+
+// The derivation is structurally pinned: AWS's algorithm prefixes a
+// one-byte version (0x04) to a 32-byte HMAC-SHA256, so every password
+// decodes to exactly 33 bytes starting 0x04. A change that breaks either
+// invariant produces credentials SES rejects at auth time — hours later,
+// in someone else's deployment.
+func TestSMTPPasswordShape(t *testing.T) {
+ got := SMTPPassword("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "eu-west-1")
+ raw, err := base64.StdEncoding.DecodeString(got)
+ if err != nil {
+ t.Fatalf("password is not base64: %v", err)
+ }
+ if len(raw) != 33 {
+ t.Fatalf("decoded length = %d, want 33", len(raw))
+ }
+ if raw[0] != 0x04 {
+ t.Fatalf("version byte = %#x, want 0x04", raw[0])
+ }
+}
+
+// Region is part of the signing chain, so the same secret yields a
+// DIFFERENT password per region. This is why §8's multi-region delivery
+// must derive per region rather than reuse one string.
+func TestSMTPPasswordIsRegionScoped(t *testing.T) {
+ secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ if SMTPPassword(secret, "eu-west-1") == SMTPPassword(secret, "us-east-1") {
+ t.Fatal("password did not change with region")
+ }
+}
+
+func TestSMTPPasswordIsDeterministic(t *testing.T) {
+ secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ if SMTPPassword(secret, "eu-west-1") != SMTPPassword(secret, "eu-west-1") {
+ t.Fatal("derivation is not deterministic")
+ }
+}
+
+// A golden value produced by AWS's own reference implementation, not by
+// this package. The property tests above cannot tell a correct chain from
+// a wrong-but-well-formed one (transpose region and service and every
+// property still holds), so this is the only test here with an independent
+// oracle. Regenerate it from the SES Developer Guide, never from our code.
+func TestSMTPPasswordGolden(t *testing.T) {
+ const want = "BMW5RDrXmmVs0lV7GpI4oLkHXpZ4stDsk6q91z1g38Pk"
+ got := SMTPPassword("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "eu-west-1")
+ if got != want {
+ t.Fatalf("SMTPPassword = %q, want %q", got, want)
+ }
+}