Implement Spotify Connect ZeroConf DH blob encryption (#192)

Replace the simplified tokenType=accesstoken push with the full Spotify
Connect ZeroConf protocol: GET getInfo to fetch the speaker's 768-bit DH
public key, derive AES-128-CTR + HMAC-SHA1 keys from the shared secret,
and POST an encrypted LoginCredentials protobuf blob. Speakers that
receive a proper blob can self-refresh their Spotify session
independently, eliminating the need for periodic re-priming on token
expiry. Falls back to the raw token approach automatically when getInfo
fails, preserving compatibility with older firmware.

SHA1 is mandated by the Spotify Connect ZeroConf protocol spec for DH key derivation. This cannot be changed without breaking protocol compatibility.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-04-28 15:34:05 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 9412b5ffa0
commit 968312aa39
5 changed files with 656 additions and 48 deletions
+22 -13
View File
@@ -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 `<group/>` 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
+34 -6
View File
@@ -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.
+1 -29
View File
@@ -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) {
+275
View File
@@ -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))
}
+324
View File
@@ -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)
}