Nik Afiq 8f7024edfa
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
feat(climate): add SetTemperature method to ClimateService
- 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.
2026-07-25 12:08:24 +09:00

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
}