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

409 lines
14 KiB
Go

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")
}
})
}