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
+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)
}