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.
409 lines
14 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|