From 466e9eca97128171c25235cfa26135808f031443 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 29 Apr 2026 10:16:09 +0200 Subject: [PATCH] feat: extract shared ZeroConf package and add Amazon Music OAuth service - Extract DH key exchange crypto from pkg/service/spotify into new pkg/service/zeroconf package with exported functions and AuthTypeOAuthToken constant (both Spotify and Amazon use auth type 4) - Reduce pkg/service/spotify/zeroconf.go to thin wrappers around the shared package; public API (PushSpotifyCredentials, ZeroConfGetInfo) is preserved - Add pkg/service/amazon package mirroring the Spotify service with Amazon-specific differences: LWA endpoints, POST body credentials (not Basic Auth), user_id/name profile fields, amazon/accounts.json - Add PushAmazonCredentials delegating to shared zeroconf.PushCredentials Co-Authored-By: Claude Sonnet 4.6 --- pkg/service/amazon/service.go | 420 ++++++++++++++++++++++++++ pkg/service/amazon/service_test.go | 366 ++++++++++++++++++++++ pkg/service/amazon/zeroconf.go | 11 + pkg/service/amazon/zeroconf_test.go | 176 +++++++++++ pkg/service/spotify/zeroconf.go | 292 +----------------- pkg/service/spotify/zeroconf_test.go | 149 +-------- pkg/service/zeroconf/zeroconf.go | 300 ++++++++++++++++++ pkg/service/zeroconf/zeroconf_test.go | 314 +++++++++++++++++++ 8 files changed, 1607 insertions(+), 421 deletions(-) create mode 100644 pkg/service/amazon/service.go create mode 100644 pkg/service/amazon/service_test.go create mode 100644 pkg/service/amazon/zeroconf.go create mode 100644 pkg/service/amazon/zeroconf_test.go create mode 100644 pkg/service/zeroconf/zeroconf.go create mode 100644 pkg/service/zeroconf/zeroconf_test.go diff --git a/pkg/service/amazon/service.go b/pkg/service/amazon/service.go new file mode 100644 index 0000000..6b7e7ad --- /dev/null +++ b/pkg/service/amazon/service.go @@ -0,0 +1,420 @@ +// Package amazon provides Amazon Music (Login with Amazon) OAuth integration +// and token management for the SoundTouch service. +package amazon + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + // AmazonAuthorizeURL is the Login with Amazon (LWA) authorization endpoint. + AmazonAuthorizeURL = "https://www.amazon.com/ap/oa" + // AmazonTokenURL is the LWA token endpoint. + AmazonTokenURL = "https://api.amazon.com/auth/o2/token" + // AmazonProfileURL is the LWA user profile endpoint. + AmazonProfileURL = "https://api.amazon.com/user/profile" + // AmazonScopes are the OAuth scopes for account linking. + // Expand to "music::*" scopes once Amazon Music API access is available. + AmazonScopes = "profile" +) + +// Account represents a stored Amazon account with tokens. +type Account struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` + BoseSecret string `json:"bose_secret,omitempty"` +} + +// Service manages Amazon OAuth flow and token lifecycle. +type Service struct { + clientID string + clientSecret string + redirectURI string + dataDir string + mu sync.RWMutex + accounts map[string]*Account + + // Overridable URLs for testing + tokenURL string + profileURL string +} + +// NewAmazonService creates a new Service and loads any persisted accounts. +func NewAmazonService(clientID, clientSecret, redirectURI, dataDir string) *Service { + return &Service{ + clientID: clientID, + clientSecret: clientSecret, + redirectURI: redirectURI, + dataDir: dataDir, + accounts: make(map[string]*Account), + tokenURL: AmazonTokenURL, + profileURL: AmazonProfileURL, + } +} + +// Load loads persisted accounts from disk. +func (s *Service) Load() error { + return s.load() +} + +// SetEndpoints allows overriding default Amazon API endpoints (for testing). +func (s *Service) SetEndpoints(tokenURL, profileURL string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.tokenURL = tokenURL + s.profileURL = profileURL +} + +// BuildAuthorizeURL constructs the LWA OAuth authorization URL. +func (s *Service) BuildAuthorizeURL(state string) string { + params := url.Values{ + "client_id": {s.clientID}, + "response_type": {"code"}, + "redirect_uri": {s.redirectURI}, + "scope": {AmazonScopes}, + } + if state != "" { + params.Set("state", state) + } + + return AmazonAuthorizeURL + "?" + params.Encode() +} + +// ExchangeCodeAndStore exchanges an authorization code for tokens, +// fetches the user profile, and stores the account. +func (s *Service) ExchangeCodeAndStore(code string) error { + tokenResp, err := s.exchangeCode(code) + if err != nil { + return fmt.Errorf("token exchange: %w", err) + } + + accessToken, _ := tokenResp["access_token"].(string) + refreshToken, _ := tokenResp["refresh_token"].(string) + + expiresIn, _ := tokenResp["expires_in"].(float64) + if expiresIn == 0 { + expiresIn = 3600 + } + + profile, err := s.getUserProfile(accessToken) + if err != nil { + return fmt.Errorf("fetch profile: %w", err) + } + + // LWA profile uses "user_id" and "name" (not "id" and "display_name" like Spotify). + userID, _ := profile["user_id"].(string) + displayName, _ := profile["name"].(string) + email, _ := profile["email"].(string) + + boseSecret := s.generateBoseSecret() + + account := &Account{ + UserID: userID, + DisplayName: displayName, + Email: email, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: time.Now().Unix() + int64(expiresIn), + BoseSecret: boseSecret, + } + + s.mu.Lock() + s.accounts[userID] = account + s.mu.Unlock() + + if err := s.save(); err != nil { + return fmt.Errorf("save accounts: %w", err) + } + + log.Printf("[Amazon] Account linked: %s (%s)", displayName, userID) + + return nil +} + +// exchangeCode exchanges an authorization code for tokens. +// Amazon LWA requires client_id and client_secret as POST body fields, +// not as HTTP Basic Auth (unlike Spotify). +func (s *Service) exchangeCode(code string) (map[string]interface{}, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {s.redirectURI}, + "client_id": {s.clientID}, + "client_secret": {s.clientSecret}, + } + + req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + return result, nil +} + +func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) { + req, err := http.NewRequest(http.MethodGet, s.profileURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("profile request: %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse profile: %w", err) + } + + return result, nil +} + +// RefreshAccessToken refreshes the access token for the given account. +// Amazon LWA requires client credentials as POST body fields. +func (s *Service) RefreshAccessToken(account *Account) error { + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {account.RefreshToken}, + "client_id": {s.clientID}, + "client_secret": {s.clientSecret}, + } + + req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("refresh request: %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + s.mu.Lock() + account.AccessToken, _ = result["access_token"].(string) + + expiresIn, _ := result["expires_in"].(float64) + if expiresIn == 0 { + expiresIn = 3600 + } + + account.ExpiresAt = time.Now().Unix() + int64(expiresIn) + if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" { + account.RefreshToken = newRefresh + } + s.mu.Unlock() + + if err := s.save(); err != nil { + return fmt.Errorf("save accounts: %w", err) + } + + return nil +} + +// GetFreshToken returns a valid access token and username, refreshing if needed. +func (s *Service) GetFreshToken() (accessToken, username string, err error) { + s.mu.RLock() + + if len(s.accounts) == 0 { + s.mu.RUnlock() + return "", "", fmt.Errorf("no Amazon accounts linked") + } + + var account *Account + for _, a := range s.accounts { + account = a + break + } + + s.mu.RUnlock() + + // Check if token needs refresh (expired or within 60s of expiry) + if account.ExpiresAt < time.Now().Unix()+60 { + if err := s.RefreshAccessToken(account); err != nil { + return "", "", fmt.Errorf("refresh token: %w", err) + } + } + + s.mu.RLock() + defer s.mu.RUnlock() + + return account.AccessToken, account.UserID, nil +} + +// GetAccounts returns a copy of all accounts with tokens stripped for API responses. +func (s *Service) GetAccounts() []Account { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Account, 0, len(s.accounts)) + for _, a := range s.accounts { + result = append(result, Account{ + UserID: a.UserID, + DisplayName: a.DisplayName, + Email: a.Email, + ExpiresAt: a.ExpiresAt, + BoseSecret: a.BoseSecret, + // AccessToken and RefreshToken deliberately omitted + }) + } + + return result +} + +// GetAccountBySecret retrieves an Amazon account by its Bose surrogate secret. +func (s *Service) GetAccountBySecret(secret string) (*Account, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, a := range s.accounts { + if a.BoseSecret == secret { + return a, true + } + } + + return nil, false +} + +func (s *Service) generateBoseSecret() string { + prefix := "ba-" + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) + } + + return prefix + hex.EncodeToString(b) +} + +// save persists accounts to disk as JSON. +func (s *Service) save() error { + s.mu.RLock() + + data := make(map[string]*Account, len(s.accounts)) + for k, v := range s.accounts { + data[k] = v + } + + s.mu.RUnlock() + + dir := filepath.Join(s.dataDir, "amazon") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal accounts: %w", err) + } + + path := filepath.Join(dir, "accounts.json") + if err := os.WriteFile(path, jsonData, 0600); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} + +// load reads persisted accounts from disk. +func (s *Service) load() error { + path := filepath.Join(s.dataDir, "amazon", "accounts.json") + + jsonData, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // No accounts file yet, not an error + } + + return fmt.Errorf("read file: %w", err) + } + + var accounts map[string]*Account + if err := json.Unmarshal(jsonData, &accounts); err != nil { + return fmt.Errorf("unmarshal accounts: %w", err) + } + + s.mu.Lock() + s.accounts = accounts + s.mu.Unlock() + + log.Printf("[Amazon] Loaded %d account(s)", len(accounts)) + + return nil +} \ No newline at end of file diff --git a/pkg/service/amazon/service_test.go b/pkg/service/amazon/service_test.go new file mode 100644 index 0000000..a10ed1b --- /dev/null +++ b/pkg/service/amazon/service_test.go @@ -0,0 +1,366 @@ +package amazon + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBuildAuthorizeURL(t *testing.T) { + svc := NewAmazonService("test-client-id", "test-secret", "ueberboese-login://amazon", t.TempDir()) + + state := "test-state" + gotURL := svc.BuildAuthorizeURL(state) + + if !strings.Contains(gotURL, "client_id=test-client-id") { + t.Errorf("URL should contain client_id, got: %s", gotURL) + } + if !strings.Contains(gotURL, "redirect_uri=") { + t.Errorf("URL should contain redirect_uri, got: %s", gotURL) + } + if !strings.Contains(gotURL, "scope=") { + t.Errorf("URL should contain scope, got: %s", gotURL) + } + if !strings.Contains(gotURL, "response_type=code") { + t.Errorf("URL should contain response_type=code, got: %s", gotURL) + } + if !strings.Contains(gotURL, "state=test-state") { + t.Errorf("URL should contain state=test-state, got: %s", gotURL) + } + if !strings.HasPrefix(gotURL, AmazonAuthorizeURL) { + t.Errorf("URL should start with %s, got: %s", AmazonAuthorizeURL, gotURL) + } +} + +func TestGetAccountsStripsTokens(t *testing.T) { + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir()) + + svc.mu.Lock() + svc.accounts["amzn1.account.EXAMPLE"] = &Account{ + UserID: "amzn1.account.EXAMPLE", + DisplayName: "Test User", + Email: "test@example.com", + AccessToken: "secret-access-token", + RefreshToken: "secret-refresh-token", + ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), + } + svc.mu.Unlock() + + accounts := svc.GetAccounts() + + if len(accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(accounts)) + } + + if accounts[0].AccessToken != "" { + t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken) + } + if accounts[0].RefreshToken != "" { + t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken) + } + if accounts[0].UserID != "amzn1.account.EXAMPLE" { + t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID) + } + if accounts[0].DisplayName != "Test User" { + t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName) + } +} + +func TestExchangeCodeAndStore(t *testing.T) { + // Mock token endpoint — Amazon uses POST body credentials, not Basic Auth. + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + + switch r.Form.Get("grant_type") { + case "authorization_code": + if r.Form.Get("code") != "test-auth-code" { + t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code")) + } + + // Amazon uses POST body credentials, not HTTP Basic Auth. + if r.Form.Get("client_id") != "cid" { + t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id")) + } + if r.Form.Get("client_secret") != "csecret" { + t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret")) + } + _, _, hasBasicAuth := r.BasicAuth() + if hasBasicAuth { + t.Error("Amazon token endpoint must NOT use HTTP Basic Auth") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "new-at", + "refresh_token": "new-rt", + "expires_in": 3600, + }) + default: + t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type")) + http.Error(w, "bad request", 400) + } + })) + defer tokenServer.Close() + + // Mock profile endpoint — LWA returns "user_id" and "name" (not "id" / "display_name"). + profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer new-at" { + t.Errorf("expected Bearer new-at, got %s", auth) + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "user_id": "amzn1.account.TESTUSER123", + "name": "Amazon User", + "email": "user@amazon.com", + }) + })) + defer profileServer.Close() + + dir := t.TempDir() + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir) + svc.SetEndpoints(tokenServer.URL, profileServer.URL) + + err := svc.ExchangeCodeAndStore("test-auth-code") + if err != nil { + t.Fatalf("ExchangeCodeAndStore failed: %v", err) + } + + svc.mu.RLock() + account, ok := svc.accounts["amzn1.account.TESTUSER123"] + svc.mu.RUnlock() + + if !ok { + t.Fatal("account not found after exchange") + } + if account.DisplayName != "Amazon User" { + t.Errorf("expected Amazon User, got %s", account.DisplayName) + } + if account.Email != "user@amazon.com" { + t.Errorf("expected user@amazon.com, got %s", account.Email) + } + if account.AccessToken != "new-at" { + t.Errorf("expected new-at, got %s", account.AccessToken) + } + if account.RefreshToken != "new-rt" { + t.Errorf("expected new-rt, got %s", account.RefreshToken) + } + + // Verify saved to disk under amazon/ (not spotify/). + accountsFile := filepath.Join(dir, "amazon", "accounts.json") + data, err := os.ReadFile(accountsFile) + if err != nil { + t.Fatalf("failed to read accounts file: %v", err) + } + if !strings.Contains(string(data), "amzn1.account.TESTUSER123") { + t.Error("accounts file should contain the user ID") + } +} + +func TestRefreshAccessToken(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("grant_type") != "refresh_token" { + t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type")) + } + if r.Form.Get("refresh_token") != "my-refresh-token" { + t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token")) + } + + // Amazon uses POST body credentials. + if r.Form.Get("client_id") != "cid" { + t.Errorf("expected client_id=cid in POST body, got %q", r.Form.Get("client_id")) + } + if r.Form.Get("client_secret") != "csecret" { + t.Errorf("expected client_secret=csecret in POST body, got %q", r.Form.Get("client_secret")) + } + _, _, hasBasicAuth := r.BasicAuth() + if hasBasicAuth { + t.Error("Amazon token endpoint must NOT use HTTP Basic Auth") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token", + }) + })) + defer tokenServer.Close() + + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir()) + svc.tokenURL = tokenServer.URL + + account := &Account{ + UserID: "amzn1.account.USER", + AccessToken: "old-expired-token", + RefreshToken: "my-refresh-token", + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + } + + svc.mu.Lock() + svc.accounts[account.UserID] = account + svc.mu.Unlock() + + if err := svc.RefreshAccessToken(account); err != nil { + t.Fatalf("RefreshAccessToken: %v", err) + } + + if account.AccessToken != "new-access-token" { + t.Errorf("expected new-access-token, got %s", account.AccessToken) + } + if account.RefreshToken != "new-refresh-token" { + t.Errorf("expected new-refresh-token, got %s", account.RefreshToken) + } +} + +func TestGetFreshTokenRefreshesExpired(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token", + }) + })) + defer tokenServer.Close() + + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir()) + svc.tokenURL = tokenServer.URL + + svc.mu.Lock() + svc.accounts["amzn1.account.USER"] = &Account{ + UserID: "amzn1.account.USER", + AccessToken: "old-expired-token", + RefreshToken: "my-refresh-token", + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + } + svc.mu.Unlock() + + accessToken, username, err := svc.GetFreshToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if accessToken != "new-access-token" { + t.Errorf("expected new-access-token, got %s", accessToken) + } + if username != "amzn1.account.USER" { + t.Errorf("expected amzn1.account.USER, got %s", username) + } + + svc.mu.RLock() + account := svc.accounts["amzn1.account.USER"] + svc.mu.RUnlock() + + if account.RefreshToken != "new-refresh-token" { + t.Errorf("refresh token should be updated, got %s", account.RefreshToken) + } +} + +func TestGetFreshTokenNoAccounts(t *testing.T) { + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir()) + + _, _, err := svc.GetFreshToken() + if err == nil { + t.Error("expected error when no accounts exist") + } +} + +func TestGetFreshTokenNotExpired(t *testing.T) { + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", t.TempDir()) + + svc.mu.Lock() + svc.accounts["amzn1.account.USER"] = &Account{ + UserID: "amzn1.account.USER", + AccessToken: "valid-token", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), + } + svc.mu.Unlock() + + token, username, err := svc.GetFreshToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "valid-token" { + t.Errorf("expected valid-token, got %s", token) + } + if username != "amzn1.account.USER" { + t.Errorf("expected amzn1.account.USER, got %s", username) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + + svc := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir) + svc.mu.Lock() + svc.accounts["amzn1.account.USER1"] = &Account{ + UserID: "amzn1.account.USER1", + DisplayName: "Test User", + Email: "test@example.com", + AccessToken: "at", + RefreshToken: "rt", + ExpiresAt: 1234567890, + } + svc.accounts["amzn1.account.USER2"] = &Account{ + UserID: "amzn1.account.USER2", + DisplayName: "User Two", + Email: "two@example.com", + AccessToken: "at2", + RefreshToken: "rt2", + ExpiresAt: 9876543210, + } + svc.mu.Unlock() + + if err := svc.save(); err != nil { + t.Fatalf("save failed: %v", err) + } + + accountsFile := filepath.Join(dir, "amazon", "accounts.json") + if _, err := os.Stat(accountsFile); os.IsNotExist(err) { + t.Fatal("amazon/accounts.json was not created") + } + + svc2 := NewAmazonService("cid", "csecret", "ueberboese-login://amazon", dir) + if err := svc2.Load(); err != nil { + t.Fatalf("load failed: %v", err) + } + + svc2.mu.RLock() + defer svc2.mu.RUnlock() + + if len(svc2.accounts) != 2 { + t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts)) + } + + u1, ok := svc2.accounts["amzn1.account.USER1"] + if !ok { + t.Fatal("USER1 not found after load") + } + if u1.DisplayName != "Test User" { + t.Errorf("expected Test User, got %s", u1.DisplayName) + } + if u1.AccessToken != "at" { + t.Errorf("expected at, got %s", u1.AccessToken) + } + if u1.ExpiresAt != 1234567890 { + t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt) + } +} \ No newline at end of file diff --git a/pkg/service/amazon/zeroconf.go b/pkg/service/amazon/zeroconf.go new file mode 100644 index 0000000..5b86a19 --- /dev/null +++ b/pkg/service/amazon/zeroconf.go @@ -0,0 +1,11 @@ +package amazon + +import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf" + +// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the +// ZeroConf DH key exchange protocol. Falls back to simplified token push if +// the speaker does not support DH (older firmware). +// zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc". +func PushAmazonCredentials(zcBaseURL, username, accessToken string) error { + return zeroconf.PushCredentials(zcBaseURL, username, accessToken) +} \ No newline at end of file diff --git a/pkg/service/amazon/zeroconf_test.go b/pkg/service/amazon/zeroconf_test.go new file mode 100644 index 0000000..185dd60 --- /dev/null +++ b/pkg/service/amazon/zeroconf_test.go @@ -0,0 +1,176 @@ +package amazon + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf" +) + +func TestPushAmazonCredentials_FullRoundTrip(t *testing.T) { + speakerPrivate, speakerPublicBytes, err := zeroconf.GenerateDHKeyPair() + if err != nil { + t.Fatalf("speaker keygen: %v", err) + } + + type received struct { + username string + authData string + authType int + } + var got received + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getInfo": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 101, + "statusString": "OK", + "publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes), + }) + + case "addUser": + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob")) + if err != nil { + http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest) + return + } + clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey")) + if err != nil { + http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest) + return + } + + shared := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes) + encKey, macKey := zeroconf.DeriveKeys(shared) + + plaintext, err := zeroconf.DecryptBlob(encKey, macKey, blobBytes) + if err != nil { + http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest) + return + } + + // Minimal protobuf parse: field 1 = username, field 4 = authData, field 5 = authType + i := 0 + for i < len(plaintext) { + tag := plaintext[i] + i++ + fieldNum := tag >> 3 + wireType := tag & 0x07 + switch wireType { + case 0: + val, n := readVarint(plaintext[i:]) + i += n + if fieldNum == 5 { + got.authType = int(val) + } + case 2: + length, n := readVarint(plaintext[i:]) + i += n + value := plaintext[i : i+int(length)] + i += int(length) + switch fieldNum { + case 1: + got.username = string(value) + case 4: + got.authData = string(value) + } + default: + http.Error(w, "unexpected wire type", http.StatusBadRequest) + return + } + } + w.WriteHeader(http.StatusOK) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + const wantUsername = "amazonuser@example.com" + const wantToken = "Atza|access-token" + + if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushAmazonCredentials: %v", err) + } + + if got.username != wantUsername { + t.Errorf("username = %q, want %q", got.username, wantUsername) + } + if got.authData != wantToken { + t.Errorf("authData = %q, want %q", got.authData, wantToken) + } + if uint64(got.authType) != zeroconf.AuthTypeOAuthToken { + t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, zeroconf.AuthTypeOAuthToken) + } +} + +func TestPushAmazonCredentials_FallbackOnGetInfoFailure(t *testing.T) { + var receivedForm map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getInfo": + http.Error(w, "not supported", http.StatusNotFound) + case "addUser": + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + receivedForm = map[string]string{ + "userName": r.FormValue("userName"), + "blob": r.FormValue("blob"), + "clientKey": r.FormValue("clientKey"), + "tokenType": r.FormValue("tokenType"), + } + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + const wantUsername = "amazonuser@example.com" + const wantToken = "Atza|raw-access-token" + + if err := PushAmazonCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushAmazonCredentials: %v", err) + } + + if receivedForm == nil { + t.Fatal("addUser was never called") + } + if receivedForm["userName"] != wantUsername { + t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername) + } + if receivedForm["blob"] != wantToken { + t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken) + } + if receivedForm["tokenType"] != "accesstoken" { + t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken") + } + if receivedForm["clientKey"] != "" { + t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"]) + } +} + +func readVarint(data []byte) (uint64, int) { + var val uint64 + for i, b := range data { + val |= uint64(b&0x7f) << (7 * uint(i)) + if b&0x80 == 0 { + return val, i + 1 + } + } + return 0, len(data) +} \ No newline at end of file diff --git a/pkg/service/spotify/zeroconf.go b/pkg/service/spotify/zeroconf.go index 4c8624d..c5eff20 100644 --- a/pkg/service/spotify/zeroconf.go +++ b/pkg/service/spotify/zeroconf.go @@ -1,296 +1,16 @@ package spotify -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/rand" - "crypto/sha1" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "math/big" - "net/http" - "net/url" - "time" -) - -// dhPrimeBytes is the 768-bit MODP Group 1 prime from RFC 2409 §6.1. -// Spotify Connect ZeroConf uses this group for the DH key exchange. -var dhPrimeBytes = []byte{ - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, 0x34, - 0xc4, 0xc6, 0x66, 0x28, 0xb8, 0x0d, 0xc1, 0xcd, - 0x12, 0x90, 0x24, 0xe0, 0x88, 0xa6, 0x7c, 0xc7, - 0x40, 0x20, 0xbb, 0xea, 0x63, 0xb1, 0x39, 0xb2, - 0x25, 0x14, 0xa0, 0x87, 0x98, 0xe3, 0x40, 0x4d, - 0xde, 0xf9, 0x51, 0x9b, 0x3c, 0xd3, 0xa4, 0x31, - 0xb3, 0x02, 0xb0, 0xa6, 0xdf, 0x25, 0xf1, 0x43, - 0x74, 0xfe, 0x13, 0x56, 0xd6, 0xd5, 0x1c, 0x24, - 0x5e, 0x48, 0x5b, 0x57, 0x66, 0x25, 0xe7, 0xec, - 0x6f, 0x44, 0xc4, 0x2e, 0x9a, 0x63, 0xa3, 0x62, - 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, -} - -var dhPrime = new(big.Int).SetBytes(dhPrimeBytes) -var dhGenerator = big.NewInt(2) - -const dhKeySize = 96 // bytes, matches the 768-bit prime - -type zcGetInfoResponse struct { - PublicKey string `json:"publicKey"` -} - -// generateDHKeyPair generates a fresh DH private key and derives the public key. -// Both keys are padded to dhKeySize bytes (big-endian). -func generateDHKeyPair() (privateKey *big.Int, publicKeyBytes []byte, err error) { - privBytes := make([]byte, dhKeySize) - if _, err = rand.Read(privBytes); err != nil { - return - } - - privateKey = new(big.Int).SetBytes(privBytes) - pub := new(big.Int).Exp(dhGenerator, privateKey, dhPrime) - publicKeyBytes = padBigInt(pub, dhKeySize) - - return -} - -// computeSharedSecret computes DH(remotePublicKey, privateKey) mod prime. -func computeSharedSecret(privateKey *big.Int, remotePublicKeyBytes []byte) []byte { - remote := new(big.Int).SetBytes(remotePublicKeyBytes) - shared := new(big.Int).Exp(remote, privateKey, dhPrime) - - return padBigInt(shared, dhKeySize) -} - -// deriveKeys produces a 16-byte AES key and a 20-byte HMAC key from the shared secret. -func deriveKeys(sharedSecret []byte) (encKey, macKey []byte) { - h := sha1.Sum(sharedSecret) - baseKey := h[:16] - - hEnc := hmac.New(sha1.New, baseKey) - hEnc.Write([]byte("encryption")) - encKey = hEnc.Sum(nil)[:16] - - hMac := hmac.New(sha1.New, baseKey) - hMac.Write([]byte("checksum")) - macKey = hMac.Sum(nil) - - return -} - -// buildCredentialsBlob encodes Spotify login credentials as a minimal protobuf -// LoginCredentials message (username=1, typ=5, auth_data=4). -// typ=4 = AUTHENTICATION_SPOTIFY_TOKEN. -func buildCredentialsBlob(username, accessToken string) []byte { - var buf bytes.Buffer - - // field 1 (username), wire type 2 - buf.WriteByte(0x0a) - writeVarint(&buf, uint64(len(username))) - buf.WriteString(username) - - // field 5 (typ), wire type 0; value 4 = AUTHENTICATION_SPOTIFY_TOKEN - buf.WriteByte(0x28) - writeVarint(&buf, 4) - - // field 4 (auth_data), wire type 2 - buf.WriteByte(0x22) - writeVarint(&buf, uint64(len(accessToken))) - buf.WriteString(accessToken) - - return buf.Bytes() -} - -// encryptBlob encrypts plaintext using AES-128-CTR with an HMAC-SHA1 checksum. -// Returns [16-byte IV][ciphertext][20-byte HMAC]. -func encryptBlob(encKey, macKey, plaintext []byte) ([]byte, error) { - iv := make([]byte, aes.BlockSize) - if _, err := rand.Read(iv); err != nil { - return nil, err - } - - block, err := aes.NewCipher(encKey) - if err != nil { - return nil, err - } - - ciphertext := make([]byte, len(plaintext)) - cipher.NewCTR(block, iv).XORKeyStream(ciphertext, plaintext) - - mac := hmac.New(sha1.New, macKey) - mac.Write(ciphertext) - - out := make([]byte, 0, aes.BlockSize+len(ciphertext)+20) - out = append(out, iv...) - out = append(out, ciphertext...) - out = append(out, mac.Sum(nil)...) - - return out, nil -} - -// decryptBlob reverses encryptBlob: verifies the HMAC then decrypts. -func decryptBlob(encKey, macKey, blob []byte) ([]byte, error) { - const overhead = aes.BlockSize + 20 // IV + HMAC - if len(blob) < overhead { - return nil, fmt.Errorf("blob too short (%d bytes)", len(blob)) - } - - iv := blob[:aes.BlockSize] - ciphertext := blob[aes.BlockSize : len(blob)-20] - gotMAC := blob[len(blob)-20:] - - mac := hmac.New(sha1.New, macKey) - mac.Write(ciphertext) - - if !hmac.Equal(mac.Sum(nil), gotMAC) { - return nil, fmt.Errorf("blob HMAC verification failed") - } - - block, err := aes.NewCipher(encKey) - if err != nil { - return nil, err - } - - plaintext := make([]byte, len(ciphertext)) - cipher.NewCTR(block, iv).XORKeyStream(plaintext, ciphertext) - - return plaintext, nil -} +import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf" // ZeroConfGetInfo fetches the speaker's DH public key via GET ?action=getInfo. func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) { - client := &http.Client{Timeout: 10 * time.Second} - - resp, err := client.Get(zcBaseURL + "?action=getInfo") - if err != nil { - return nil, fmt.Errorf("getInfo: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("getInfo: status %d", resp.StatusCode) - } - - var info zcGetInfoResponse - if decodeErr := json.NewDecoder(resp.Body).Decode(&info); decodeErr != nil { - return nil, fmt.Errorf("getInfo: decode: %w", decodeErr) - } - - if info.PublicKey == "" { - return nil, fmt.Errorf("getInfo: empty publicKey") - } - - // Accept both standard and URL-safe base64. - pubKey, err := base64.StdEncoding.DecodeString(info.PublicKey) - if err != nil { - pubKey, err = base64.URLEncoding.DecodeString(info.PublicKey) - if err != nil { - return nil, fmt.Errorf("getInfo: invalid base64 publicKey: %w", err) - } - } - - return pubKey, nil + return zeroconf.GetInfo(zcBaseURL) } // PushSpotifyCredentials pushes Spotify credentials to a speaker using the full -// Spotify Connect ZeroConf protocol (DH key exchange + encrypted credential blob). -// If getInfo fails (e.g. older firmware without DH support), it falls back to the -// simplified tokenType=accesstoken approach. +// ZeroConf DH key exchange protocol. Falls back to simplified token push if +// the speaker does not support DH (older firmware). // zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc". func PushSpotifyCredentials(zcBaseURL, username, accessToken string) error { - speakerPublicKey, err := ZeroConfGetInfo(zcBaseURL) - if err != nil { - log.Printf("[ZeroConf] getInfo failed (%v), falling back to simplified token push", err) - return pushSimplifiedToken(zcBaseURL, username, accessToken) - } - - privateKey, ourPublicKeyBytes, err := generateDHKeyPair() - if err != nil { - return fmt.Errorf("pushSpotifyCredentials: keygen: %w", err) - } - - sharedSecret := computeSharedSecret(privateKey, speakerPublicKey) - encKey, macKey := deriveKeys(sharedSecret) - - plaintext := buildCredentialsBlob(username, accessToken) - - encryptedBlob, err := encryptBlob(encKey, macKey, plaintext) - if err != nil { - return fmt.Errorf("pushSpotifyCredentials: encrypt: %w", err) - } - - data := url.Values{} - data.Set("userName", username) - data.Set("blob", base64.StdEncoding.EncodeToString(encryptedBlob)) - data.Set("clientKey", base64.StdEncoding.EncodeToString(ourPublicKeyBytes)) - - client := &http.Client{Timeout: 10 * time.Second} - - resp, err := client.PostForm(zcBaseURL+"?action=addUser", data) - if err != nil { - return fmt.Errorf("pushSpotifyCredentials: addUser: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pushSpotifyCredentials: addUser status %d: %s", resp.StatusCode, body) - } - - return nil -} - -// pushSimplifiedToken is the fallback for firmware that does not support the DH -// key exchange. It sends the raw OAuth access token directly as the blob with -// tokenType=accesstoken. The token will expire after ~60 minutes. -func pushSimplifiedToken(zcBaseURL, username, accessToken string) error { - data := url.Values{} - data.Set("userName", username) - data.Set("blob", accessToken) - data.Set("clientKey", "") - data.Set("tokenType", "accesstoken") - - client := &http.Client{Timeout: 10 * time.Second} - - resp, err := client.PostForm(zcBaseURL+"?action=addUser", data) - if err != nil { - return fmt.Errorf("pushSimplifiedToken: %w", err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("pushSimplifiedToken: status %d: %s", resp.StatusCode, body) - } - - return nil -} - -func padBigInt(n *big.Int, size int) []byte { - b := n.Bytes() - if len(b) >= size { - return b - } - - out := make([]byte, size) - copy(out[size-len(b):], b) - - return out -} - -func writeVarint(buf *bytes.Buffer, v uint64) { - for v >= 0x80 { - buf.WriteByte(byte(v) | 0x80) - v >>= 7 - } - - buf.WriteByte(byte(v)) -} + return zeroconf.PushCredentials(zcBaseURL, username, accessToken) +} \ No newline at end of file diff --git a/pkg/service/spotify/zeroconf_test.go b/pkg/service/spotify/zeroconf_test.go index 7fb6f9f..3d1bb72 100644 --- a/pkg/service/spotify/zeroconf_test.go +++ b/pkg/service/spotify/zeroconf_test.go @@ -7,135 +7,15 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf" ) -func TestGenerateDHKeyPair(t *testing.T) { - priv1, pub1, err := generateDHKeyPair() - if err != nil { - t.Fatalf("generateDHKeyPair: %v", err) - } - if priv1 == nil || len(pub1) == 0 { - t.Fatal("expected non-nil private key and non-empty public key") - } - if len(pub1) != dhKeySize { - t.Errorf("public key length = %d, want %d", len(pub1), dhKeySize) - } - - // Two calls must produce different key pairs. - _, pub2, err := generateDHKeyPair() - if err != nil { - t.Fatalf("generateDHKeyPair second call: %v", err) - } - if string(pub1) == string(pub2) { - t.Error("two key-pair generations produced identical public keys") - } -} - -func TestDHCommutativity(t *testing.T) { - // DH shared secret must be symmetric: A's secret == B's secret. - privA, pubA, err := generateDHKeyPair() - if err != nil { - t.Fatal(err) - } - privB, pubB, err := generateDHKeyPair() - if err != nil { - t.Fatal(err) - } - - secretA := computeSharedSecret(privA, pubB) - secretB := computeSharedSecret(privB, pubA) - - if string(secretA) != string(secretB) { - t.Error("DH shared secrets are not equal (commutativity broken)") - } -} - -func TestDeriveKeys(t *testing.T) { - sharedSecret := make([]byte, dhKeySize) - for i := range sharedSecret { - sharedSecret[i] = byte(i) - } - - encKey, macKey := deriveKeys(sharedSecret) - - if len(encKey) != 16 { - t.Errorf("encKey length = %d, want 16", len(encKey)) - } - if len(macKey) != 20 { - t.Errorf("macKey length = %d, want 20", len(macKey)) - } - - // Deterministic: same input → same output. - encKey2, macKey2 := deriveKeys(sharedSecret) - if string(encKey) != string(encKey2) || string(macKey) != string(macKey2) { - t.Error("deriveKeys is not deterministic") - } - - // Different secrets → different keys. - other := make([]byte, dhKeySize) - encKeyOther, _ := deriveKeys(other) - if string(encKey) == string(encKeyOther) { - t.Error("different secrets produced the same encKey") - } -} - -func TestBuildCredentialsBlob(t *testing.T) { - blob := buildCredentialsBlob("alice", "tok123") - - // The blob must be non-empty and parseable back. - creds, err := parseCredentialsBlob(blob) - if err != nil { - t.Fatalf("parseCredentialsBlob: %v", err) - } - if creds.username != "alice" { - t.Errorf("username = %q, want %q", creds.username, "alice") - } - if string(creds.authData) != "tok123" { - t.Errorf("authData = %q, want %q", string(creds.authData), "tok123") - } - if creds.authType != 4 { - t.Errorf("authType = %d, want 4 (AUTHENTICATION_SPOTIFY_TOKEN)", creds.authType) - } -} - -func TestEncryptDecryptBlob(t *testing.T) { - sharedSecret := make([]byte, dhKeySize) - for i := range sharedSecret { - sharedSecret[i] = byte(42 + i) - } - encKey, macKey := deriveKeys(sharedSecret) - - plaintext := []byte("hello spotify world") - - encrypted, err := encryptBlob(encKey, macKey, plaintext) - if err != nil { - t.Fatalf("encryptBlob: %v", err) - } - - decrypted, err := decryptBlob(encKey, macKey, encrypted) - if err != nil { - t.Fatalf("decryptBlob: %v", err) - } - - if string(decrypted) != string(plaintext) { - t.Errorf("round-trip mismatch: got %q, want %q", decrypted, plaintext) - } - - // Tampered checksum must fail. - tampered := make([]byte, len(encrypted)) - copy(tampered, encrypted) - tampered[len(tampered)-1] ^= 0xff - if _, err := decryptBlob(encKey, macKey, tampered); err == nil { - t.Error("expected error on tampered checksum, got nil") - } -} - // TestPushSpotifyCredentials_FullRoundTrip starts a mock "speaker" ZeroConf server, // has it generate its own DH key pair, and verifies that the client correctly // encrypts and delivers the Spotify credentials. func TestPushSpotifyCredentials_FullRoundTrip(t *testing.T) { - // Speaker-side: generate a DH key pair. - speakerPrivate, speakerPublicBytes, err := generateDHKeyPair() + speakerPrivate, speakerPublicBytes, err := zeroconf.GenerateDHKeyPair() if err != nil { t.Fatalf("speaker keygen: %v", err) } @@ -174,11 +54,10 @@ func TestPushSpotifyCredentials_FullRoundTrip(t *testing.T) { return } - // Decrypt using the speaker's DH private key. - shared := computeSharedSecret(speakerPrivate, clientKeyBytes) - encKey, macKey := deriveKeys(shared) + shared := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes) + encKey, macKey := zeroconf.DeriveKeys(shared) - plaintext, err := decryptBlob(encKey, macKey, blobBytes) + plaintext, err := zeroconf.DecryptBlob(encKey, macKey, blobBytes) if err != nil { http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest) return @@ -219,12 +98,6 @@ func TestPushSpotifyCredentials_FullRoundTrip(t *testing.T) { } } -type parsedCredentials struct { - username string - authType int - authData []byte -} - // TestPushSpotifyCredentials_FallbackOnGetInfoFailure verifies that when getInfo // returns a non-200 response (older firmware without DH support), PushSpotifyCredentials // falls back to the simplified tokenType=accesstoken POST. @@ -277,7 +150,13 @@ func TestPushSpotifyCredentials_FallbackOnGetInfoFailure(t *testing.T) { } } -// parseCredentialsBlob is the inverse of buildCredentialsBlob, used in tests. +type parsedCredentials struct { + username string + authType int + authData []byte +} + +// parseCredentialsBlob is the inverse of BuildCredentialsBlob, used in tests. func parseCredentialsBlob(data []byte) (*parsedCredentials, error) { var r parsedCredentials i := 0 @@ -320,4 +199,4 @@ func readProtoVarint(data []byte) (uint64, int) { } } return 0, len(data) -} +} \ No newline at end of file diff --git a/pkg/service/zeroconf/zeroconf.go b/pkg/service/zeroconf/zeroconf.go new file mode 100644 index 0000000..3579526 --- /dev/null +++ b/pkg/service/zeroconf/zeroconf.go @@ -0,0 +1,300 @@ +// Package zeroconf implements the Spotify Connect ZeroConf DH key exchange +// protocol used to push OAuth credentials to SoundTouch speakers. +// Both Spotify and Amazon Music use the same protocol with authType 4. +package zeroconf + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "time" +) + +// AuthTypeOAuthToken is the protobuf auth_type value for OAuth token credentials +// (AUTHENTICATION_SPOTIFY_TOKEN = 4). Both Spotify and Amazon use this value. +const AuthTypeOAuthToken uint64 = 4 + +// dhPrimeBytes is the 768-bit MODP Group 1 prime from RFC 2409 §6.1. +var dhPrimeBytes = []byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, 0x34, + 0xc4, 0xc6, 0x66, 0x28, 0xb8, 0x0d, 0xc1, 0xcd, + 0x12, 0x90, 0x24, 0xe0, 0x88, 0xa6, 0x7c, 0xc7, + 0x40, 0x20, 0xbb, 0xea, 0x63, 0xb1, 0x39, 0xb2, + 0x25, 0x14, 0xa0, 0x87, 0x98, 0xe3, 0x40, 0x4d, + 0xde, 0xf9, 0x51, 0x9b, 0x3c, 0xd3, 0xa4, 0x31, + 0xb3, 0x02, 0xb0, 0xa6, 0xdf, 0x25, 0xf1, 0x43, + 0x74, 0xfe, 0x13, 0x56, 0xd6, 0xd5, 0x1c, 0x24, + 0x5e, 0x48, 0x5b, 0x57, 0x66, 0x25, 0xe7, 0xec, + 0x6f, 0x44, 0xc4, 0x2e, 0x9a, 0x63, 0xa3, 0x62, + 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, +} + +var dhPrime = new(big.Int).SetBytes(dhPrimeBytes) +var dhGenerator = big.NewInt(2) + +const dhKeySize = 96 // bytes, matches the 768-bit prime + +type getInfoResponse struct { + PublicKey string `json:"publicKey"` +} + +// GenerateDHKeyPair generates a fresh DH private key and derives the public key. +// Both keys are padded to dhKeySize bytes (big-endian). +func GenerateDHKeyPair() (privateKey *big.Int, publicKeyBytes []byte, err error) { + privBytes := make([]byte, dhKeySize) + if _, err = rand.Read(privBytes); err != nil { + return + } + + privateKey = new(big.Int).SetBytes(privBytes) + pub := new(big.Int).Exp(dhGenerator, privateKey, dhPrime) + publicKeyBytes = padBigInt(pub, dhKeySize) + + return +} + +// ComputeSharedSecret computes DH(remotePublicKey, privateKey) mod prime. +func ComputeSharedSecret(privateKey *big.Int, remotePublicKeyBytes []byte) []byte { + remote := new(big.Int).SetBytes(remotePublicKeyBytes) + shared := new(big.Int).Exp(remote, privateKey, dhPrime) + + return padBigInt(shared, dhKeySize) +} + +// DeriveKeys produces a 16-byte AES key and a 20-byte HMAC key from the shared secret. +func DeriveKeys(sharedSecret []byte) (encKey, macKey []byte) { + h := sha1.Sum(sharedSecret) + baseKey := h[:16] + + hEnc := hmac.New(sha1.New, baseKey) + hEnc.Write([]byte("encryption")) + encKey = hEnc.Sum(nil)[:16] + + hMac := hmac.New(sha1.New, baseKey) + hMac.Write([]byte("checksum")) + macKey = hMac.Sum(nil) + + return +} + +// BuildCredentialsBlob encodes login credentials as a minimal protobuf +// LoginCredentials message (username=1, typ=5, auth_data=4). +// Pass AuthTypeOAuthToken for both Spotify and Amazon OAuth flows. +func BuildCredentialsBlob(username, accessToken string, authType uint64) []byte { + var buf bytes.Buffer + + // field 1 (username), wire type 2 + buf.WriteByte(0x0a) + writeVarint(&buf, uint64(len(username))) + buf.WriteString(username) + + // field 5 (typ), wire type 0 + buf.WriteByte(0x28) + writeVarint(&buf, authType) + + // field 4 (auth_data), wire type 2 + buf.WriteByte(0x22) + writeVarint(&buf, uint64(len(accessToken))) + buf.WriteString(accessToken) + + return buf.Bytes() +} + +// EncryptBlob encrypts plaintext using AES-128-CTR with an HMAC-SHA1 checksum. +// Returns [16-byte IV][ciphertext][20-byte HMAC]. +func EncryptBlob(encKey, macKey, plaintext []byte) ([]byte, error) { + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, err + } + + ciphertext := make([]byte, len(plaintext)) + cipher.NewCTR(block, iv).XORKeyStream(ciphertext, plaintext) + + mac := hmac.New(sha1.New, macKey) + mac.Write(ciphertext) + + out := make([]byte, 0, aes.BlockSize+len(ciphertext)+20) + out = append(out, iv...) + out = append(out, ciphertext...) + out = append(out, mac.Sum(nil)...) + + return out, nil +} + +// DecryptBlob reverses EncryptBlob: verifies the HMAC then decrypts. +func DecryptBlob(encKey, macKey, blob []byte) ([]byte, error) { + const overhead = aes.BlockSize + 20 // IV + HMAC + if len(blob) < overhead { + return nil, fmt.Errorf("blob too short (%d bytes)", len(blob)) + } + + iv := blob[:aes.BlockSize] + ciphertext := blob[aes.BlockSize : len(blob)-20] + gotMAC := blob[len(blob)-20:] + + mac := hmac.New(sha1.New, macKey) + mac.Write(ciphertext) + + if !hmac.Equal(mac.Sum(nil), gotMAC) { + return nil, fmt.Errorf("blob HMAC verification failed") + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, err + } + + plaintext := make([]byte, len(ciphertext)) + cipher.NewCTR(block, iv).XORKeyStream(plaintext, ciphertext) + + return plaintext, nil +} + +// GetInfo fetches the speaker's DH public key via GET ?action=getInfo. +func GetInfo(zcBaseURL string) ([]byte, error) { + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.Get(zcBaseURL + "?action=getInfo") + if err != nil { + return nil, fmt.Errorf("getInfo: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("getInfo: status %d", resp.StatusCode) + } + + var info getInfoResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&info); decodeErr != nil { + return nil, fmt.Errorf("getInfo: decode: %w", decodeErr) + } + + if info.PublicKey == "" { + return nil, fmt.Errorf("getInfo: empty publicKey") + } + + // Accept both standard and URL-safe base64. + pubKey, err := base64.StdEncoding.DecodeString(info.PublicKey) + if err != nil { + pubKey, err = base64.URLEncoding.DecodeString(info.PublicKey) + if err != nil { + return nil, fmt.Errorf("getInfo: invalid base64 publicKey: %w", err) + } + } + + return pubKey, nil +} + +// PushCredentials pushes OAuth credentials to a speaker using the ZeroConf DH +// key exchange protocol. If getInfo fails (older firmware without DH support), +// it falls back to the simplified tokenType=accesstoken approach. +// zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc". +func PushCredentials(zcBaseURL, username, accessToken string) error { + speakerPublicKey, err := GetInfo(zcBaseURL) + if err != nil { + log.Printf("[ZeroConf] getInfo failed (%v), falling back to simplified token push", err) + return pushSimplifiedToken(zcBaseURL, username, accessToken) + } + + privateKey, ourPublicKeyBytes, err := GenerateDHKeyPair() + if err != nil { + return fmt.Errorf("pushCredentials: keygen: %w", err) + } + + sharedSecret := ComputeSharedSecret(privateKey, speakerPublicKey) + encKey, macKey := DeriveKeys(sharedSecret) + + plaintext := BuildCredentialsBlob(username, accessToken, AuthTypeOAuthToken) + + encryptedBlob, err := EncryptBlob(encKey, macKey, plaintext) + if err != nil { + return fmt.Errorf("pushCredentials: encrypt: %w", err) + } + + data := url.Values{} + data.Set("userName", username) + data.Set("blob", base64.StdEncoding.EncodeToString(encryptedBlob)) + data.Set("clientKey", base64.StdEncoding.EncodeToString(ourPublicKeyBytes)) + + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.PostForm(zcBaseURL+"?action=addUser", data) + if err != nil { + return fmt.Errorf("pushCredentials: addUser: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pushCredentials: addUser status %d: %s", resp.StatusCode, body) + } + + return nil +} + +// pushSimplifiedToken is the fallback for firmware that does not support DH +// key exchange. It sends the raw OAuth access token directly as the blob. +func pushSimplifiedToken(zcBaseURL, username, accessToken string) error { + data := url.Values{} + data.Set("userName", username) + data.Set("blob", accessToken) + data.Set("clientKey", "") + data.Set("tokenType", "accesstoken") + + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.PostForm(zcBaseURL+"?action=addUser", data) + if err != nil { + return fmt.Errorf("pushSimplifiedToken: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pushSimplifiedToken: status %d: %s", resp.StatusCode, body) + } + + return nil +} + +func padBigInt(n *big.Int, size int) []byte { + b := n.Bytes() + if len(b) >= size { + return b + } + + out := make([]byte, size) + copy(out[size-len(b):], b) + + return out +} + +func writeVarint(buf *bytes.Buffer, v uint64) { + for v >= 0x80 { + buf.WriteByte(byte(v) | 0x80) + v >>= 7 + } + + buf.WriteByte(byte(v)) +} \ No newline at end of file diff --git a/pkg/service/zeroconf/zeroconf_test.go b/pkg/service/zeroconf/zeroconf_test.go new file mode 100644 index 0000000..6e70d2e --- /dev/null +++ b/pkg/service/zeroconf/zeroconf_test.go @@ -0,0 +1,314 @@ +package zeroconf + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGenerateDHKeyPair(t *testing.T) { + priv1, pub1, err := GenerateDHKeyPair() + if err != nil { + t.Fatalf("GenerateDHKeyPair: %v", err) + } + if priv1 == nil || len(pub1) == 0 { + t.Fatal("expected non-nil private key and non-empty public key") + } + if len(pub1) != dhKeySize { + t.Errorf("public key length = %d, want %d", len(pub1), dhKeySize) + } + + // Two calls must produce different key pairs. + _, pub2, err := GenerateDHKeyPair() + if err != nil { + t.Fatalf("GenerateDHKeyPair second call: %v", err) + } + if string(pub1) == string(pub2) { + t.Error("two key-pair generations produced identical public keys") + } +} + +func TestDHCommutativity(t *testing.T) { + // DH shared secret must be symmetric: A's secret == B's secret. + privA, pubA, err := GenerateDHKeyPair() + if err != nil { + t.Fatal(err) + } + privB, pubB, err := GenerateDHKeyPair() + if err != nil { + t.Fatal(err) + } + + secretA := ComputeSharedSecret(privA, pubB) + secretB := ComputeSharedSecret(privB, pubA) + + if string(secretA) != string(secretB) { + t.Error("DH shared secrets are not equal (commutativity broken)") + } +} + +func TestDeriveKeys(t *testing.T) { + sharedSecret := make([]byte, dhKeySize) + for i := range sharedSecret { + sharedSecret[i] = byte(i) + } + + encKey, macKey := DeriveKeys(sharedSecret) + + if len(encKey) != 16 { + t.Errorf("encKey length = %d, want 16", len(encKey)) + } + if len(macKey) != 20 { + t.Errorf("macKey length = %d, want 20", len(macKey)) + } + + // Deterministic: same input → same output. + encKey2, macKey2 := DeriveKeys(sharedSecret) + if string(encKey) != string(encKey2) || string(macKey) != string(macKey2) { + t.Error("DeriveKeys is not deterministic") + } + + // Different secrets → different keys. + other := make([]byte, dhKeySize) + encKeyOther, _ := DeriveKeys(other) + if string(encKey) == string(encKeyOther) { + t.Error("different secrets produced the same encKey") + } +} + +func TestBuildCredentialsBlob(t *testing.T) { + blob := BuildCredentialsBlob("alice", "tok123", AuthTypeOAuthToken) + + creds, err := parseCredentialsBlob(blob) + if err != nil { + t.Fatalf("parseCredentialsBlob: %v", err) + } + if creds.username != "alice" { + t.Errorf("username = %q, want %q", creds.username, "alice") + } + if string(creds.authData) != "tok123" { + t.Errorf("authData = %q, want %q", string(creds.authData), "tok123") + } + if uint64(creds.authType) != AuthTypeOAuthToken { + t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", creds.authType, AuthTypeOAuthToken) + } +} + +func TestEncryptDecryptBlob(t *testing.T) { + sharedSecret := make([]byte, dhKeySize) + for i := range sharedSecret { + sharedSecret[i] = byte(42 + i) + } + encKey, macKey := DeriveKeys(sharedSecret) + + plaintext := []byte("hello zeroconf world") + + encrypted, err := EncryptBlob(encKey, macKey, plaintext) + if err != nil { + t.Fatalf("EncryptBlob: %v", err) + } + + decrypted, err := DecryptBlob(encKey, macKey, encrypted) + if err != nil { + t.Fatalf("DecryptBlob: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("round-trip mismatch: got %q, want %q", decrypted, plaintext) + } + + // Tampered checksum must fail. + tampered := make([]byte, len(encrypted)) + copy(tampered, encrypted) + tampered[len(tampered)-1] ^= 0xff + if _, err := DecryptBlob(encKey, macKey, tampered); err == nil { + t.Error("expected error on tampered checksum, got nil") + } +} + +func TestPushCredentials_FullRoundTrip(t *testing.T) { + speakerPrivate, speakerPublicBytes, err := GenerateDHKeyPair() + if err != nil { + t.Fatalf("speaker keygen: %v", err) + } + + type received struct { + username string + authData string + authType int + } + var got received + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getInfo": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 101, + "statusString": "OK", + "publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes), + }) + + case "addUser": + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob")) + if err != nil { + http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest) + return + } + clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey")) + if err != nil { + http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest) + return + } + + shared := ComputeSharedSecret(speakerPrivate, clientKeyBytes) + encKey, macKey := DeriveKeys(shared) + + plaintext, err := DecryptBlob(encKey, macKey, blobBytes) + if err != nil { + http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest) + return + } + + creds, err := parseCredentialsBlob(plaintext) + if err != nil { + http.Error(w, "parse failed: "+err.Error(), http.StatusBadRequest) + return + } + + got.username = creds.username + got.authData = string(creds.authData) + got.authType = creds.authType + w.WriteHeader(http.StatusOK) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + const wantUsername = "user@example.com" + const wantToken = "eyJhbGciOiJSUzI1NiJ9.access-token" + + if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushCredentials: %v", err) + } + + if got.username != wantUsername { + t.Errorf("username = %q, want %q", got.username, wantUsername) + } + if got.authData != wantToken { + t.Errorf("authData = %q, want %q", got.authData, wantToken) + } + if uint64(got.authType) != AuthTypeOAuthToken { + t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, AuthTypeOAuthToken) + } +} + +func TestPushCredentials_FallbackOnGetInfoFailure(t *testing.T) { + var receivedForm map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getInfo": + http.Error(w, "not supported", http.StatusNotFound) + case "addUser": + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + receivedForm = map[string]string{ + "userName": r.FormValue("userName"), + "blob": r.FormValue("blob"), + "clientKey": r.FormValue("clientKey"), + "tokenType": r.FormValue("tokenType"), + } + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + const wantUsername = "user@example.com" + const wantToken = "raw-access-token" + + if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushCredentials: %v", err) + } + + if receivedForm == nil { + t.Fatal("addUser was never called") + } + if receivedForm["userName"] != wantUsername { + t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername) + } + if receivedForm["blob"] != wantToken { + t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken) + } + if receivedForm["tokenType"] != "accesstoken" { + t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken") + } + if receivedForm["clientKey"] != "" { + t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"]) + } +} + +// parseCredentialsBlob is the inverse of BuildCredentialsBlob, used in tests. +func parseCredentialsBlob(data []byte) (*parsedCredentials, error) { + var r parsedCredentials + i := 0 + for i < len(data) { + tag := data[i] + i++ + fieldNum := tag >> 3 + wireType := tag & 0x07 + switch wireType { + case 0: // varint + val, n := readProtoVarint(data[i:]) + i += n + if fieldNum == 5 { + r.authType = int(val) + } + case 2: // length-delimited + length, n := readProtoVarint(data[i:]) + i += n + value := data[i : i+int(length)] + i += int(length) + switch fieldNum { + case 1: + r.username = string(value) + case 4: + r.authData = value + } + default: + return nil, fmt.Errorf("unsupported wire type %d at offset %d", wireType, i-1) + } + } + return &r, nil +} + +type parsedCredentials struct { + username string + authType int + authData []byte +} + +func readProtoVarint(data []byte) (uint64, int) { + var val uint64 + for i, b := range data { + val |= uint64(b&0x7f) << (7 * uint(i)) + if b&0x80 == 0 { + return val, i + 1 + } + } + return 0, len(data) +} \ No newline at end of file