This commit is contained in:
Tobias Gesellchen
2026-04-28 17:57:46 +02:00
parent 376c85a641
commit c6fbc45be5
7 changed files with 40 additions and 15 deletions
@@ -71,7 +71,6 @@ The only native installable phone app in the ecosystem. Pairs with the Überbös
### 4. SoundTouch Hybrid 2026
**[github.com/TJGigs/Bose-SoundTouch-Hybrid-2026](https://github.com/TJGigs/Bose-SoundTouch-Hybrid-2026)**
(V3 variant: [github.com/TJGigs/Bose-SoundTouch-Hybrid-2026-V3](https://github.com/TJGigs/Bose-SoundTouch-Hybrid-2026-V3))
| | |
|---|---|
| Language | Node.js (JavaScript) |
+8 -4
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"math/rand"
@@ -23,6 +24,9 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
// ErrGroupNotFound is returned when no group is found for a given device.
var ErrGroupNotFound = errors.New("group not found")
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
@@ -1933,7 +1937,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
return nil, ErrGroupNotFound
}
return nil, err
@@ -1961,7 +1965,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
}
}
return nil, nil
return nil, ErrGroupNotFound
}
// AddGroup saves a new group to disk and returns its generated ID.
@@ -2002,8 +2006,8 @@ func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Grou
}
var g models.Group
if err := xml.Unmarshal(data, &g); err != nil {
return nil, err
if xmlErr := xml.Unmarshal(data, &g); xmlErr != nil {
return nil, xmlErr
}
g.Name = newName
+3 -3
View File
@@ -687,7 +687,7 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, r *http.Request)
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
group, err := s.ds.GetGroupForDevice(account, device)
if err != nil || group == nil {
if err != nil {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
@@ -732,7 +732,7 @@ func (s *Server) HandleMargeAddGroup(w http.ResponseWriter, r *http.Request) {
}
var group models.Group
if err := xml.Unmarshal(body, &group); err != nil {
if xmlErr := xml.Unmarshal(body, &group); xmlErr != nil {
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
@@ -773,7 +773,7 @@ func (s *Server) HandleMargeModifyGroup(w http.ResponseWriter, r *http.Request)
}
var req models.Group
if err := xml.Unmarshal(body, &req); err != nil {
if xmlErr := xml.Unmarshal(body, &req); xmlErr != nil {
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
+1
View File
@@ -113,6 +113,7 @@ func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
preview := body
truncated := ""
if len(preview) > 512 {
preview = preview[:512]
truncated = "…"
+1
View File
@@ -470,6 +470,7 @@ func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string
} else {
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
}
return spotify.PushSpotifyCredentials(zcURL, username, accessToken)
}
+24 -3
View File
@@ -51,9 +51,11 @@ func generateDHKeyPair() (privateKey *big.Int, publicKeyBytes []byte, err error)
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
}
@@ -61,6 +63,7 @@ func generateDHKeyPair() (privateKey *big.Int, publicKeyBytes []byte, err error)
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)
}
@@ -76,6 +79,7 @@ func deriveKeys(sharedSecret []byte) (encKey, macKey []byte) {
hMac := hmac.New(sha1.New, baseKey)
hMac.Write([]byte("checksum"))
macKey = hMac.Sum(nil)
return
}
@@ -125,6 +129,7 @@ func encryptBlob(encKey, macKey, plaintext []byte) ([]byte, error) {
out = append(out, iv...)
out = append(out, ciphertext...)
out = append(out, mac.Sum(nil)...)
return out, nil
}
@@ -141,6 +146,7 @@ func decryptBlob(encKey, macKey, blob []byte) ([]byte, error) {
mac := hmac.New(sha1.New, macKey)
mac.Write(ciphertext)
if !hmac.Equal(mac.Sum(nil), gotMAC) {
return nil, fmt.Errorf("blob HMAC verification failed")
}
@@ -152,16 +158,19 @@ func decryptBlob(encKey, macKey, blob []byte) ([]byte, error) {
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 {
@@ -169,9 +178,10 @@ func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) {
}
var info zcGetInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("getInfo: decode: %w", err)
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")
}
@@ -184,6 +194,7 @@ func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) {
return nil, fmt.Errorf("getInfo: invalid base64 publicKey: %w", err)
}
}
return pubKey, nil
}
@@ -208,6 +219,7 @@ func PushSpotifyCredentials(zcBaseURL, username, accessToken string) error {
encKey, macKey := deriveKeys(sharedSecret)
plaintext := buildCredentialsBlob(username, accessToken)
encryptedBlob, err := encryptBlob(encKey, macKey, plaintext)
if err != nil {
return fmt.Errorf("pushSpotifyCredentials: encrypt: %w", err)
@@ -219,16 +231,19 @@ func PushSpotifyCredentials(zcBaseURL, username, accessToken string) error {
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
}
@@ -243,16 +258,19 @@ func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
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
}
@@ -261,8 +279,10 @@ func padBigInt(n *big.Int, size int) []byte {
if len(b) >= size {
return b
}
out := make([]byte, size)
copy(out[size-len(b):], b)
return out
}
@@ -271,5 +291,6 @@ func writeVarint(buf *bytes.Buffer, v uint64) {
buf.WriteByte(byte(v) | 0x80)
v >>= 7
}
buf.WriteByte(byte(v))
}
}
+3 -4
View File
@@ -152,9 +152,9 @@ func TestPushSpotifyCredentials_FullRoundTrip(t *testing.T) {
case "getInfo":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": 101,
"status": 101,
"statusString": "OK",
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
})
case "addUser":
@@ -311,7 +311,6 @@ func parseCredentialsBlob(data []byte) (*parsedCredentials, error) {
return &r, nil
}
func readProtoVarint(data []byte) (uint64, int) {
var val uint64
for i, b := range data {
@@ -321,4 +320,4 @@ func readProtoVarint(data []byte) (uint64, int) {
}
}
return 0, len(data)
}
}