package alexa import ( "bytes" "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha1" //nolint:gosec // matches Alexa's fixed signing scheme, see signature.go "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/pem" "math/big" "net/http" "net/http/httptest" "net/url" "testing" "time" ) // generateTestChain builds a two-certificate PEM chain (leaf signed by a // throwaway root) for exercising verifyChain/verifySignature without // needing real Alexa-issued certificates. func generateTestChain(t *testing.T, sans []string, notBefore, notAfter time.Time) ([]byte, *rsa.PrivateKey) { t.Helper() rootKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate root key: %v", err) } rootTemplate := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test Root CA"}, NotBefore: notBefore, NotAfter: notAfter, IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, } rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) if err != nil { t.Fatalf("create root cert: %v", err) } rootCert, err := x509.ParseCertificate(rootDER) if err != nil { t.Fatalf("parse root cert: %v", err) } leafKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate leaf key: %v", err) } leafTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "echo-api.amazon.com"}, DNSNames: sans, NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, } leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey) if err != nil { t.Fatalf("create leaf cert: %v", err) } var buf bytes.Buffer if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: leafDER}); err != nil { t.Fatalf("encode leaf pem: %v", err) } if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: rootDER}); err != nil { t.Fatalf("encode root pem: %v", err) } return buf.Bytes(), leafKey } func parseChain(t *testing.T, chainPEM []byte) []*x509.Certificate { t.Helper() var chain []*x509.Certificate data := chainPEM for { var block *pem.Block block, data = pem.Decode(data) if block == nil { break } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { t.Fatalf("parse certificate: %v", err) } chain = append(chain, cert) } return chain } func TestValidateCertChainURL(t *testing.T) { tests := []struct { name string url string wantErr bool }{ {name: "valid", url: "https://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: false}, {name: "valid with explicit 443 port", url: "https://s3.amazonaws.com:443/echo.api/echo-api-cert.pem", wantErr: false}, {name: "wrong scheme", url: "http://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: true}, {name: "wrong host", url: "https://evil.example.com/echo.api/echo-api-cert.pem", wantErr: true}, {name: "host looks similar but differs", url: "https://s3.amazonaws.com.evil.com/echo.api/echo-api-cert.pem", wantErr: true}, {name: "wrong port", url: "https://s3.amazonaws.com:8443/echo.api/echo-api-cert.pem", wantErr: true}, {name: "wrong path prefix", url: "https://s3.amazonaws.com/not-echo-api/echo-api-cert.pem", wantErr: true}, {name: "unparsable", url: "://bad", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateCertChainURL(tt.url) if (err != nil) != tt.wantErr { t.Fatalf("validateCertChainURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) } }) } } func TestVerifyChain(t *testing.T) { now := time.Now() t.Run("valid chain with correct SAN", func(t *testing.T) { chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour)) leaf, err := verifyChain(parseChain(t, chainPEM)) if err != nil { t.Fatalf("verifyChain() error = %v", err) } if leaf == nil { t.Fatal("verifyChain() leaf = nil") } }) t.Run("missing required SAN", func(t *testing.T) { chainPEM, _ := generateTestChain(t, []string{"not-echo-api.amazon.com"}, now.Add(-time.Hour), now.Add(time.Hour)) if _, err := verifyChain(parseChain(t, chainPEM)); err == nil { t.Fatal("verifyChain() error = nil, want error for missing SAN") } }) t.Run("expired certificate", func(t *testing.T) { chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-2*time.Hour), now.Add(-time.Hour)) if _, err := verifyChain(parseChain(t, chainPEM)); err == nil { t.Fatal("verifyChain() error = nil, want error for expired cert") } }) t.Run("not yet valid certificate", func(t *testing.T) { chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(time.Hour), now.Add(2*time.Hour)) if _, err := verifyChain(parseChain(t, chainPEM)); err == nil { t.Fatal("verifyChain() error = nil, want error for not-yet-valid cert") } }) t.Run("leaf not actually signed by the presented root", func(t *testing.T) { chainA, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour)) chainB, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour)) mismatched := []*x509.Certificate{parseChain(t, chainA)[0], parseChain(t, chainB)[1]} if _, err := verifyChain(mismatched); err == nil { t.Fatal("verifyChain() error = nil, want error for mismatched leaf/root") } }) } func TestVerifySignature(t *testing.T) { now := time.Now() chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour)) leaf := parseChain(t, chainPEM)[0] body := []byte(`{"request":{"type":"LaunchRequest"}}`) hash := sha1.Sum(body) //nolint:gosec sig, err := rsa.SignPKCS1v15(rand.Reader, leafKey, crypto.SHA1, hash[:]) if err != nil { t.Fatalf("sign body: %v", err) } t.Run("valid signature", func(t *testing.T) { if err := verifySignature(leaf, body, sig); err != nil { t.Fatalf("verifySignature() error = %v", err) } }) t.Run("tampered body fails", func(t *testing.T) { if err := verifySignature(leaf, []byte(`{"request":{"type":"IntentRequest"}}`), sig); err == nil { t.Fatal("verifySignature() error = nil, want error for tampered body") } }) t.Run("garbage signature fails", func(t *testing.T) { if err := verifySignature(leaf, body, []byte("not a real signature")); err == nil { t.Fatal("verifySignature() error = nil, want error for garbage signature") } }) } // rewriteTransport redirects requests to a local httptest.Server while // leaving the request's URL (as seen by application code, e.g. // validateCertChainURL's host check) untouched. type rewriteTransport struct { target *url.URL base http.RoundTripper } func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.URL.Scheme = t.target.Scheme req.URL.Host = t.target.Host return t.base.RoundTrip(req) } func TestSignatureValidatorValidate(t *testing.T) { now := time.Now() chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour)) newTestValidator := func(t *testing.T, handler http.HandlerFunc) (*SignatureValidator, func()) { t.Helper() ts := httptest.NewServer(handler) targetURL, err := url.Parse(ts.URL) if err != nil { t.Fatalf("parse test server url: %v", err) } httpClient := &http.Client{Transport: &rewriteTransport{target: targetURL, base: http.DefaultTransport}} return NewSignatureValidator(httpClient), ts.Close } signBody := func(t *testing.T, key *rsa.PrivateKey, body []byte) string { t.Helper() hash := sha1.Sum(body) //nolint:gosec sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, hash[:]) if err != nil { t.Fatalf("sign body: %v", err) } return base64.StdEncoding.EncodeToString(sig) } t.Run("valid request end to end", func(t *testing.T) { v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(chainPEM) }) defer closeServer() body := []byte(`{"request":{"type":"LaunchRequest"}}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem") req.Header.Set("Signature", signBody(t, leafKey, body)) if err := v.Validate(context.Background(), req, body); err != nil { t.Fatalf("Validate() error = %v", err) } }) t.Run("caches the leaf certificate across calls", func(t *testing.T) { fetches := 0 v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { fetches++ _, _ = w.Write(chainPEM) }) defer closeServer() body := []byte(`{"request":{"type":"LaunchRequest"}}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem") req.Header.Set("Signature", signBody(t, leafKey, body)) if err := v.Validate(context.Background(), req, body); err != nil { t.Fatalf("Validate() #1 error = %v", err) } if err := v.Validate(context.Background(), req, body); err != nil { t.Fatalf("Validate() #2 error = %v", err) } if fetches != 1 { t.Fatalf("fetches = %d, want 1 (second Validate should hit the cache)", fetches) } }) t.Run("missing headers", func(t *testing.T) { v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(chainPEM) }) defer closeServer() body := []byte(`{}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) if err := v.Validate(context.Background(), req, body); err == nil { t.Fatal("Validate() error = nil, want error for missing headers") } }) t.Run("cert chain url pointing at the wrong host is rejected before any fetch", func(t *testing.T) { fetched := false v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { fetched = true _, _ = w.Write(chainPEM) }) defer closeServer() body := []byte(`{}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req.Header.Set("SignatureCertChainUrl", "https://evil.example.com/echo.api/echo-api-cert.pem") req.Header.Set("Signature", signBody(t, leafKey, body)) if err := v.Validate(context.Background(), req, body); err == nil { t.Fatal("Validate() error = nil, want error for wrong cert chain host") } if fetched { t.Fatal("cert chain should never be fetched when the URL itself is rejected") } }) t.Run("signature does not match body", func(t *testing.T) { v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(chainPEM) }) defer closeServer() signedBody := []byte(`{"request":{"type":"LaunchRequest"}}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(signedBody)) req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem") req.Header.Set("Signature", signBody(t, leafKey, signedBody)) tamperedBody := []byte(`{"request":{"type":"IntentRequest"}}`) if err := v.Validate(context.Background(), req, tamperedBody); err == nil { t.Fatal("Validate() error = nil, want error for body/signature mismatch") } }) t.Run("fetch failure surfaces as an error", func(t *testing.T) { v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) defer closeServer() body := []byte(`{}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem") req.Header.Set("Signature", signBody(t, leafKey, body)) if err := v.Validate(context.Background(), req, body); err == nil { t.Fatal("Validate() error = nil, want error when cert fetch fails") } }) } func TestValidateTimestamp(t *testing.T) { tests := []struct { name string ts string wantErr bool }{ {name: "now", ts: time.Now().Format(time.RFC3339), wantErr: false}, {name: "within tolerance", ts: time.Now().Add(-100 * time.Second).Format(time.RFC3339), wantErr: false}, {name: "too old", ts: time.Now().Add(-200 * time.Second).Format(time.RFC3339), wantErr: true}, {name: "too far in the future", ts: time.Now().Add(200 * time.Second).Format(time.RFC3339), wantErr: true}, {name: "unparsable", ts: "not-a-timestamp", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateTimestamp(tt.ts) if (err != nil) != tt.wantErr { t.Fatalf("ValidateTimestamp(%q) error = %v, wantErr %v", tt.ts, err, tt.wantErr) } }) } } func TestValidateApplicationID(t *testing.T) { const skillID = "amzn1.ask.skill.test" t.Run("matches via context", func(t *testing.T) { req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: skillID}}}} if err := ValidateApplicationID(req, skillID); err != nil { t.Fatalf("ValidateApplicationID() error = %v", err) } }) t.Run("matches via session fallback when context absent", func(t *testing.T) { req := &Request{Session: &Session{Application: Application{ApplicationID: skillID}}} if err := ValidateApplicationID(req, skillID); err != nil { t.Fatalf("ValidateApplicationID() error = %v", err) } }) t.Run("context takes precedence over session", func(t *testing.T) { req := &Request{ Context: &Context{System: System{Application: Application{ApplicationID: skillID}}}, Session: &Session{Application: Application{ApplicationID: "some-other-skill"}}, } if err := ValidateApplicationID(req, skillID); err != nil { t.Fatalf("ValidateApplicationID() error = %v", err) } }) t.Run("mismatch errors", func(t *testing.T) { req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: "wrong-skill"}}}} if err := ValidateApplicationID(req, skillID); err == nil { t.Fatal("ValidateApplicationID() error = nil, want error") } }) t.Run("neither context nor session present errors", func(t *testing.T) { req := &Request{} if err := ValidateApplicationID(req, skillID); err == nil { t.Fatal("ValidateApplicationID() error = nil, want error") } }) }