// 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" "errors" "fmt" "io" "log" "math/big" "net" "net/http" "net/url" "strconv" "strings" "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 // ErrAddUserNoOp signals a benign 404-with-empty-body reply from the speaker's // ?action=addUser endpoint. SoundTouch firmware uses that exact response shape // to mean "no transition required" — typically because the requested // activeUser is already the active one. It is NOT a credential or transport // failure; the speaker silently kept its current state. Callers that have // already written the authoritative source record to marge (the path // presets/playback actually go through) should treat this as success. var ErrAddUserNoOp = errors.New("zeroconf: addUser no-op (speaker already in target state)") // 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 } // validateZcHost parses host as a literal IP and ensures it is on the local // network. Speakers live on the LAN; rejecting non-local addresses prevents // SSRF gadgets that could trick the service into reaching arbitrary hosts. // // Only literal IPs are accepted — no DNS/mDNS hostnames. If the caller has a // hostname, resolve it first and pass the resulting IP. Doing the lookup here // would re-introduce the SSRF surface, because malicious DNS could point a // *.local name at a public address between the lookup and the request. func validateZcHost(host string) (net.IP, error) { if host == "" { return nil, fmt.Errorf("zeroconf host must not be empty") } ip := net.ParseIP(host) if ip == nil { return nil, fmt.Errorf( "zeroconf host %q must be a literal IP — resolve the hostname first "+ "(e.g. `getent hosts %s` or `dig +short %s`) and retry with the resolved address", host, host, host) } if !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() { return nil, fmt.Errorf( "zeroconf host %q is not on a local network — only loopback (127.0.0.0/8, ::1), "+ "RFC1918 private (10/8, 172.16/12, 192.168/16) and link-local (169.254/16, fe80::/10) "+ "addresses are accepted", host) } return ip, nil } // validateZcPort checks that port is a decimal integer in [1, 65535] and // returns the canonical decimal string produced by strconv.Itoa. The // round-trip through an integer breaks static-analysis taint chains while // also rejecting strings like "0", "-1", or "foo" that would silently // produce a broken URL. // An empty port string is allowed and returned unchanged — callers that omit // the port rely on the scheme default. func validateZcPort(port string) (string, error) { if port == "" { return "", nil } p, err := strconv.Atoi(port) if err != nil || p < 1 || p > 65535 { return "", fmt.Errorf("zeroconf port %q must be a decimal integer in [1, 65535]", port) } return strconv.Itoa(p), nil } // buildZcBase constructs the ZeroConf base URL from a validated IP and port. // The path is always the literal "/zc" — no user-supplied path component ever // flows here, which is what satisfies CodeQL's go/request-forgery model. // port may be empty, in which case the scheme default applies. func buildZcBase(ip net.IP, port string) (*url.URL, error) { var host string switch { case port != "": safePort, err := validateZcPort(port) if err != nil { return nil, err } // net.JoinHostPort brackets IPv6 addresses automatically. host = net.JoinHostPort(ip.String(), safePort) case ip.To4() == nil: // IPv6 address without a port must be bracketed in a URL host field. host = "[" + ip.String() + "]" default: host = ip.String() } return &url.URL{ Scheme: "http", Host: host, Path: "/zc", }, nil } // withAction returns the validated base URL with ?action= appended. func withAction(base *url.URL, action string) string { u := *base q := u.Query() q.Set("action", action) u.RawQuery = q.Encode() return u.String() } // GetInfo fetches the speaker's DH public key via GET ?action=getInfo. // host must be a literal private-network IP address. // port is the ZeroConf port (typically "8200"); pass "" to omit it from the URL. func GetInfo(host, port string) ([]byte, error) { ip, err := validateZcHost(host) if err != nil { return nil, fmt.Errorf("getInfo: %w", err) } base, err := buildZcBase(ip, port) if err != nil { return nil, fmt.Errorf("getInfo: %w", err) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(withAction(base, "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. // host must be a literal private-network IP address. // port is the ZeroConf port (typically "8200"); pass "" to omit it from the URL. func PushCredentials(host, port, username, accessToken string) error { ip, err := validateZcHost(host) if err != nil { return fmt.Errorf("pushCredentials: %w", err) } base, err := buildZcBase(ip, port) if err != nil { return fmt.Errorf("pushCredentials: %w", err) } speakerPublicKey, err := GetInfo(host, port) if err != nil { log.Printf("[ZeroConf] getInfo failed (%s), falling back to simplified token push", sanitizeErr(err)) return pushSimplifiedToken(host, port, 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(withAction(base, "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) if isAddUserNoOp(resp.StatusCode, body) { logAddUserNoOp("DH", base, username, resp) return ErrAddUserNoOp } logAddUserFailure("DH", base, username, resp, body) return fmt.Errorf("pushCredentials: addUser status %d: %s", resp.StatusCode, body) } return nil } // isAddUserNoOp recognises the narrow firmware pattern (status 404, empty body) // that signals "no transition required". Anything else — including 404 with a // body, or any other non-2xx — falls through to the real failure path so we // don't silently swallow genuine errors. func isAddUserNoOp(status int, body []byte) bool { return status == http.StatusNotFound && len(bytes.TrimSpace(body)) == 0 } // logAddUserNoOp emits a single line marking the benign no-op explicitly — // kept visible (not Debug-level) so the operator can correlate it with priming // runs, but worded so it's clearly not a failure. func logAddUserNoOp(path string, base *url.URL, username string, resp *http.Response) { log.Printf("[ZeroConf] addUser produced expected no-op via %s path (speaker already has activeUser=%q or equivalent state): url=%s status=%d body= — marge source registration is authoritative for preset/playback", path, sanitizeLog(username), withAction(base, "addUser"), resp.StatusCode) } // logAddUserFailure emits a single diagnostic line capturing what the speaker // said about an `?action=addUser` rejection. Bose firmware often returns 4xx // with an empty body, so the headers (libspotify version, content-type, // content-length) are the only clue about whether the speaker refused the // transition, the credential, or the action entirely. Kept verbose on purpose — // these failures are rare and worth grepping for. func logAddUserFailure(path string, base *url.URL, username string, resp *http.Response, body []byte) { ct := resp.Header.Get("Content-Type") cl := resp.Header.Get("Content-Length") server := resp.Header.Get("Server") bodySummary := strings.TrimSpace(string(body)) if bodySummary == "" { bodySummary = "" } log.Printf("[ZeroConf] addUser rejected via %s path: url=%s userName=%q status=%d server=%q content-type=%q content-length=%q body=%q", path, withAction(base, "addUser"), sanitizeLog(username), resp.StatusCode, sanitizeLog(server), sanitizeLog(ct), sanitizeLog(cl), sanitizeLog(bodySummary)) } // 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(host, port, username, accessToken string) error { ip, err := validateZcHost(host) if err != nil { return fmt.Errorf("pushSimplifiedToken: %w", err) } base, err := buildZcBase(ip, port) if err != nil { return fmt.Errorf("pushSimplifiedToken: %w", err) } 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(withAction(base, "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) if isAddUserNoOp(resp.StatusCode, body) { logAddUserNoOp("simplified", base, username, resp) return ErrAddUserNoOp } logAddUserFailure("simplified", base, username, 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)) }