All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
- Implemented SetTemperature in ClimateService for setting an absolute target temperature. - Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature. - Added corresponding handler and tests for SetTemperature in ClimateGRPC. - Modified ClimateApp to handle SetTemperature requests without clamping. - Updated climate.proto to define SetTemperatureRequest message. - Adjusted Dockerfiles to include alexa-bridge dependencies.
263 lines
8.2 KiB
Go
263 lines
8.2 KiB
Go
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
|
|
}
|