package alexa import ( "context" "crypto" "crypto/rsa" "crypto/sha1" //nolint:gosec // Alexa's request signing scheme is fixed as SHA1withRSA; not our choice. "crypto/x509" "encoding/base64" "encoding/pem" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) const ( // certChainHost/certChainPathPrefix are Amazon's documented constraints // on where a request's SignatureCertChainUrl header is allowed to point // (https://developer.amazon.com/en-US/docs/alexa/custom-skills/host-a-custom-skill-as-a-web-service.html#check-the-signature-certificate-url). // Accepting a URL to an attacker-controlled host would let anyone // present their own signing certificate. certChainHost = "s3.amazonaws.com" certChainPathPrefix = "/echo.api/" certSANRequired = "echo-api.amazon.com" // timestampTolerance is Amazon's recommended replay-protection window. timestampTolerance = 150 * time.Second ) // Validator verifies an incoming HTTP request against its Signature/ // SignatureCertChainUrl headers. *SignatureValidator implements it; Handler // depends on this interface (not the concrete type) so tests can inject a // fake instead of exercising real crypto/network calls. type Validator interface { Validate(ctx context.Context, r *http.Request, body []byte) error } // SignatureValidator verifies an incoming HTTP request actually came from // Alexa: the SignatureCertChainUrl points at Amazon's cert host, the // referenced certificate chain is valid and carries the expected SAN, and // the Signature header is a valid RSA-SHA1 signature over the raw request // body made with that certificate's key. Amazon's certs are cached by URL // until they expire, since the same handful of URLs are reused across many // requests. type SignatureValidator struct { httpClient *http.Client mu sync.RWMutex cache map[string]*cachedChain } type cachedChain struct { leaf *x509.Certificate expiresAt time.Time } // NewSignatureValidator constructs a validator using the given HTTP client // to fetch certificate chains (pass nil to use http.DefaultClient). func NewSignatureValidator(httpClient *http.Client) *SignatureValidator { if httpClient == nil { httpClient = http.DefaultClient } return &SignatureValidator{httpClient: httpClient, cache: make(map[string]*cachedChain)} } // Validate checks the SignatureCertChainUrl and Signature headers against // the raw request body. Callers must pass the exact bytes that were signed — // decoding then re-marshaling the JSON would produce different bytes and // always fail verification. func (v *SignatureValidator) Validate(ctx context.Context, r *http.Request, body []byte) error { chainURL := r.Header.Get("SignatureCertChainUrl") sigHeader := r.Header.Get("Signature") if chainURL == "" || sigHeader == "" { return errors.New("missing SignatureCertChainUrl or Signature header") } if err := validateCertChainURL(chainURL); err != nil { return fmt.Errorf("invalid SignatureCertChainUrl: %w", err) } leaf, err := v.leafCertificate(ctx, chainURL) if err != nil { return fmt.Errorf("fetch/validate cert chain: %w", err) } sig, err := base64.StdEncoding.DecodeString(sigHeader) if err != nil { return fmt.Errorf("decode Signature header: %w", err) } if err := verifySignature(leaf, body, sig); err != nil { return err } return nil } func verifySignature(leaf *x509.Certificate, body, sig []byte) error { pubKey, ok := leaf.PublicKey.(*rsa.PublicKey) if !ok { return fmt.Errorf("leaf certificate public key is %T, want RSA", leaf.PublicKey) } hash := sha1.Sum(body) //nolint:gosec // required by Alexa's fixed signing scheme if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA1, hash[:], sig); err != nil { return fmt.Errorf("signature verification failed: %w", err) } return nil } func validateCertChainURL(raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("parse url: %w", err) } if !strings.EqualFold(u.Scheme, "https") { return fmt.Errorf("scheme %q, want https", u.Scheme) } if !strings.EqualFold(u.Hostname(), certChainHost) { return fmt.Errorf("host %q, want %q", u.Hostname(), certChainHost) } if port := u.Port(); port != "" && port != "443" { return fmt.Errorf("port %q, want 443 or unset", port) } if !strings.HasPrefix(u.Path, certChainPathPrefix) { return fmt.Errorf("path %q, want prefix %q", u.Path, certChainPathPrefix) } return nil } func (v *SignatureValidator) leafCertificate(ctx context.Context, chainURL string) (*x509.Certificate, error) { v.mu.RLock() cached, ok := v.cache[chainURL] v.mu.RUnlock() if ok && time.Now().Before(cached.expiresAt) { return cached.leaf, nil } chain, err := fetchChain(ctx, v.httpClient, chainURL) if err != nil { return nil, err } leaf, err := verifyChain(chain) if err != nil { return nil, err } v.mu.Lock() v.cache[chainURL] = &cachedChain{leaf: leaf, expiresAt: leaf.NotAfter} v.mu.Unlock() return leaf, nil } func fetchChain(ctx context.Context, httpClient *http.Client, chainURL string) ([]*x509.Certificate, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, chainURL, nil) if err != nil { return nil, err } resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d fetching cert chain", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var chain []*x509.Certificate for { var block *pem.Block block, data = pem.Decode(data) if block == nil { break } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("parse certificate: %w", err) } chain = append(chain, cert) } if len(chain) == 0 { return nil, errors.New("no certificates found in chain") } return chain, nil } // verifyChain validates the leaf certificate's expiry and required SAN, and // that the chain cryptographically links leaf -> intermediates -> the // chain's own terminal certificate (trusted here as the root: the chain was // already fetched over a TLS connection to s3.amazonaws.com whose own // certificate is checked against the system trust store, so this step is // about proving the leaf/intermediate/root signatures are internally // consistent and the leaf itself hasn't been tampered with or expired). func verifyChain(chain []*x509.Certificate) (*x509.Certificate, error) { leaf := chain[0] now := time.Now() if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { return nil, fmt.Errorf("leaf certificate not valid at %s (window %s to %s)", now, leaf.NotBefore, leaf.NotAfter) } found := false for _, san := range leaf.DNSNames { if strings.EqualFold(san, certSANRequired) { found = true break } } if !found { return nil, fmt.Errorf("leaf certificate SAN does not include %q", certSANRequired) } roots := x509.NewCertPool() roots.AddCert(chain[len(chain)-1]) intermediates := x509.NewCertPool() for _, c := range chain[1 : len(chain)-1] { intermediates.AddCert(c) } if _, err := leaf.Verify(x509.VerifyOptions{ Roots: roots, Intermediates: intermediates, CurrentTime: now, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }); err != nil { return nil, fmt.Errorf("certificate chain verification failed: %w", err) } return leaf, nil } // ValidateTimestamp rejects requests outside Amazon's recommended // replay-protection window. func ValidateTimestamp(rfc3339 string) error { ts, err := time.Parse(time.RFC3339, rfc3339) if err != nil { return fmt.Errorf("parse request timestamp %q: %w", rfc3339, err) } if d := time.Since(ts); d < -timestampTolerance || d > timestampTolerance { return fmt.Errorf("request timestamp %s outside %s tolerance (age %s)", rfc3339, timestampTolerance, d) } return nil } // ValidateApplicationID checks the request envelope's application ID // against the configured skill ID. See alexa-bridge/plan.md's "Decisions on // open questions" #4. func ValidateApplicationID(req *Request, expectedSkillID string) error { got := req.ApplicationID() if got == "" { return errors.New("request has no application ID in context or session") } if got != expectedSkillID { return errors.New("application id does not match configured skill id") } return nil }