diff --git a/docs/PARITY-SOUNDCORK.md b/docs/PARITY-SOUNDCORK.md index 25eb174..18cd864 100644 --- a/docs/PARITY-SOUNDCORK.md +++ b/docs/PARITY-SOUNDCORK.md @@ -8,13 +8,14 @@ This document provides a comparative analysis of the current Go implementation a ## 2. Functional Comparison -| Feature | Bose-SoundTouch (Go) | SoundCork (Python) | -|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------| -| **Group Management** | Placeholder handlers (return `` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. | -| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. | -| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. | -| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). | -| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. | +| Feature | Bose-SoundTouch (Go) | SoundCork (Python) | +|:---------------------|:----------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------| +| **Group Management** | Full CRUD: `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` with XML datastore persistence (`Group_{id}.xml`). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. | +| **ZeroConf Priming** | Full DH key exchange + encrypted blob; fallback to `tokenType=accesstoken` for older firmware. | Simple `tokenType=accesstoken` push only; token expires after ~60 minutes. | +| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. | +| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. | +| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). | +| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. | ## 3. Key Strengths of SoundCork - **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs. @@ -23,18 +24,26 @@ This document provides a comparative analysis of the current Go implementation a ## 4. Suggested Implementation Steps for Bose-SoundTouch -### A. Implement Full Group Support (High Priority) -- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`. -- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones. +### ✅ A. Implement Full Group Support (Completed) +- `POST /group`, `POST /group/{id}`, `DELETE /group/{id}` implemented in `pkg/service/handlers/handlers_marge.go`. +- Group CRUD persisted in XML datastore (`Group_{id}.xml`) via `pkg/service/datastore/datastore.go`. +- `GET /group` on device registration reads the group the device belongs to. -### B. Modularize BMX Registry (Medium Priority) +### ✅ B. Proper ZeroConf Spotify Blob (Completed) +- Full Spotify Connect ZeroConf protocol implemented in `pkg/service/spotify/zeroconf.go`. +- Flow: `getInfo` (fetch speaker DH public key) → 768-bit DH key exchange → AES-128-CTR encrypted `LoginCredentials` protobuf blob → `addUser`. +- Speaker can self-refresh credentials independently; no periodic re-priming needed for token expiry. +- Automatic fallback to `tokenType=accesstoken` if `getInfo` fails (older firmware without DH support). +- See `docs/concepts/spotify-priming-strategy.md` for full protocol details. + +### C. Modularize BMX Registry (Medium Priority) - Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file. - Allow users to customize which mocked services are advertised to the speaker. -### C. Enhanced Source Management (Medium Priority) +### D. Enhanced Source Management (Medium Priority) - Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`. -### D. Basic Admin Web UI (Low Priority) +### E. Basic Admin Web UI (Low Priority) - Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls. ## 5. Summary diff --git a/docs/concepts/spotify-priming-strategy.md b/docs/concepts/spotify-priming-strategy.md index 3b3d901..fc2832f 100644 --- a/docs/concepts/spotify-priming-strategy.md +++ b/docs/concepts/spotify-priming-strategy.md @@ -4,7 +4,31 @@ This document outlines the strategy for ensuring Bose SoundTouch devices are cor ## Overview -To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token. +To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves a two-step exchange with the speaker's ZeroConf API (port 8200): + +1. **`getInfo`** — retrieve the speaker's Diffie-Hellman public key and device metadata. +2. **`addUser`** — push encrypted Spotify credentials using the shared DH secret. + +This is the standard Spotify Connect ZeroConf protocol. Once the speaker holds a properly encrypted credential blob it can independently authenticate with Spotify's servers and refresh its own session without any further involvement from AfterTouch. + +### ZeroConf Protocol + +The current implementation follows the full Spotify Connect ZeroConf protocol (`pkg/service/spotify/zeroconf.go`): + +1. `GET http://{ip}:8200/zc?action=getInfo` → parse `publicKey` (base64 DH key, 768-bit Oakley Group 1 prime) from the response. +2. Generate a client DH key pair using the same group parameters. +3. Compute `sharedSecret = DH(clientPrivate, speakerPublicKey)`. +4. Derive keys: `baseKey = SHA1(sharedSecret)[:16]`, then HMAC-SHA1 with labels `"encryption"` and `"checksum"`. +5. Encrypt a protobuf-encoded `LoginCredentials` blob (username, `AUTHENTICATION_SPOTIFY_TOKEN=4`, access token) using AES-128-CTR + HMAC-SHA1 checksum. +6. `POST http://{ip}:8200/zc?action=addUser` with `blob={encryptedBlob}`, `clientKey={clientPublicKeyBase64}`. + +The speaker decrypts the blob, stores long-lived credentials, and can handle token refresh with Spotify independently. No periodic re-priming is required for token expiry. + +The algorithm is based on [librespot](https://github.com/librespot-org/librespot) (Rust reference implementation). + +### Fallback for Older Firmware + +If `getInfo` fails (e.g. firmware that does not implement the DH exchange), `PushSpotifyCredentials` automatically falls back to the simplified `tokenType=accesstoken` approach: the raw OAuth access token is sent as the `blob` with an empty `clientKey`. This token expires after ~60 minutes and the speaker cannot self-refresh, so periodic re-priming is required in that case. AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing. @@ -53,6 +77,8 @@ The logic for account management and device interaction remains decoupled: 4. AfterTouch pushes a fresh token from the Spotify Service. 5. UI reflects that the device is "Managed by AfterTouch" and healthy. +> **Note:** With the proper encrypted-blob flow now in place, the watchdog is only needed for the "speaker reboots and loses state" case — not for token expiry. Speakers running older firmware that trigger the `tokenType=accesstoken` fallback still require periodic re-priming (~45 min) because the raw access token expires. + ### Manual Override Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device. @@ -76,9 +102,11 @@ As AfterTouch moves to the Server-Centric model, we will: 2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts. 3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text. -## Implementation Roadmap (Conceptual) +## Implementation Roadmap -1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks. -2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API. -3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state. -4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons. +1. ✅ **Server-Side Priming Logic:** `PrimeDeviceWithSpotify(ip)` and `pushSpotifyTokenToDevice` in `pkg/service/handlers/server.go`. Triggered on device registration (marge handlers) and via the manual `HandleMgmtPrimeDevice` endpoint. +2. ✅ **Discovery Hook:** `handleDiscoveredDevice` calls `PrimeDeviceWithSpotify` when a speaker is found. +3. ✅ **Proper ZeroConf Blob:** Full DH key exchange + AES-128-CTR encrypted `LoginCredentials` blob implemented in `pkg/service/spotify/zeroconf.go`. Automatically falls back to `tokenType=accesstoken` if `getInfo` fails (older firmware). +4. ⬜ **Watchdog / Session Refresh:** Background timer to re-prime all known devices on a schedule. Only strictly needed for older firmware (fallback path) or "speaker lost state" recovery; not required for token expiry on modern firmware. +5. ⬜ **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers. +6. ⬜ **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons. diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index d7328c7..305b4cb 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "io" "log" "net" "net/http" @@ -465,40 +464,13 @@ func (s *Server) PrimeDeviceWithSpotify(deviceIP string) { } func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error { - // ZeroConf API endpoint on the speaker var zcURL string if _, _, err := net.SplitHostPort(deviceIP); err == nil { - // If port is specified (e.g. in tests), keep it but usually it's just IP zcURL = fmt.Sprintf("http://%s/zc", deviceIP) } else { - // If no port specified, default to 8200 zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP) } - - data := url.Values{} - data.Set("action", "addUser") - 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(zcURL, data) - if err != nil { - return fmt.Errorf("POST to %s failed: %w", zcURL, err) - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("POST to %s returned status %d: %s", zcURL, resp.StatusCode, string(body)) - } - - return nil + return spotify.PushSpotifyCredentials(zcURL, username, accessToken) } func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { diff --git a/pkg/service/spotify/zeroconf.go b/pkg/service/spotify/zeroconf.go new file mode 100644 index 0000000..0a05db6 --- /dev/null +++ b/pkg/service/spotify/zeroconf.go @@ -0,0 +1,275 @@ +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 +} + +// 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 err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("getInfo: decode: %w", err) + } + 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 +} + +// 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. +// 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)) +} \ No newline at end of file diff --git a/pkg/service/spotify/zeroconf_test.go b/pkg/service/spotify/zeroconf_test.go new file mode 100644 index 0000000..3e8cf03 --- /dev/null +++ b/pkg/service/spotify/zeroconf_test.go @@ -0,0 +1,324 @@ +package spotify + +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") + + // 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() + 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 + } + + // Decrypt using the speaker's DH private key. + 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 = "spotifyuser@example.com" + const wantToken = "eyJhbGciOiJSUzI1NiJ9.access-token" + + if err := PushSpotifyCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushSpotifyCredentials: %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 got.authType != 4 { + t.Errorf("authType = %d, want 4 (AUTHENTICATION_SPOTIFY_TOKEN)", got.authType) + } +} + +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. +func TestPushSpotifyCredentials_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 = "spotifyuser@example.com" + const wantToken = "raw-access-token" + + if err := PushSpotifyCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil { + t.Fatalf("PushSpotifyCredentials: %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 +} + + +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