diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index 7f59be58..dee4b4d6 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -352,24 +352,26 @@ func (c *Client) doKAuth() error { fmt.Printf("[Wyze] K10001 received, status=%d\n", status) } - // Step 3: Send K10002 - k10002 := c.buildK10002(challenge, status) - if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10002); err != nil { - return fmt.Errorf("wyze: K10002 send failed: %w", err) + // Step 3: Send K10008 + k10008 := c.buildK10008(challenge, status) + + if err := c.conn.SendIOCtrl(tutk.KCmdChallengeResp, k10008); err != nil { + return fmt.Errorf("wyze: K10008 send failed: %w", err) } - // Step 4: Wait for K10003 + // Step 4: Wait for K10009 cmdID, data, err = c.conn.RecvIOCtrl(10 * time.Second) if err != nil { - return fmt.Errorf("wyze: K10003 recv failed: %w", err) - } - if cmdID != tutk.KCmdAuthResult { - return fmt.Errorf("wyze: expected K10003, got K%d", cmdID) + return fmt.Errorf("wyze: K10009 recv failed: %w", err) } - authResp, err := c.parseK10003(data) + if cmdID != tutk.KCmdAuthSuccess { + return fmt.Errorf("wyze: expected K10009, got K%d", cmdID) + } + + authResp, err := c.parseK10009(data) if err != nil { - return fmt.Errorf("wyze: K10003 parse failed: %w", err) + return fmt.Errorf("wyze: K10009 parse failed: %w", err) } // Parse capabilities @@ -405,11 +407,18 @@ func (c *Client) doKAuth() error { } func (c *Client) buildK10000() []byte { - buf := make([]byte, 16) + // 137 = G.711 μ-law (PCMU) + // 138 = G.711 A-law (PCMA) + // 140 = PCM 16-bit + jsonPayload := []byte(`{"cameraInfo":{"audioEncoderList":[137,138,140]}}`) + + buf := make([]byte, 16+len(jsonPayload)) buf[0] = 'H' buf[1] = 'L' buf[2] = 5 binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuth) + binary.LittleEndian.PutUint16(buf[6:], uint16(len(jsonPayload))) + copy(buf[16:], jsonPayload) return buf } @@ -437,6 +446,28 @@ func (c *Client) buildK10002(challenge []byte, status byte) []byte { return buf } +func (c *Client) buildK10008(challenge []byte, status byte) []byte { + response := crypto.GenerateChallengeResponse(challenge, c.enr, status) + openUserID := []byte(c.enr) + payloadLen := 16 + 4 + 1 + 1 + 1 + len(openUserID) + + buf := make([]byte, 16+payloadLen) + buf[0] = 'H' + buf[1] = 'L' + buf[2] = 5 // Protocol version + binary.LittleEndian.PutUint16(buf[4:], tutk.KCmdAuthWithPayload) // 10008 + binary.LittleEndian.PutUint16(buf[6:], uint16(payloadLen)) + + copy(buf[16:], response[:16]) // Challenge response + copy(buf[32:], c.uid[:4]) // UID prefix + buf[36] = 1 // Video enabled + buf[37] = 1 // Audio enabled + buf[38] = byte(len(openUserID)) + copy(buf[39:], openUserID) + + return buf +} + func (c *Client) buildK10010(mediaType byte, enabled bool) []byte { buf := make([]byte, 18) buf[0] = 'H' @@ -529,3 +560,42 @@ func (c *Client) parseK10003(data []byte) (*tutk.AuthResponse, error) { return &tutk.AuthResponse{}, nil } + +func (c *Client) parseK10009(data []byte) (*tutk.AuthResponse, error) { + if c.verbose { + fmt.Printf("[Wyze] parseK10009: received %d bytes\n", len(data)) + } + + if len(data) < 16 { + return &tutk.AuthResponse{}, nil + } + + if data[0] != 'H' || data[1] != 'L' { + return &tutk.AuthResponse{}, nil + } + + cmdID := binary.LittleEndian.Uint16(data[4:]) + textLen := binary.LittleEndian.Uint16(data[6:]) + + if cmdID != tutk.KCmdAuthSuccess { + return &tutk.AuthResponse{}, nil + } + + if len(data) > 16 && textLen > 0 { + jsonData := data[16:] + for i := range jsonData { + if jsonData[i] == '{' { + var resp tutk.AuthResponse + if err := json.Unmarshal(jsonData[i:], &resp); err == nil { + if c.verbose { + fmt.Printf("[Wyze] parseK10009: parsed JSON\n") + } + return &resp, nil + } + break + } + } + } + + return &tutk.AuthResponse{}, nil +} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 84a927ca..7526115f 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -56,6 +56,10 @@ func NewProducer(rawURL string) (*Producer, error) { func (p *Producer) Start() error { for { + if p.client.verbose { + fmt.Println("[Wyze] Reading packet...") + } + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) pkt, err := p.client.ReadPacket() if err != nil { @@ -136,6 +140,10 @@ func probe(client *Client, sd bool) ([]*core.Media, error) { var tutkAudioCodec uint16 for { + if client.verbose { + fmt.Println("[Wyze] Probing for codecs...") + } + pkt, err := client.ReadPacket() if err != nil { return nil, fmt.Errorf("wyze: probe: %w", err) diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md index 37d601ec..ed98a857 100644 --- a/pkg/wyze/tutk/README.md +++ b/pkg/wyze/tutk/README.md @@ -349,7 +349,33 @@ DTLS records are wrapped in IOTC DATA_TX (0x0407) packets for transmission and e ``` Identity: "AUTHPWD_admin" -PSK: SHA256(ENR_string) → 32 bytes +PSK: SHA256(ENR_string) → variable length (see below) +``` + +#### PSK Length Determination + +**CRITICAL**: The TUTK SDK treats the binary PSK as a NULL-terminated C string. +This means the effective PSK length is determined by the first `0x00` byte in the SHA256 hash: + +``` +hash = SHA256(ENR) +psk_length = position of first 0x00 byte in hash (or 32 if no 0x00) +psk = hash[0:psk_length] + zeros[psk_length:32] +``` + +**Example 1** - No NULL byte in hash (full 32-byte PSK): +``` +ENR: "aKzdqckqZ8HUHFe5" +SHA256: 3e5b96b8d6fc7264b531e1633de9526929d453cb47606c55d574a6e0ef5eb95f + ^^ No 0x00 byte → PSK length = 32 +``` + +**Example 2** - NULL byte at position 11 (11-byte PSK): +``` +ENR: "GkB9S7cX38GgzSC6" +SHA256: 16549c533b4e9812808f91|00|95f6edf00365266f09ea1e0328df3eee1ce127ed + ^^ 0x00 at position 11 → PSK length = 11 +PSK: 16549c533b4e9812808f91000000000000000000000000000000000000000000 ``` ### Nonce Construction diff --git a/pkg/wyze/tutk/conn.go b/pkg/wyze/tutk/conn.go index d539fce2..58f8bc9d 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/wyze/tutk/conn.go @@ -39,10 +39,9 @@ type FrameAssembler struct { } type Conn struct { - udpConn *net.UDPConn - addr *net.UDPAddr - broadcastAddrs []*net.UDPAddr - randomID []byte + udpConn *net.UDPConn + addr *net.UDPAddr + randomID []byte uid string authKey string enr string @@ -100,14 +99,18 @@ func Dial(host, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { ctx, cancel := context.WithCancel(context.Background()) - hash := sha256.Sum256([]byte(enr)) - psk := hash[:] + psk := derivePSK(enr) + + if verbose { + hash := sha256.Sum256([]byte(enr)) + fmt.Printf("[PSK] ENR: %q → SHA256: %x\n", enr, hash) + fmt.Printf("[PSK] PSK: %x\n", psk) + } c := &Conn{ - udpConn: conn, - addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, - broadcastAddrs: getBroadcastAddrs(DefaultPort, verbose), - randomID: genRandomID(), + udpConn: conn, + addr: &net.UDPAddr{IP: net.ParseIP(host), Port: DefaultPort}, + randomID: genRandomID(), uid: uid, authKey: authKey, enr: enr, @@ -394,8 +397,8 @@ func (c *Conn) discovery() error { newDiscoPkt := c.buildNewProtoPacket(0, 0, false) // NEW protocol (0xCC51, cmd=0x1002) if c.verbose { - fmt.Printf("[DISCO] Unified discovery: timeout=%v interval=%v broadcasts=%d\n", - DiscoTimeout, DiscoInterval, len(c.broadcastAddrs)) + fmt.Printf("[DISCO] Discovery: target=%s timeout=%v interval=%v\n", + c.addr, DiscoTimeout, DiscoInterval) fmt.Printf("[DISCO] SessionID=%s\n", hex.EncodeToString(c.sessionID)) fmt.Printf("[OLD] TX Discovery packet (%d bytes):\n%s", len(oldDiscoPkt), hexDump(crypto.ReverseTransCodeBlob(oldDiscoPkt))) fmt.Printf("[NEW] TX Discovery packet (%d bytes):\n%s", len(newDiscoPkt), hexDump(newDiscoPkt)) @@ -406,12 +409,9 @@ func (c *Conn) discovery() error { buf := make([]byte, MaxPacketSize) for time.Now().Before(deadline) { - // Send both discovery packets periodically if time.Since(lastSend) >= DiscoInterval { - for _, bcast := range c.broadcastAddrs { - c.udpConn.WriteToUDP(oldDiscoPkt, bcast) // OLD protocol - c.udpConn.WriteToUDP(newDiscoPkt, bcast) // NEW protocol - } + c.udpConn.WriteToUDP(oldDiscoPkt, c.addr) + c.udpConn.WriteToUDP(newDiscoPkt, c.addr) lastSend = time.Now() } @@ -424,6 +424,10 @@ func (c *Conn) discovery() error { return err } + if !addr.IP.Equal(c.addr.IP) { + continue + } + // Check for NEW protocol response (0xCC51 magic) if n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { cmd := binary.LittleEndian.Uint16(buf[4:]) @@ -448,8 +452,7 @@ func (c *Conn) discovery() error { } if c.verbose { - fmt.Printf("[NEW] Camera detected! ticket=0x%04x sessionID=%s\n", - ticket, hex.EncodeToString(c.sessionID)) + fmt.Printf("[NEW] RX Discovery Response seq=1 (%d bytes):\n%s", n, hexDump(buf[:n])) } _ = c.udpConn.SetDeadline(time.Time{}) @@ -571,7 +574,8 @@ func (c *Conn) newProtoComplete() error { if cmd == CmdNewProtoDiscovery && dir == 0xFFFF && seq == 3 { if c.verbose { - fmt.Printf("[NEW] seq=3 received, discovery complete!\n") + fmt.Printf("[NEW] RX Echo Response seq=3 (%d bytes):\n%s", n, hexDump(buf[:n])) + fmt.Printf("[NEW] Discovery complete!\n") } c.addr = addr return nil @@ -634,8 +638,13 @@ func (c *Conn) iotcReader() { return } - if addr.Port != c.addr.Port || !addr.IP.Equal(c.addr.IP) { - c.addr = addr + if !addr.IP.Equal(c.addr.IP) { + continue + } + + // Update port if camera responds from different port + if addr.Port != c.addr.Port { + c.addr.Port = addr.Port } // Check for NEW protocol (0xCC51 magic at start) @@ -823,10 +832,6 @@ func (c *Conn) worker() { } func (c *Conn) route(data []byte) { - // [channel][frameType][version_lo][version_hi][seq_lo][seq_hi]... - // channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video - // frameType: 0x00=cont, 0x05=end, 0x08=I-start, 0x0d=end-44 - if len(data) < 2 { return } @@ -1334,6 +1339,17 @@ func (c *Conn) buildNewProtoPacket(seq, ticket uint16, isResponse bool) []byte { authBytes := h.Sum(nil) copy(pkt[32:52], authBytes) + if c.verbose { + fmt.Printf("[AUTH] Discovery Auth Debug:\n") + fmt.Printf("[AUTH] ENR: %s\n", c.enr) + fmt.Printf("[AUTH] MAC: %s\n", c.mac) + fmt.Printf("[AUTH] UID: %s\n", c.uid) + fmt.Printf("[AUTH] AuthKey: %x\n", authKey) + fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) + fmt.Printf("[AUTH] Hash Input (32 bytes): %x\n", pkt[:32]) + fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) + } + return pkt } @@ -1360,14 +1376,25 @@ func (c *Conn) buildNewProtoDTLS(payload []byte, channel byte) []byte { binary.LittleEndian.PutUint32(pkt[24:], 1) // Always 1 for DTLS wrapper copy(pkt[NewProtoHeaderSize:], payload) - // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, packet_header) + // Add Auth bytes at the end: HMAC-SHA1(UID+AuthKey, header only) authKey := crypto.CalculateAuthKey(c.enr, c.mac) key := append([]byte(c.uid), authKey...) h := hmac.New(sha1.New, key) - h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion + h.Write(pkt[:NewProtoHeaderSize]) // Hash the header portion only authBytes := h.Sum(nil) copy(pkt[NewProtoHeaderSize+len(payload):], authBytes) + if c.verbose { + fmt.Printf("[AUTH] DTLS Auth Debug:\n") + fmt.Printf("[AUTH] ENR: %s\n", c.enr) + fmt.Printf("[AUTH] MAC: %s\n", c.mac) + fmt.Printf("[AUTH] UID: %s\n", c.uid) + fmt.Printf("[AUTH] AuthKey: %x\n", authKey) + fmt.Printf("[AUTH] HMAC Key (UID+AuthKey): %x\n", key) + fmt.Printf("[AUTH] Hash Input (Header 28 bytes): %x\n", pkt[:NewProtoHeaderSize]) + fmt.Printf("[AUTH] Auth Bytes: %x\n", authBytes) + } + return pkt } @@ -1742,78 +1769,34 @@ func (c *Conn) logAudioTX(frame []byte, codec uint16, payloadLen int, timestampU } } +func derivePSK(enr string) []byte { + // TUTK SDK treats the PSK as a NULL-terminated C string, so if SHA256(ENR) + // contains a 0x00 byte, the PSK is truncated at that position. + // This matches iOS Wyze app behavior discovered via Frida instrumentation. + + hash := sha256.Sum256([]byte(enr)) + + // Find first NULL byte - TUTK uses strlen() on binary PSK + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + // Create PSK: bytes up to first 0x00, rest padded with zeros + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} + func genRandomID() []byte { b := make([]byte, 8) _, _ = rand.Read(b) return b } -func getBroadcastAddrs(port int, verbose bool) []*net.UDPAddr { - var addrs []*net.UDPAddr - - ifaces, err := net.Interfaces() - if err != nil { - if verbose { - fmt.Printf("[IOTC] Failed to get interfaces: %v\n", err) - } - // Fallback to limited broadcast - return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} - } - - for _, iface := range ifaces { - // Skip loopback and down interfaces - if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { - continue - } - - ifAddrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range ifAddrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - - // Only IPv4 - ip4 := ipNet.IP.To4() - if ip4 == nil { - continue - } - - // Calculate broadcast address: IP | ~mask - mask := ipNet.Mask - if len(mask) != 4 { - continue - } - - broadcast := make(net.IP, 4) - for i := 0; i < 4; i++ { - broadcast[i] = ip4[i] | ^mask[i] - } - - bcastAddr := &net.UDPAddr{IP: broadcast, Port: port} - addrs = append(addrs, bcastAddr) - - if verbose { - fmt.Printf("[IOTC] Found broadcast address: %s (iface: %s)\n", bcastAddr, iface.Name) - } - } - } - - if len(addrs) == 0 { - // Fallback to limited broadcast - if verbose { - fmt.Printf("[IOTC] No broadcast addresses found, using 255.255.255.255\n") - } - return []*net.UDPAddr{{IP: net.IPv4(255, 255, 255, 255), Port: port}} - } - - return addrs -} - func hexDump(data []byte) string { var result string for i := 0; i < len(data); i += 16 { diff --git a/pkg/wyze/tutk/constants.go b/pkg/wyze/tutk/constants.go index 84e867e1..5645f969 100644 --- a/pkg/wyze/tutk/constants.go +++ b/pkg/wyze/tutk/constants.go @@ -164,6 +164,8 @@ const ( KCmdChallenge = 10001 KCmdChallengeResp = 10002 KCmdAuthResult = 10003 + KCmdAuthWithPayload = 10008 + KCmdAuthSuccess = 10009 KCmdControlChannel = 10010 KCmdControlChannelResp = 10011 KCmdSetResolution = 10056