mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
406180e4ce
commit
466e9eca97
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user