mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
9412b5ffa0
commit
968312aa39
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user