diff --git a/pkg/tutk/auth.go b/pkg/tutk/auth.go new file mode 100644 index 00000000..8dca29aa --- /dev/null +++ b/pkg/tutk/auth.go @@ -0,0 +1,35 @@ +package tutk + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +func CalculateAuthKey(enr, mac string) []byte { + data := enr + strings.ToUpper(mac) + hash := sha256.Sum256([]byte(data)) + b64 := base64.StdEncoding.EncodeToString(hash[:6]) + b64 = strings.ReplaceAll(b64, "+", "Z") + b64 = strings.ReplaceAll(b64, "/", "9") + b64 = strings.ReplaceAll(b64, "=", "A") + return []byte(b64) +} + +func DerivePSK(enr string) []byte { + // DerivePSK derives the DTLS PSK from ENR + // 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. + hash := sha256.Sum256([]byte(enr)) + pskLen := 32 + for i := range 32 { + if hash[i] == 0x00 { + pskLen = i + break + } + } + + psk := make([]byte, 32) + copy(psk[:pskLen], hash[:pskLen]) + return psk +} diff --git a/pkg/wyze/tutk/cipher.go b/pkg/tutk/cipher.go similarity index 99% rename from pkg/wyze/tutk/cipher.go rename to pkg/tutk/cipher.go index 85831abe..0a238fa3 100644 --- a/pkg/wyze/tutk/cipher.go +++ b/pkg/tutk/cipher.go @@ -72,7 +72,7 @@ func computeNonce(iv []byte, epoch uint16, sequenceNumber uint64) []byte { binary.BigEndian.PutUint64(nonce[4:], sequenceNumber) binary.BigEndian.PutUint16(nonce[4:], epoch) - for i := 0; i < chachaNonceLength; i++ { + for i := range chachaNonceLength { nonce[i] ^= iv[i] } diff --git a/pkg/tutk/codec.go b/pkg/tutk/codec.go new file mode 100644 index 00000000..68ca72ca --- /dev/null +++ b/pkg/tutk/codec.go @@ -0,0 +1,50 @@ +package tutk + +// https://github.com/seydx/tutk_wyze#11-codec-reference +const ( + CodecMPEG4 byte = 0x4C + CodecH263 byte = 0x4D + CodecH264 byte = 0x4E + CodecMJPEG byte = 0x4F + CodecH265 byte = 0x50 +) + +const ( + CodecAACRaw byte = 0x86 + CodecAACADTS byte = 0x87 + CodecAACLATM byte = 0x88 + CodecPCMU byte = 0x89 + CodecPCMA byte = 0x8A + CodecADPCM byte = 0x8B + CodecPCML byte = 0x8C + CodecSPEEX byte = 0x8D + CodecMP3 byte = 0x8E + CodecG726 byte = 0x8F + CodecAACAlt byte = 0x90 + CodecOpus byte = 0x92 +) + +var sampleRates = [9]uint32{8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000} + +func GetSamplesPerFrame(codecID byte) uint32 { + switch codecID { + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: + return 1024 + case CodecPCMU, CodecPCMA, CodecPCML, CodecADPCM, CodecSPEEX, CodecG726: + return 160 + case CodecMP3: + return 1152 + case CodecOpus: + return 960 + default: + return 1024 + } +} + +func IsVideoCodec(id byte) bool { + return id >= CodecMPEG4 && id <= CodecH265 +} + +func IsAudioCodec(id byte) bool { + return id >= CodecAACRaw && id <= CodecOpus +} diff --git a/pkg/wyze/tutk/conn.go b/pkg/tutk/conn_dtls.go similarity index 59% rename from pkg/wyze/tutk/conn.go rename to pkg/tutk/conn_dtls.go index fc16da27..eccd985f 100644 --- a/pkg/wyze/tutk/conn.go +++ b/pkg/tutk/conn_dtls.go @@ -3,31 +3,71 @@ package tutk import ( "context" "crypto/hmac" - "crypto/rand" "crypto/sha1" - "crypto/sha256" "encoding/binary" - "encoding/hex" "fmt" "io" "net" "sync" "time" - "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" "github.com/pion/dtls/v3" ) const ( - MaxPacketSize = 2048 - ReadBufferSize = 2 * 1024 * 1024 - DiscoTimeout = 5000 * time.Millisecond - DiscoInterval = 100 * time.Millisecond - SessionTimeout = 5000 * time.Millisecond - ReadWaitInterval = 50 * time.Millisecond + magicCC51 = "\x51\xcc" // (wyze specific?) + sdkVersion42 = "\x01\x01\x02\x04" // 4.2.1.1 + sdkVersion43 = "\x00\x08\x03\x04" // 4.3.8.0 ) -type Conn struct { +const ( + cmdDiscoReq uint16 = 0x0601 + cmdDiscoRes uint16 = 0x0602 + cmdSessionReq uint16 = 0x0402 + cmdSessionRes uint16 = 0x0404 + cmdDataTX uint16 = 0x0407 + cmdDataRX uint16 = 0x0408 + cmdKeepaliveReq uint16 = 0x0427 + cmdKeepaliveRes uint16 = 0x0428 + + headerSize = 16 + discoBodySize = 72 + discoSize = headerSize + discoBodySize + sessionBody = 36 + sessionSize = headerSize + sessionBody +) + +const ( + cmdDiscoCC51 uint16 = 0x1002 + cmdKeepaliveCC51 uint16 = 0x1202 + cmdDTLSCC51 uint16 = 0x1502 + payloadSizeCC51 uint16 = 0x0028 + packetSizeCC51 = 52 + headerSizeCC51 = 28 + authSizeCC51 = 20 + keepaliveSizeCC51 = 48 +) + +const ( + magicAVLoginResp uint16 = 0x2100 + magicIOCtrl uint16 = 0x7000 + magicChannelMsg uint16 = 0x1000 + magicACK uint16 = 0x0009 + magicAVLogin1 uint16 = 0x0000 + magicAVLogin2 uint16 = 0x2000 +) + +const ( + protoVersion uint16 = 0x000c + defaultCaps uint32 = 0x001f07fb +) + +const ( + iotcChannelMain = 0 // Main AV (we = DTLS Client) + iotcChannelBack = 1 // Backchannel (we = DTLS Server) +) + +type DTLSConn struct { conn *net.UDPConn addr *net.UDPAddr frames *FrameHandler @@ -49,17 +89,15 @@ type Conn struct { uid string authKey string enr string - mac string psk []byte - rid []byte // Session - sid []byte - ticket uint16 - avResp *AVLoginResponse + sid []byte + ticket uint16 + hasTwoWayStreaming bool // Protocol - newProto bool + isCC51 bool seq uint16 seqCmd uint16 avSeq uint32 @@ -75,34 +113,32 @@ type Conn struct { cmdAck func() } -func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (*Conn, error) { +func DialDTLS(host string, port int, uid, authKey, enr string, verbose bool) (*DTLSConn, error) { udp, err := net.ListenUDP("udp", nil) if err != nil { return nil, err } - _ = udp.SetReadBuffer(ReadBufferSize) + _ = udp.SetReadBuffer(2 * 1024 * 1024) ctx, cancel := context.WithCancel(context.Background()) - psk := derivePSK(enr) + psk := DerivePSK(enr) if port == 0 { - port = DefaultPort + port = 32761 } - c := &Conn{ + c := &DTLSConn{ conn: udp, addr: &net.UDPAddr{IP: net.ParseIP(host), Port: port}, - rid: genRandomID(), uid: uid, authKey: authKey, enr: enr, - mac: mac, psk: psk, verbose: verbose, ctx: ctx, cancel: cancel, - rxSeqStart: 0xffff, // Initialize RX seq for ACK + rxSeqStart: 0xffff, rxSeqEnd: 0xffff, } @@ -130,10 +166,10 @@ func Dial(host string, port int, uid, authKey, enr, mac string, verbose bool) (* return c, nil } -func (c *Conn) AVClientStart(timeout time.Duration) error { - randomID := genRandomID() - pkt1 := c.buildAVLoginPacket(MagicAVLogin1, 570, 0x0001, randomID) - pkt2 := c.buildAVLoginPacket(MagicAVLogin2, 572, 0x0000, randomID) +func (c *DTLSConn) AVClientStart(timeout time.Duration) error { + randomID := GenSessionID() + pkt1 := c.msgAVLogin(magicAVLogin1, 570, 0x0001, randomID) + pkt2 := c.msgAVLogin(magicAVLogin2, 572, 0x0000, randomID) pkt2[20]++ // pkt2 has randomID incremented by 1 if _, err := c.clientConn.Write(pkt1); err != nil { @@ -155,16 +191,13 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { if !ok { return io.EOF } - if len(data) >= 32 && binary.LittleEndian.Uint16(data) == MagicAVLoginResp { - c.avResp = &AVLoginResponse{ - ServerType: binary.LittleEndian.Uint32(data[4:]), - Resend: int32(data[29]), - TwoWayStreaming: int32(data[31]), - } + if len(data) >= 32 && binary.LittleEndian.Uint16(data) == magicAVLoginResp { + c.hasTwoWayStreaming = data[31] == 1 - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) + // Start ACK sender for continuous streaming c.wg.Add(1) go func() { defer c.wg.Done() @@ -177,7 +210,7 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { return case <-ackTicker.C: if c.clientConn != nil { - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) } } @@ -192,14 +225,9 @@ func (c *Conn) AVClientStart(timeout time.Duration) error { } } -func (c *Conn) AVServStart() error { - if c.verbose { - fmt.Printf("[DTLS] Waiting for client handshake on channel %d\n", IOTCChannelBack) - fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) - fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) - } - - conn, err := NewDtlsServer(c, IOTCChannelBack, c.psk) +func (c *DTLSConn) AVServStart() error { + adapter := NewChannelAdapter(c.ctx, iotcChannelBack, c.addr, c.WriteDTLS, c.serverBuf) + conn, err := NewDTLSServer(adapter, c.addr, c.psk) if err != nil { return fmt.Errorf("dtls: server handshake failed: %w", err) } @@ -209,7 +237,7 @@ func (c *Conn) AVServStart() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Server handshake complete on channel %d\n", IOTCChannelBack) + fmt.Printf("[DTLS] Server handshake complete on channel %d\n", iotcChannelBack) } // Wait for and respond to AV Login request from camera @@ -220,10 +248,11 @@ func (c *Conn) AVServStart() error { return nil } -func (c *Conn) AVServStop() error { +func (c *DTLSConn) AVServStop() error { c.mu.Lock() serverConn := c.serverConn c.serverConn = nil + // Reset audio TX state c.audioSeq = 0 c.audioFrameNo = 0 @@ -238,7 +267,7 @@ func (c *Conn) AVServStop() error { return nil } -func (c *Conn) AVRecvFrameData() (*Packet, error) { +func (c *DTLSConn) AVRecvFrameData() (*Packet, error) { select { case pkt, ok := <-c.frames.Recv(): if !ok { @@ -250,7 +279,7 @@ func (c *Conn) AVRecvFrameData() (*Packet, error) { } } -func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { +func (c *DTLSConn) AVSendAudioData(codec byte, payload []byte, timestampUS uint32, sampleRate uint32, channels uint8) error { c.mu.Lock() conn := c.serverConn if conn == nil { @@ -258,7 +287,7 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return fmt.Errorf("speaker channel not connected") } - frame := c.buildAudioFrame(payload, timestampUS, codec, sampleRate, channels) + frame := c.msgAudioFrame(payload, timestampUS, codec, sampleRate, channels) c.mu.Unlock() @@ -273,35 +302,27 @@ func (c *Conn) AVSendAudioData(codec uint16, payload []byte, timestampUS uint32, return err } -func (c *Conn) Write(data []byte) error { - // if c.verbose { - // fmt.Printf("[UDP TX] to=%s, len=%d, data:\n%s", c.addr.String(), len(data), hexDump(data)) - // } - - if c.newProto { +func (c *DTLSConn) Write(data []byte) error { + if c.isCC51 { _, err := c.conn.WriteToUDP(data, c.addr) return err } - _, err := c.conn.WriteToUDP(crypto.TransCodeBlob(data), c.addr) + _, err := c.conn.WriteToUDP(TransCodeBlob(data), c.addr) return err } -func (c *Conn) WriteDTLS(payload []byte, channel byte) error { +func (c *DTLSConn) WriteDTLS(payload []byte, channel byte) error { var frame []byte - if c.newProto { - frame = c.buildNewTxData(payload, channel) + if c.isCC51 { + frame = c.msgTxDataCC51(payload, channel) } else { - frame = c.buildTxData(payload, channel) + frame = c.msgTxData(payload, channel) } - // if c.verbose { - // fmt.Printf("[DTLS TX] to=%s, len=%d, channel=%d, data:\n%s", c.addr.String(), len(frame), channel, hexDump(frame)) - // } - return c.Write(frame) } -func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byte) bool) ([]byte, error) { +func (c *DTLSConn) WriteAndWait(req []byte, ok func(res []byte) bool) ([]byte, error) { var t *time.Timer t = time.AfterFunc(1, func() { if err := c.Write(req); err == nil && t != nil { @@ -310,10 +331,10 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt }) defer t.Stop() - _ = c.conn.SetDeadline(time.Now().Add(timeout)) + _ = c.conn.SetDeadline(time.Now().Add(5000 * time.Millisecond)) defer c.conn.SetDeadline(time.Time{}) - buf := make([]byte, MaxPacketSize) + buf := make([]byte, 2048) for { n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { @@ -324,10 +345,10 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt } var res []byte - if c.newProto { + if c.isCC51 { res = buf[:n] } else { - res = crypto.ReverseTransCodeBlob(buf[:n]) + res = ReverseTransCodeBlob(buf[:n]) } if ok(res) { @@ -337,8 +358,8 @@ func (c *Conn) WriteAndWait(req []byte, timeout time.Duration, ok func(res []byt } } -func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { - frame := c.buildIOCtrlFrame(payload) +func (c *DTLSConn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, timeout time.Duration) ([]byte, error) { + frame := c.msgIOCtrl(payload) var t *time.Timer t = time.AfterFunc(1, func() { c.mu.RLock() @@ -362,7 +383,7 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, return nil, io.EOF } - ack := c.buildACK() + ack := c.msgACK() c.clientConn.Write(ack) if len(data) >= 6 { @@ -376,29 +397,29 @@ func (c *Conn) WriteAndWaitIOCtrl(cmd uint16, payload []byte, expectCmd uint16, } } -func (c *Conn) GetAVLoginResponse() *AVLoginResponse { - return c.avResp +func (c *DTLSConn) HasTwoWayStreaming() bool { + return c.hasTwoWayStreaming } -func (c *Conn) IsBackchannelReady() bool { +func (c *DTLSConn) IsBackchannelReady() bool { c.mu.RLock() defer c.mu.RUnlock() return c.serverConn != nil } -func (c *Conn) RemoteAddr() *net.UDPAddr { +func (c *DTLSConn) RemoteAddr() *net.UDPAddr { return c.addr } -func (c *Conn) LocalAddr() *net.UDPAddr { +func (c *DTLSConn) LocalAddr() *net.UDPAddr { return c.conn.LocalAddr().(*net.UDPAddr) } -func (c *Conn) SetDeadline(t time.Time) error { +func (c *DTLSConn) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) } -func (c *Conn) Close() error { +func (c *DTLSConn) Close() error { c.cancel() c.mu.Lock() @@ -416,27 +437,27 @@ func (c *Conn) Close() error { return c.conn.Close() } -func (c *Conn) Error() error { +func (c *DTLSConn) Error() error { if c.err != nil { return c.err } return io.EOF } -func (c *Conn) discovery() error { - c.sid = make([]byte, 8) - rand.Read(c.sid) +func (c *DTLSConn) discovery() error { + c.sid = GenSessionID() - oldPkt := crypto.TransCodeBlob(c.buildDisco(1)) - newPkt := c.buildNewDisco(0, 0, false) - buf := make([]byte, MaxPacketSize) - deadline := time.Now().Add(DiscoTimeout) + pktIOTC := TransCodeBlob(c.msgDisco(1)) + pktCC51 := c.msgDiscoCC51(0, 0, false) + + buf := make([]byte, 2048) + deadline := time.Now().Add(5000 * time.Millisecond) for time.Now().Before(deadline) { - c.conn.WriteToUDP(oldPkt, c.addr) - c.conn.WriteToUDP(newPkt, c.addr) + c.conn.WriteToUDP(pktIOTC, c.addr) + c.conn.WriteToUDP(pktCC51, c.addr) - c.conn.SetReadDeadline(time.Now().Add(DiscoInterval)) + c.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { continue @@ -445,59 +466,54 @@ func (c *Conn) discovery() error { continue } - // NEW protocol - if n >= NewPacketSize && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { - if binary.LittleEndian.Uint16(buf[4:]) == CmdNewDisco { - c.addr, c.newProto, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) + // CC51 protocol + if n >= packetSizeCC51 && string(buf[:2]) == magicCC51 { + if binary.LittleEndian.Uint16(buf[4:]) == cmdDiscoCC51 { + c.addr, c.isCC51, c.ticket = addr, true, binary.LittleEndian.Uint16(buf[14:]) if n >= 24 { copy(c.sid, buf[16:24]) } - return c.newDiscoDone() + return c.discoDoneCC51() } continue } - // OLD protocol - data := crypto.ReverseTransCodeBlob(buf[:n]) - if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == CmdDiscoRes { - c.addr, c.newProto = addr, false - return c.oldDiscoDone() + // IOTC Protocol (Basis) + data := ReverseTransCodeBlob(buf[:n]) + if len(data) >= 16 && binary.LittleEndian.Uint16(data[8:]) == cmdDiscoRes { + c.addr, c.isCC51 = addr, false + return c.discoDone() } } return fmt.Errorf("discovery timeout") } -func (c *Conn) oldDiscoDone() error { - c.Write(c.buildDisco(2)) +func (c *DTLSConn) discoDone() error { + c.Write(c.msgDisco(2)) time.Sleep(100 * time.Millisecond) - _, err := c.WriteAndWait(c.buildSession(), SessionTimeout, func(res []byte) bool { - return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == CmdSessionRes + _, err := c.WriteAndWait(c.msgSession(), func(res []byte) bool { + return len(res) >= 16 && binary.LittleEndian.Uint16(res[8:]) == cmdSessionRes }) return err } -func (c *Conn) newDiscoDone() error { - _, err := c.WriteAndWait(c.buildNewDisco(2, c.ticket, false), SessionTimeout, func(res []byte) bool { - if len(res) < NewPacketSize || binary.LittleEndian.Uint16(res[:2]) != MagicNewProto { +func (c *DTLSConn) discoDoneCC51() error { + _, err := c.WriteAndWait(c.msgDiscoCC51(2, c.ticket, false), func(res []byte) bool { + if len(res) < packetSizeCC51 || string(res[:2]) != magicCC51 { return false } cmd := binary.LittleEndian.Uint16(res[4:]) dir := binary.LittleEndian.Uint16(res[8:]) seq := binary.LittleEndian.Uint16(res[12:]) - return cmd == CmdNewDisco && dir == 0xFFFF && seq == 3 + return cmd == cmdDiscoCC51 && dir == 0xFFFF && seq == 3 }) return err } -func (c *Conn) connect() error { - if c.verbose { - fmt.Printf("[DTLS] Starting client handshake on channel %d\n", IOTCChannelMain) - fmt.Printf("[DTLS] PSK Identity: %s\n", PSKIdentity) - fmt.Printf("[DTLS] PSK Key: %s\n", hex.EncodeToString(c.psk)) - } - - conn, err := NewDtlsClient(c, IOTCChannelMain, c.psk) +func (c *DTLSConn) connect() error { + adapter := NewChannelAdapter(c.ctx, iotcChannelMain, c.addr, c.WriteDTLS, c.clientBuf) + conn, err := NewDTLSClient(adapter, c.addr, c.psk) if err != nil { return fmt.Errorf("dtls: client create failed: %w", err) } @@ -507,13 +523,13 @@ func (c *Conn) connect() error { c.mu.Unlock() if c.verbose { - fmt.Printf("[DTLS] Client created for channel %d\n", IOTCChannelMain) + fmt.Printf("[DTLS] Client created for channel %d\n", iotcChannelMain) } return nil } -func (c *Conn) worker() { +func (c *DTLSConn) worker() { defer c.wg.Done() buf := make([]byte, 2048) @@ -538,15 +554,11 @@ func (c *Conn) worker() { data := buf[:n] magic := binary.LittleEndian.Uint16(data) - // if c.verbose { - // fmt.Printf("[DTLS RX] from=%s, len=%d, data:\n%s", c.addr.String(), n, hexDump(data)) - // } - switch magic { - case MagicAVLoginResp: + case magicAVLoginResp: c.queue(c.rawCmd, data) - case MagicIOCtrl: + case magicIOCtrl: if len(data) >= 32 { for i := 32; i+2 < len(data); i++ { if data[i] == 'H' && data[i+1] == 'L' { @@ -556,7 +568,7 @@ func (c *Conn) worker() { } } - case MagicChannelMsg: + case magicChannelMsg: if len(data) >= 36 && data[16] == 0x00 { for i := 36; i+2 < len(data); i++ { if data[i] == 'H' && data[i+1] == 'L' { @@ -566,7 +578,7 @@ func (c *Conn) worker() { } } - case ProtoVersion: + case protoVersion: if len(data) >= 8 { // Extract seq number at byte 4-5 (uint16 of uint32 AVSeq) seq := binary.LittleEndian.Uint16(data[4:]) @@ -589,7 +601,7 @@ func (c *Conn) worker() { } } - case MagicACK: + case magicACK: c.mu.RLock() ack := c.cmdAck c.mu.RUnlock() @@ -606,9 +618,10 @@ func (c *Conn) worker() { } } -func (c *Conn) reader() { +func (c *DTLSConn) reader() { defer c.wg.Done() - buf := make([]byte, MaxPacketSize) + + buf := make([]byte, 2048) for { select { @@ -626,10 +639,6 @@ func (c *Conn) reader() { return } - // if c.verbose { - // fmt.Printf("[UDP RX] from=%s, len=%d, data:\n%s", addr.String(), n, hexDump(buf[:n])) - // } - if !addr.IP.Equal(c.addr.IP) { if c.verbose { fmt.Printf("Ignored packet from unknown IP: %s\n", addr.IP.String()) @@ -640,47 +649,47 @@ func (c *Conn) reader() { c.addr.Port = addr.Port } - // NEW protocol (0xCC51) - if c.newProto && n >= 12 && binary.LittleEndian.Uint16(buf[:2]) == MagicNewProto { + // CC51 Protocol + if c.isCC51 && n >= 12 && string(buf[:2]) == magicCC51 { cmd := binary.LittleEndian.Uint16(buf[4:]) switch cmd { - case CmdNewKeepalive: - if n >= NewKeepaliveSize { - _ = c.Write(c.buildNewKeepalive()) + case cmdKeepaliveCC51: + if n >= keepaliveSizeCC51 { + _ = c.Write(c.msgKeepaliveCC51()) } - case CmdNewDTLS: - if n >= NewHeaderSize+NewAuthSize { + case cmdDTLSCC51: + if n >= headerSizeCC51+authSizeCC51 { ch := byte(binary.LittleEndian.Uint16(buf[12:]) >> 8) - dtls := buf[NewHeaderSize : n-NewAuthSize] + dtlsData := buf[headerSizeCC51 : n-authSizeCC51] switch ch { - case IOTCChannelMain: - c.queue(c.clientBuf, dtls) - case IOTCChannelBack: - c.queue(c.serverBuf, dtls) + case iotcChannelMain: + c.queue(c.clientBuf, dtlsData) + case iotcChannelBack: + c.queue(c.serverBuf, dtlsData) } } } continue } - // OLD protocol (TransCode) - data := crypto.ReverseTransCodeBlob(buf[:n]) + // IOTC Protocol (Basis) + data := ReverseTransCodeBlob(buf[:n]) if len(data) < 16 { continue } switch binary.LittleEndian.Uint16(data[8:]) { - case CmdKeepaliveRes: + case cmdKeepaliveRes: if len(data) > 24 { - _ = c.Write(c.buildKeepAlive(data[16:])) + _ = c.Write(c.msgKeepalive(data[16:])) } - case CmdDataRX: + case cmdDataRX: if len(data) > 28 { ch := data[14] switch ch { - case IOTCChannelMain: + case iotcChannelMain: c.queue(c.clientBuf, data[28:]) - case IOTCChannelBack: + case iotcChannelBack: c.queue(c.serverBuf, data[28:]) } } @@ -688,7 +697,7 @@ func (c *Conn) reader() { } } -func (c *Conn) queue(ch chan []byte, data []byte) { +func (c *DTLSConn) queue(ch chan []byte, data []byte) { b := make([]byte, len(data)) copy(b, data) select { @@ -702,7 +711,7 @@ func (c *Conn) queue(ch chan []byte, data []byte) { } } -func (c *Conn) handleSpeakerAVLogin() error { +func (c *DTLSConn) handleSpeakerAVLogin() error { if c.verbose { fmt.Printf("[SPEAK] Waiting for AV Login request from camera...\n") } @@ -723,7 +732,7 @@ func (c *Conn) handleSpeakerAVLogin() error { } checksum := binary.LittleEndian.Uint32(buf[20:]) - resp := c.buildAVLoginResponse(checksum) + resp := c.msgAVLoginResponse(checksum) if c.verbose { fmt.Printf("[SPEAK] Sending AV Login response: %d bytes\n", len(resp)) @@ -751,16 +760,16 @@ func (c *Conn) handleSpeakerAVLogin() error { return nil } -func (c *Conn) buildDisco(stage byte) []byte { - b := make([]byte, OldDiscoSize) - copy(b, "\x04\x02\x1a\x02") // marker + mode - binary.LittleEndian.PutUint16(b[4:], OldDiscoBodySize) // body size - binary.LittleEndian.PutUint16(b[8:], CmdDiscoReq) // 0x0601 - binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags - body := b[OldHeaderSize:] - copy(body[:UIDSize], c.uid) - copy(body[36:], "\x01\x01\x02\x04") // unknown - copy(body[40:], c.rid) +func (c *DTLSConn) msgDisco(stage byte) []byte { + b := make([]byte, discoSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], discoBodySize) // body size + binary.LittleEndian.PutUint16(b[8:], cmdDiscoReq) // 0x0601 + binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[36:], sdkVersion42) // SDK 4.2.1.1 + copy(body[40:], c.sid) body[48] = stage if stage == 1 && len(c.authKey) > 0 { copy(body[58:], c.authKey) @@ -768,69 +777,67 @@ func (c *Conn) buildDisco(stage byte) []byte { return b } -func (c *Conn) buildNewDisco(seq, ticket uint16, isResponse bool) []byte { - b := make([]byte, NewPacketSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewDisco) // 0x1002 - binary.LittleEndian.PutUint16(b[6:], NewPayloadSize) // 40 bytes +func (c *DTLSConn) msgDiscoCC51(seq, ticket uint16, isResponse bool) []byte { + b := make([]byte, packetSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDiscoCC51) // 0x1002 + binary.LittleEndian.PutUint16(b[6:], payloadSizeCC51) // 40 bytes if isResponse { binary.LittleEndian.PutUint16(b[8:], 0xFFFF) // response } binary.LittleEndian.PutUint16(b[12:], seq) binary.LittleEndian.PutUint16(b[14:], ticket) copy(b[16:24], c.sid) - copy(b[24:32], "\x00\x08\x03\x04\x1d\x00\x00\x00") // SDK 4.3.8.0 - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + copy(b[24:28], sdkVersion43) // SDK 4.3.8.0 + b[28] = 0x1d // unknown field (capability/build flag?) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) h.Write(b[:32]) copy(b[32:52], h.Sum(nil)) return b } -func (c *Conn) buildNewKeepalive() []byte { +func (c *DTLSConn) msgKeepaliveCC51() []byte { c.kaSeq += 2 - b := make([]byte, NewKeepaliveSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewKeepalive) // 0x1202 - binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload - binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter - copy(b[20:28], c.sid) // session ID - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) + b := make([]byte, keepaliveSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdKeepaliveCC51) // 0x1202 + binary.LittleEndian.PutUint16(b[6:], 0x0024) // 36 bytes payload + binary.LittleEndian.PutUint32(b[16:], c.kaSeq) // counter + copy(b[20:28], c.sid) // session ID + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) h.Write(b[:28]) copy(b[28:48], h.Sum(nil)) return b } -func (c *Conn) buildSession() []byte { - b := make([]byte, OldSessionSize) - copy(b, "\x04\x02\x1a\x02") // marker + mode - binary.LittleEndian.PutUint16(b[4:], OldSessionBody) // body size - binary.LittleEndian.PutUint16(b[8:], CmdSessionReq) // 0x0402 - binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags - body := b[OldHeaderSize:] - copy(body[:UIDSize], c.uid) - copy(body[UIDSize:], c.rid) +func (c *DTLSConn) msgSession() []byte { + b := make([]byte, sessionSize) + copy(b, "\x04\x02\x1a\x02") // marker + mode + binary.LittleEndian.PutUint16(b[4:], sessionBody) // body size + binary.LittleEndian.PutUint16(b[8:], cmdSessionReq) // 0x0402 + binary.LittleEndian.PutUint16(b[10:], 0x0033) // flags + body := b[headerSize:] + copy(body[:20], c.uid) + copy(body[20:], c.sid) binary.LittleEndian.PutUint32(body[32:], uint32(time.Now().Unix())) return b } -func (c *Conn) buildAVLoginPacket(magic uint16, size int, flags uint16, randomID []byte) []byte { +func (c *DTLSConn) msgAVLogin(magic uint16, size int, flags uint16, randomID []byte) []byte { b := make([]byte, size) binary.LittleEndian.PutUint16(b, magic) - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint16(b[2:], protoVersion) binary.LittleEndian.PutUint16(b[16:], uint16(size-24)) // payload size binary.LittleEndian.PutUint16(b[18:], flags) copy(b[20:], randomID[:4]) - copy(b[24:], DefaultUser) // username - copy(b[280:], c.enr) // password/ENR - // binary.LittleEndian.PutUint32(b[536:], 1) // resend enabled + copy(b[24:], "admin") // username + copy(b[280:], c.enr) // password/ENR binary.LittleEndian.PutUint32(b[540:], 4) // security_mode ? - binary.LittleEndian.PutUint32(b[552:], DefaultCaps) // capabilities + binary.LittleEndian.PutUint32(b[552:], defaultCaps) // capabilities return b } -func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { +func (c *DTLSConn) msgAVLoginResponse(checksum uint32) []byte { b := make([]byte, 60) binary.LittleEndian.PutUint16(b, 0x2100) // magic binary.LittleEndian.PutUint16(b[2:], 0x000c) // version @@ -840,13 +847,13 @@ func (c *Conn) buildAVLoginResponse(checksum uint32) []byte { b[29] = 0x01 // enable flag b[31] = 0x01 // two-way streaming binary.LittleEndian.PutUint32(b[36:], 0x04) // buffer config - binary.LittleEndian.PutUint32(b[40:], DefaultCaps) + binary.LittleEndian.PutUint32(b[40:], defaultCaps) binary.LittleEndian.PutUint16(b[54:], 0x0003) // channel info binary.LittleEndian.PutUint16(b[56:], 0x0002) return b } -func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, sampleRate uint32, channels uint8) []byte { +func (c *DTLSConn) msgAudioFrame(payload []byte, timestampUS uint32, codec byte, sampleRate uint32, channels uint8) []byte { c.audioSeq++ c.audioFrameNo++ prevFrame := uint32(0) @@ -860,7 +867,7 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, // Outer header (36 bytes) b[0] = ChannelAudio // 0x03 b[1] = FrameTypeStartAlt // 0x09 - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) + binary.LittleEndian.PutUint16(b[2:], protoVersion) binary.LittleEndian.PutUint32(b[4:], c.audioSeq) binary.LittleEndian.PutUint32(b[8:], timestampUS) if c.audioFrameNo == 1 { @@ -880,54 +887,65 @@ func (c *Conn) buildAudioFrame(payload []byte, timestampUS uint32, codec uint16, binary.LittleEndian.PutUint32(b[32:], c.audioFrameNo) copy(b[36:], payload) // Payload + FrameInfo fi := b[36+len(payload):] - binary.LittleEndian.PutUint16(fi, codec) - fi[2] = BuildAudioFlags(sampleRate, true, channels == 2) + fi[0] = codec // Codec ID (low byte) + fi[1] = 0 // Codec ID (high byte, unused) + // Audio flags: [3:2]=sampleRateIdx [1]=16bit [0]=stereo + var srIdx uint8 = 3 // default 16kHz + for i, rate := range sampleRates { + if rate == sampleRate { + srIdx = uint8(i) + break + } + } + fi[2] = (srIdx << 2) | 0x02 // 16-bit always set + if channels == 2 { + fi[2] |= 0x01 + } fi[4] = 1 // online binary.LittleEndian.PutUint32(fi[12:], (c.audioFrameNo-1)*GetSamplesPerFrame(codec)*1000/sampleRate) return b } -func (c *Conn) buildTxData(payload []byte, channel byte) []byte { +func (c *DTLSConn) msgTxData(payload []byte, channel byte) []byte { bodySize := 12 + len(payload) b := make([]byte, 16+bodySize) copy(b, "\x04\x02\x1a\x0b") // marker + mode=data binary.LittleEndian.PutUint16(b[4:], uint16(bodySize)) // body size binary.LittleEndian.PutUint16(b[6:], c.seq) // sequence c.seq++ - binary.LittleEndian.PutUint16(b[8:], CmdDataTX) // 0x0407 + binary.LittleEndian.PutUint16(b[8:], cmdDataTX) // 0x0407 binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags - copy(b[12:], c.rid[:2]) // rid[0:2] + copy(b[12:], c.sid[:2]) // rid[0:2] b[14] = channel // channel b[15] = 0x01 // marker binary.LittleEndian.PutUint32(b[16:], 0x0000000c) // const - copy(b[20:], c.rid[:8]) // rid + copy(b[20:], c.sid[:8]) // rid copy(b[28:], payload) return b } -func (c *Conn) buildNewTxData(payload []byte, channel byte) []byte { - payloadSize := uint16(16 + len(payload) + NewAuthSize) - b := make([]byte, NewHeaderSize+len(payload)+NewAuthSize) - binary.LittleEndian.PutUint16(b[0:], MagicNewProto) // 0xCC51 - binary.LittleEndian.PutUint16(b[4:], CmdNewDTLS) // 0x1502 +func (c *DTLSConn) msgTxDataCC51(payload []byte, channel byte) []byte { + payloadSize := uint16(16 + len(payload) + authSizeCC51) + b := make([]byte, headerSizeCC51+len(payload)+authSizeCC51) + copy(b[:2], magicCC51) + binary.LittleEndian.PutUint16(b[4:], cmdDTLSCC51) // 0x1502 binary.LittleEndian.PutUint16(b[6:], payloadSize) binary.LittleEndian.PutUint16(b[12:], uint16(0x0010)|(uint16(channel)<<8)) // channel in high byte binary.LittleEndian.PutUint16(b[14:], c.ticket) copy(b[16:24], c.sid) binary.LittleEndian.PutUint32(b[24:], 1) // const - copy(b[NewHeaderSize:], payload) - authKey := crypto.CalculateAuthKey(c.enr, c.mac) - h := hmac.New(sha1.New, append([]byte(c.uid), authKey...)) - h.Write(b[:NewHeaderSize]) - copy(b[NewHeaderSize+len(payload):], h.Sum(nil)) + copy(b[headerSizeCC51:], payload) + h := hmac.New(sha1.New, append([]byte(c.uid), c.authKey...)) + h.Write(b[:headerSizeCC51]) + copy(b[headerSizeCC51+len(payload):], h.Sum(nil)) return b } -func (c *Conn) buildACK() []byte { +func (c *DTLSConn) msgACK() []byte { c.ackFlags++ b := make([]byte, 24) - binary.LittleEndian.PutUint16(b[0:], MagicACK) // 0x0009 - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // 0x000c + binary.LittleEndian.PutUint16(b[0:], magicACK) // 0x0009 + binary.LittleEndian.PutUint16(b[2:], protoVersion) // 0x000c binary.LittleEndian.PutUint32(b[4:], c.avSeq) // TX seq c.avSeq++ binary.LittleEndian.PutUint16(b[8:], c.rxSeqStart) // RX start (last acked) @@ -942,11 +960,11 @@ func (c *Conn) buildACK() []byte { return b } -func (c *Conn) buildKeepAlive(incoming []byte) []byte { +func (c *DTLSConn) msgKeepalive(incoming []byte) []byte { b := make([]byte, 24) copy(b, "\x04\x02\x1a\x0a") // marker + mode binary.LittleEndian.PutUint16(b[4:], 8) // body size - binary.LittleEndian.PutUint16(b[8:], CmdKeepaliveReq) // 0x0427 + binary.LittleEndian.PutUint16(b[8:], cmdKeepaliveReq) // 0x0427 binary.LittleEndian.PutUint16(b[10:], 0x0021) // flags if len(incoming) >= 8 { copy(b[16:], incoming[:8]) // echo payload @@ -954,13 +972,13 @@ func (c *Conn) buildKeepAlive(incoming []byte) []byte { return b } -func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { +func (c *DTLSConn) msgIOCtrl(payload []byte) []byte { b := make([]byte, 40+len(payload)) - binary.LittleEndian.PutUint16(b, ProtoVersion) // magic - binary.LittleEndian.PutUint16(b[2:], ProtoVersion) // version + binary.LittleEndian.PutUint16(b, protoVersion) // magic + binary.LittleEndian.PutUint16(b[2:], protoVersion) // version binary.LittleEndian.PutUint32(b[4:], c.avSeq) // av seq c.avSeq++ - binary.LittleEndian.PutUint16(b[16:], MagicIOCtrl) // 0x7000 + binary.LittleEndian.PutUint16(b[16:], magicIOCtrl) // 0x7000 binary.LittleEndian.PutUint16(b[18:], c.seqCmd) // sub channel binary.LittleEndian.PutUint32(b[20:], 1) // ioctl seq binary.LittleEndian.PutUint32(b[24:], uint32(len(payload)+4)) // payload size @@ -971,30 +989,6 @@ func (c *Conn) buildIOCtrlFrame(payload []byte) []byte { return b } -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. - // bytes after the first 0x00 are padded with zeros to make a 32-byte key. - hash := sha256.Sum256([]byte(enr)) - pskLen := 32 - for i := range 32 { - if hash[i] == 0x00 { - pskLen = i - break - } - } - - psk := make([]byte, 32) - copy(psk[:pskLen], hash[:pskLen]) - return psk -} - -func genRandomID() []byte { - b := make([]byte, 8) - _, _ = rand.Read(b) - return b -} - func hexDump(data []byte) string { const maxBytes = 650 totalLen := len(data) diff --git a/pkg/tutk/crypto.go b/pkg/tutk/crypto.go index 6b306255..469bd2bc 100644 --- a/pkg/tutk/crypto.go +++ b/pkg/tutk/crypto.go @@ -50,6 +50,34 @@ func ReverseTransCodePartial(dst, src []byte) []byte { return dst } +func ReverseTransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return ReverseTransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := ReverseTransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if dst[3]&1 != 0 { // Partial encryption (check decrypted header) + remaining := len(src) - 16 + decryptLen := min(remaining, 48) + if decryptLen > 0 { + decrypted := ReverseTransCodePartial(nil, src[16:16+decryptLen]) + copy(dst[16:], decrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full decryption + decrypted := ReverseTransCodePartial(nil, src[16:]) + copy(dst[16:], decrypted) + } + } + return dst +} + func TransCodePartial(dst, src []byte) []byte { n := len(src) tmp := make([]byte, n) @@ -92,6 +120,34 @@ func TransCodePartial(dst, src []byte) []byte { return dst } +func TransCodeBlob(src []byte) []byte { + if len(src) < 16 { + return TransCodePartial(nil, src) + } + + dst := make([]byte, len(src)) + header := TransCodePartial(nil, src[:16]) + copy(dst, header) + + if len(src) > 16 { + if src[3]&1 != 0 { // Partial encryption + remaining := len(src) - 16 + encryptLen := min(remaining, 48) + if encryptLen > 0 { + encrypted := TransCodePartial(nil, src[16:16+encryptLen]) + copy(dst[16:], encrypted) + } + if remaining > 48 { + copy(dst[64:], src[64:]) + } + } else { // Full encryption + encrypted := TransCodePartial(nil, src[16:]) + copy(dst[16:], encrypted) + } + } + return dst +} + func swap(dst, src []byte, n int) { switch n { case 2: @@ -175,3 +231,49 @@ func XXTEADecrypt(dst, src, key []byte) { dst = dst[4:] } } + +func XXTEADecryptVar(data, key []byte) []byte { + if len(data) < 8 || len(key) < 16 { + return nil + } + + k := make([]uint32, 4) + for i := range 4 { + k[i] = binary.LittleEndian.Uint32(key[i*4:]) + } + + n := max(len(data)/4, 2) + v := make([]uint32, n) + for i := 0; i < len(data)/4; i++ { + v[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + + rounds := 6 + 52/n + sum := uint32(rounds) * delta + y := v[0] + + for rounds > 0 { + e := (sum >> 2) & 3 + for p := n - 1; p > 0; p-- { + z := v[p-1] + v[p] -= xxteaMX(sum, y, z, p, e, k) + y = v[p] + } + z := v[n-1] + v[0] -= xxteaMX(sum, y, z, 0, e, k) + y = v[0] + sum -= delta + rounds-- + } + + result := make([]byte, n*4) + for i := range n { + binary.LittleEndian.PutUint32(result[i*4:], v[i]) + } + + return result[:len(data)] +} + +func xxteaMX(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { + return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) +} diff --git a/pkg/wyze/tutk/dtls.go b/pkg/tutk/dtls.go similarity index 62% rename from pkg/wyze/tutk/dtls.go rename to pkg/tutk/dtls.go index c51b7762..e807e96f 100644 --- a/pkg/wyze/tutk/dtls.go +++ b/pkg/tutk/dtls.go @@ -1,6 +1,7 @@ package tutk import ( + "context" "net" "sync" "time" @@ -8,22 +9,26 @@ import ( "github.com/pion/dtls/v3" ) -func NewDtlsClient(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { - adapter := &ChannelAdapter{conn: c, channel: channel} - return dtls.Client(adapter, c.addr, buildDtlsConfig(psk, false)) +type DTLSConfig struct { + PSK []byte + Identity string + IsServer bool } -func NewDtlsServer(c *Conn, channel uint8, psk []byte) (*dtls.Conn, error) { - adapter := &ChannelAdapter{conn: c, channel: channel} - return dtls.Server(adapter, c.addr, buildDtlsConfig(psk, true)) +func NewDTLSClient(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { + return dtls.Client(adapter, addr, buildDTLSConfig(psk, false)) } -func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { +func NewDTLSServer(adapter net.PacketConn, addr net.Addr, psk []byte) (*dtls.Conn, error) { + return dtls.Server(adapter, addr, buildDTLSConfig(psk, true)) +} + +func buildDTLSConfig(psk []byte, isServer bool) *dtls.Config { config := &dtls.Config{ PSK: func(hint []byte) ([]byte, error) { return psk, nil }, - PSKIdentityHint: []byte(PSKIdentity), + PSKIdentityHint: []byte("AUTHPWD_admin"), InsecureSkipVerify: true, InsecureSkipVerifyHello: true, MTU: 1200, @@ -41,21 +46,26 @@ func buildDtlsConfig(psk []byte, isServer bool) *dtls.Config { } type ChannelAdapter struct { - conn *Conn - channel uint8 - + ctx context.Context + channel uint8 + writeFn func([]byte, uint8) error + readChan chan []byte + addr net.Addr mu sync.Mutex readDeadline time.Time } -func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - var buf chan []byte - if a.channel == IOTCChannelMain { - buf = a.conn.clientBuf - } else { - buf = a.conn.serverBuf +func NewChannelAdapter(ctx context.Context, channel uint8, addr net.Addr, writeFn func([]byte, uint8) error, readChan chan []byte) *ChannelAdapter { + return &ChannelAdapter{ + ctx: ctx, + channel: channel, + addr: addr, + writeFn: writeFn, + readChan: readChan, } +} +func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { a.mu.Lock() deadline := a.readDeadline a.mu.Unlock() @@ -70,25 +80,25 @@ func (a *ChannelAdapter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { defer timer.Stop() select { - case data := <-buf: - return copy(p, data), a.conn.addr, nil + case data := <-a.readChan: + return copy(p, data), a.addr, nil case <-timer.C: return 0, nil, &timeoutError{} - case <-a.conn.ctx.Done(): + case <-a.ctx.Done(): return 0, nil, net.ErrClosed } } select { - case data := <-buf: - return copy(p, data), a.conn.addr, nil - case <-a.conn.ctx.Done(): + case data := <-a.readChan: + return copy(p, data), a.addr, nil + case <-a.ctx.Done(): return 0, nil, net.ErrClosed } } func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { - if err := a.conn.WriteDTLS(p, a.channel); err != nil { + if err := a.writeFn(p, a.channel); err != nil { return 0, err } return len(p), nil @@ -96,21 +106,18 @@ func (a *ChannelAdapter) WriteTo(p []byte, _ net.Addr) (int, error) { func (a *ChannelAdapter) Close() error { return nil } func (a *ChannelAdapter) LocalAddr() net.Addr { return &net.UDPAddr{} } - func (a *ChannelAdapter) SetDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } - func (a *ChannelAdapter) SetReadDeadline(t time.Time) error { a.mu.Lock() a.readDeadline = t a.mu.Unlock() return nil } - func (a *ChannelAdapter) SetWriteDeadline(time.Time) error { return nil } type timeoutError struct{} diff --git a/pkg/wyze/tutk/frame.go b/pkg/tutk/frame.go similarity index 84% rename from pkg/wyze/tutk/frame.go rename to pkg/tutk/frame.go index ee673181..db5bf074 100644 --- a/pkg/wyze/tutk/frame.go +++ b/pkg/tutk/frame.go @@ -25,18 +25,7 @@ const ( ChannelPVideo uint8 = 0x07 ) -const ( - ResTierLow uint8 = 1 // 360P/SD - ResTierHigh uint8 = 4 // HD/2K -) - -const ( - Bitrate360P uint8 = 30 - BitrateHD uint8 = 100 - Bitrate2K uint8 = 200 -) - -const FrameInfoSize = 40 +const frameInfoSize = 40 // FrameInfo - Wyze extended FRAMEINFO (40 bytes at end of packet) // Video: 40 bytes, Audio: 16 bytes (uses same struct, fields 16+ are zero) @@ -56,7 +45,7 @@ const FrameInfoSize = 40 // 24-35 12 DeviceID - MAC address (ASCII) - video only // 36-39 4 Padding - Always 0 - video only type FrameInfo struct { - CodecID uint16 // 0-1 + CodecID byte // 0 (only low byte used) Flags uint8 // 2 CamIndex uint8 // 3 OnlineNum uint8 // 4 @@ -73,22 +62,12 @@ func (fi *FrameInfo) IsKeyframe() bool { return fi.Flags == 0x01 } -func (fi *FrameInfo) Resolution() string { - switch fi.Bitrate { - case Bitrate360P: - return "360P" - case BitrateHD: - return "HD" - case Bitrate2K: - return "2K" - default: - return "unknown" - } -} - func (fi *FrameInfo) SampleRate() uint32 { idx := (fi.Flags >> 2) & 0x0F - return uint32(SampleRateValue(idx)) + if idx < uint8(len(sampleRates)) { + return sampleRates[idx] + } + return 16000 } func (fi *FrameInfo) Channels() uint8 { @@ -98,24 +77,16 @@ func (fi *FrameInfo) Channels() uint8 { return 1 } -func (fi *FrameInfo) IsVideo() bool { - return IsVideoCodec(fi.CodecID) -} - -func (fi *FrameInfo) IsAudio() bool { - return IsAudioCodec(fi.CodecID) -} - func ParseFrameInfo(data []byte) *FrameInfo { - if len(data) < FrameInfoSize { + if len(data) < frameInfoSize { return nil } - offset := len(data) - FrameInfoSize + offset := len(data) - frameInfoSize fi := data[offset:] return &FrameInfo{ - CodecID: binary.LittleEndian.Uint16(fi), + CodecID: fi[0], Flags: fi[2], CamIndex: fi[3], OnlineNum: fi[4], @@ -131,7 +102,7 @@ func ParseFrameInfo(data []byte) *FrameInfo { type Packet struct { Channel uint8 - Codec uint16 + Codec byte Timestamp uint32 Payload []byte IsKeyframe bool @@ -140,14 +111,6 @@ type Packet struct { Channels uint8 } -func (p *Packet) IsVideo() bool { - return p.Channel == ChannelIVideo || p.Channel == ChannelPVideo -} - -func (p *Packet) IsAudio() bool { - return p.Channel == ChannelAudio -} - type PacketHeader struct { Channel byte FrameType byte @@ -347,7 +310,7 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame frameType := data[1] headerSize := 28 - frameInfoSize := 0 + fiSize := 0 switch frameType { case FrameTypeStart: @@ -357,17 +320,17 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame if len(data) >= 22 { pktTotal := binary.LittleEndian.Uint16(data[20:]) if pktTotal == 1 { - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize } } case FrameTypeCont, FrameTypeContAlt: headerSize = 28 case FrameTypeEndSingle, FrameTypeEndMulti: headerSize = 28 - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize case FrameTypeEndExt: headerSize = 36 - frameInfoSize = FrameInfoSize + fiSize = frameInfoSize default: headerSize = 28 } @@ -376,11 +339,11 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame return nil, nil } - if frameInfoSize == 0 { + if fiSize == 0 { return data[headerSize:], nil } - if len(data) < headerSize+frameInfoSize { + if len(data) < headerSize+fiSize { return data[headerSize:], nil } @@ -395,7 +358,7 @@ func (h *FrameHandler) extractPayload(data []byte, channel byte) ([]byte, *Frame } if validCodec { - payload := data[headerSize : len(data)-frameInfoSize] + payload := data[headerSize : len(data)-fiSize] return payload, fi } @@ -421,7 +384,7 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by cs.pktTotal = hdr.PktTotal } - // Sequential check: if packet index doesn't match expected, reset (data loss) + // If packet index doesn't match expected, reset (data loss) if hdr.PktIdx != cs.waitSeq { fmt.Printf("[OOO] ch=0x%02x #%d frameType=0x%02x pktTotal=%d expected pkt %d, got %d - reset\n", channel, hdr.FrameNo, hdr.FrameType, hdr.PktTotal, cs.waitSeq, hdr.PktIdx) @@ -434,7 +397,6 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by cs.hasStarted = true } - // Append payload (simple sequential accumulation) cs.waitData = append(cs.waitData, payload...) cs.waitSeq++ @@ -444,16 +406,13 @@ func (h *FrameHandler) handleVideo(channel byte, hdr *PacketHeader, payload []by } // Check if frame is complete - if cs.waitSeq == cs.pktTotal && cs.frameInfo != nil { - h.emitVideo(channel, cs) - cs.reset() + if cs.waitSeq != cs.pktTotal || cs.frameInfo == nil { + return } -} -func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { - fi := cs.frameInfo + fi = cs.frameInfo + defer cs.reset() - // Size validation if fi.PayloadSize > 0 && uint32(len(cs.waitData)) != fi.PayloadSize { fmt.Printf("[SIZE] ch=0x%02x #%d mismatch: expected %d, got %d\n", channel, cs.frameNo, fi.PayloadSize, len(cs.waitData)) @@ -467,13 +426,9 @@ func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { accumUS := h.videoTS.update(fi.Timestamp) rtpTS := uint32(accumUS * 90000 / 1000000) - // Copy payload (buffer will be reused) - payload := make([]byte, len(cs.waitData)) - copy(payload, cs.waitData) - pkt := &Packet{ Channel: channel, - Payload: payload, + Payload: append([]byte{}, cs.waitData...), Codec: fi.CodecID, Timestamp: rtpTS, IsKeyframe: fi.IsKeyframe(), @@ -485,10 +440,10 @@ func (h *FrameHandler) emitVideo(channel byte, cs *channelState) { if fi.IsKeyframe() { frameType = "KEY" } - fmt.Printf("[OK] ch=0x%02x #%d %s %s size=%d\n", - channel, fi.FrameNo, CodecName(fi.CodecID), frameType, len(payload)) - fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x [3]=%d [4]=%d\n", - fi.CodecID, CodecName(fi.CodecID), fi.Flags, fi.CamIndex, fi.OnlineNum) + fmt.Printf("[OK] ch=0x%02x #%d codec=0x%02x %s size=%d\n", + channel, fi.FrameNo, fi.CodecID, frameType, len(pkt.Payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x [3]=%d [4]=%d\n", + fi.CodecID, fi.Flags, fi.CamIndex, fi.OnlineNum) fmt.Printf(" [5]=%d [6]=%d [7]=%d [8-11]ts=%d\n", fi.FPS, fi.ResTier, fi.Bitrate, fi.Timestamp) fmt.Printf(" [12-15]=0x%x [16-19]payload=%d [20-23]frameNo=%d\n", @@ -509,7 +464,7 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { var channels uint8 switch fi.CodecID { - case AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACLATM, AudioCodecAACWyze: + case CodecAACRaw, CodecAACADTS, CodecAACLATM, CodecAACAlt: sampleRate, channels = parseAudioParams(payload, fi) default: sampleRate = fi.SampleRate() @@ -537,10 +492,10 @@ func (h *FrameHandler) handleAudio(payload []byte, fi *FrameInfo) { if fi.Flags&0x02 != 0 { bits = 16 } - fmt.Printf("[OK] Audio #%d %s size=%d\n", - fi.FrameNo, AudioCodecName(fi.CodecID), len(payload)) - fmt.Printf(" [0-1]codec=0x%x(%s) [2]flags=0x%x(%dHz/%dbit/%dch)\n", - fi.CodecID, AudioCodecName(fi.CodecID), fi.Flags, sampleRate, bits, channels) + fmt.Printf("[OK] Audio #%d codec=0x%02x size=%d\n", + fi.FrameNo, fi.CodecID, len(payload)) + fmt.Printf(" [0-1]codec=0x%02x [2]flags=0x%x(%dHz/%dbit/%dch)\n", + fi.CodecID, fi.Flags, sampleRate, bits, channels) fmt.Printf(" [8-11]ts=%d [12-15]=0x%x rtp_ts=%d\n", fi.Timestamp, fi.SessionID, rtpTS) fmt.Printf(" hex: %s\n", dumpHex(fi)) @@ -589,8 +544,9 @@ func parseAudioParams(payload []byte, fi *FrameInfo) (sampleRate uint32, channel } func dumpHex(fi *FrameInfo) string { - b := make([]byte, FrameInfoSize) - binary.LittleEndian.PutUint16(b[0:], fi.CodecID) + b := make([]byte, frameInfoSize) + b[0] = fi.CodecID + b[1] = 0 // High byte (unused) b[2] = fi.Flags b[3] = fi.CamIndex b[4] = fi.OnlineNum diff --git a/pkg/tutk/helpers.go b/pkg/tutk/helpers.go index 118119be..b3623b9e 100644 --- a/pkg/tutk/helpers.go +++ b/pkg/tutk/helpers.go @@ -1,16 +1,16 @@ package tutk -import "encoding/binary" - -// https://github.com/seydx/tutk_wyze#11-codec-reference -const ( - CodecH264 = 0x4e - CodecH265 = 0x50 - CodecPCMA = 0x8a - CodecPCML = 0x8c - CodecAAC = 0x88 +import ( + "encoding/binary" + "time" ) +func GenSessionID() []byte { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) + return b +} + func ICAM(cmd uint32, args ...byte) []byte { // 0 4943414d ICAM // 4 d807ff00 command @@ -26,3 +26,37 @@ func ICAM(cmd uint32, args ...byte) []byte { copy(b[23:], args) return b } + +func HL(cmdID uint16, payload []byte) []byte { + // 0-1 "HL" magic + // 2 version (typically 5) + // 3 reserved + // 4-5 cmdID command ID (uint16 LE) + // 6-7 payloadLen payload length (uint16 LE) + // 8-15 reserved + // 16+ payload + const headerSize = 16 + const version = 5 + + b := make([]byte, headerSize+len(payload)) + copy(b, "HL") + b[2] = version + binary.LittleEndian.PutUint16(b[4:], cmdID) + binary.LittleEndian.PutUint16(b[6:], uint16(len(payload))) + copy(b[headerSize:], payload) + return b +} + +func ParseHL(data []byte) (cmdID uint16, payload []byte, ok bool) { + if len(data) < 16 || data[0] != 'H' || data[1] != 'L' { + return 0, nil, false + } + cmdID = binary.LittleEndian.Uint16(data[4:]) + payloadLen := binary.LittleEndian.Uint16(data[6:]) + if len(data) >= 16+int(payloadLen) { + payload = data[16 : 16+payloadLen] + } else if len(data) > 16 { + payload = data[16:] + } + return cmdID, payload, true +} diff --git a/pkg/tutk/session0.go b/pkg/tutk/session0.go index 1f1bbc7e..6a1b2253 100644 --- a/pkg/tutk/session0.go +++ b/pkg/tutk/session0.go @@ -155,9 +155,3 @@ func ConnectByUID(stage byte, uid string, sid8 []byte) []byte { return b } - -func GenSessionID() []byte { - b := make([]byte, 8) - binary.LittleEndian.PutUint64(b, uint64(time.Now().UnixNano())) - return b -} diff --git a/pkg/wyze/backchannel.go b/pkg/wyze/backchannel.go index d0b15db3..37472c10 100644 --- a/pkg/wyze/backchannel.go +++ b/pkg/wyze/backchannel.go @@ -5,7 +5,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/pion/rtp" ) diff --git a/pkg/wyze/client.go b/pkg/wyze/client.go index e047cfd5..6e691a25 100644 --- a/pkg/wyze/client.go +++ b/pkg/wyze/client.go @@ -12,8 +12,7 @@ import ( "sync" "time" - "github.com/AlexxIT/go2rtc/pkg/wyze/crypto" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" ) const ( @@ -29,15 +28,6 @@ const ( BitrateSD uint16 = 0x3C ) -const ( - QualityUnknown = 0 - QualityMax = 1 - QualityHigh = 2 - QualityMiddle = 3 - QualityLow = 4 - QualityMin = 5 -) - const ( MediaTypeVideo = 1 MediaTypeAudio = 2 @@ -59,7 +49,7 @@ const ( ) type Client struct { - conn *tutk.Conn + conn *tutk.DTLSConn host string uid string @@ -76,7 +66,7 @@ type Client struct { hasAudio bool hasIntercom bool - audioCodecID uint16 + audioCodecID byte audioSampleRate uint32 audioChannels uint8 } @@ -107,7 +97,7 @@ func Dial(rawURL string) (*Client, error) { verbose: query.Get("verbose") == "true", } - c.authKey = string(crypto.CalculateAuthKey(c.enr, c.mac)) + c.authKey = string(tutk.CalculateAuthKey(c.enr, c.mac)) if c.verbose { fmt.Printf("[Wyze] Connecting to %s (UID: %s)\n", c.host, c.uid) @@ -143,13 +133,13 @@ func (c *Client) SupportsIntercom() bool { return c.hasIntercom } -func (c *Client) SetBackchannelCodec(codecID uint16, sampleRate uint32, channels uint8) { +func (c *Client) SetBackchannelCodec(codecID byte, sampleRate uint32, channels uint8) { c.audioCodecID = codecID c.audioSampleRate = sampleRate c.audioChannels = channels } -func (c *Client) GetBackchannelCodec() (codecID uint16, sampleRate uint32, channels uint8) { +func (c *Client) GetBackchannelCodec() (codecID byte, sampleRate uint32, channels uint8) { return c.audioCodecID, c.audioSampleRate, c.audioChannels } @@ -238,13 +228,13 @@ func (c *Client) ReadPacket() (*tutk.Packet, error) { return c.conn.AVRecvFrameData() } -func (c *Client) WriteAudio(codec uint16, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { +func (c *Client) WriteAudio(codec byte, payload []byte, timestamp uint32, sampleRate uint32, channels uint8) error { if !c.conn.IsBackchannelReady() { return fmt.Errorf("speaker channel not connected") } if c.verbose { - fmt.Printf("[Wyze] WriteAudio: codec=0x%04x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) + fmt.Printf("[Wyze] WriteAudio: codec=0x%02x, payload=%d bytes, rate=%d, ch=%d\n", codec, len(payload), sampleRate, channels) } return c.conn.AVSendAudioData(codec, payload, timestamp, sampleRate, channels) @@ -305,7 +295,7 @@ func (c *Client) connect() error { host = host[:idx] } - conn, err := tutk.Dial(host, port, c.uid, c.authKey, c.enr, c.mac, c.verbose) + conn, err := tutk.DialDTLS(host, port, c.uid, c.authKey, c.enr, c.verbose) if err != nil { return fmt.Errorf("wyze: connect failed: %w", err) } @@ -386,9 +376,7 @@ func (c *Client) doKAuth() error { fmt.Printf("[Wyze] K10003 auth success\n") } - if avResp := c.conn.GetAVLoginResponse(); avResp != nil { - c.hasIntercom = avResp.TwoWayStreaming == 1 - } + c.hasIntercom = c.conn.HasTwoWayStreaming() if c.verbose { fmt.Printf("[Wyze] K-auth complete\n") @@ -409,7 +397,7 @@ func (c *Client) buildK10000() []byte { } func (c *Client) buildK10002(challenge []byte, status byte) []byte { - resp := crypto.GenerateChallengeResponse(challenge, c.enr, status) + resp := generateChallengeResponse(challenge, c.enr, status) sessionID := make([]byte, 4) rand.Read(sessionID) b := make([]byte, 38) @@ -555,3 +543,42 @@ func (c *Client) is2K() bool { func (c *Client) isFloodlight() bool { return c.model == "HL_CFL2" } + +const ( + statusDefault byte = 1 + statusENR16 byte = 3 + statusENR32 byte = 6 +) + +func generateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { + var secretKey []byte + + switch status { + case statusDefault: + secretKey = []byte("FFFFFFFFFFFFFFFF") + case statusENR16: + if len(enr) >= 16 { + secretKey = []byte(enr[:16]) + } else { + secretKey = make([]byte, 16) + copy(secretKey, enr) + } + case statusENR32: + if len(enr) >= 16 { + firstKey := []byte(enr[:16]) + challengeBytes = tutk.XXTEADecryptVar(challengeBytes, firstKey) + } + if len(enr) >= 32 { + secretKey = []byte(enr[16:32]) + } else if len(enr) > 16 { + secretKey = make([]byte, 16) + copy(secretKey, []byte(enr[16:])) + } else { + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + default: + secretKey = []byte("FFFFFFFFFFFFFFFF") + } + + return tutk.XXTEADecryptVar(challengeBytes, secretKey) +} diff --git a/pkg/wyze/crypto/transcode.go b/pkg/wyze/crypto/transcode.go deleted file mode 100644 index 61cf5f2c..00000000 --- a/pkg/wyze/crypto/transcode.go +++ /dev/null @@ -1,143 +0,0 @@ -package crypto - -import ( - "bytes" - "crypto/rand" - "encoding/binary" - "math/bits" -) - -const charlie = "Charlie is the designer of P2P!!" - -func TransCodePartial(src []byte) []byte { - n := len(src) - tmp := make([]byte, n) - dst := bytes.Clone(src) - src16, tmp16, dst16 := src, tmp, dst - - for ; n >= 16; n -= 16 { - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(src16[i:]) - binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, -i-1)) - } - for i := range 16 { - dst16[i] = tmp16[i] ^ charlie[i] - } - swap(dst16, tmp16, 16) - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(tmp16[i:]) - binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, -i-3)) - } - tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] - } - - for i := 0; i < n; i++ { - tmp16[i] = src16[i] ^ charlie[i] - } - swap(tmp16, dst16, n) - return dst -} - -func ReverseTransCodePartial(src []byte) []byte { - n := len(src) - tmp := make([]byte, n) - dst := bytes.Clone(src) - src16, tmp16, dst16 := src, tmp, dst - - for ; n >= 16; n -= 16 { - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(src16[i:]) - binary.LittleEndian.PutUint32(tmp16[i:], bits.RotateLeft32(x, i+3)) - } - swap(tmp16, dst16, 16) - for i := range 16 { - tmp16[i] = dst16[i] ^ charlie[i] - } - for i := 0; i < 16; i += 4 { - x := binary.LittleEndian.Uint32(tmp16[i:]) - binary.LittleEndian.PutUint32(dst16[i:], bits.RotateLeft32(x, i+1)) - } - tmp16, dst16, src16 = tmp16[16:], dst16[16:], src16[16:] - } - - swap(src16, tmp16, n) - for i := 0; i < n; i++ { - dst16[i] = tmp16[i] ^ charlie[i] - } - return dst -} - -func TransCodeBlob(src []byte) []byte { - if len(src) < 16 { - return TransCodePartial(src) - } - - dst := make([]byte, len(src)) - header := TransCodePartial(src[:16]) - copy(dst, header) - - if len(src) > 16 { - if src[3]&1 != 0 { // Partial encryption - remaining := len(src) - 16 - encryptLen := min(remaining, 48) - if encryptLen > 0 { - encrypted := TransCodePartial(src[16 : 16+encryptLen]) - copy(dst[16:], encrypted) - } - if remaining > 48 { - copy(dst[64:], src[64:]) - } - } else { // Full encryption - encrypted := TransCodePartial(src[16:]) - copy(dst[16:], encrypted) - } - } - return dst -} - -func ReverseTransCodeBlob(src []byte) []byte { - if len(src) < 16 { - return ReverseTransCodePartial(src) - } - - dst := make([]byte, len(src)) - header := ReverseTransCodePartial(src[:16]) - copy(dst, header) - - if len(src) > 16 { - if dst[3]&1 != 0 { // Partial encryption (check decrypted header) - remaining := len(src) - 16 - decryptLen := min(remaining, 48) - if decryptLen > 0 { - decrypted := ReverseTransCodePartial(src[16 : 16+decryptLen]) - copy(dst[16:], decrypted) - } - if remaining > 48 { - copy(dst[64:], src[64:]) - } - } else { // Full decryption - decrypted := ReverseTransCodePartial(src[16:]) - copy(dst[16:], decrypted) - } - } - return dst -} - -func RandRead(b []byte) { - _, _ = rand.Read(b) -} - -func swap(src, dst []byte, n int) { - switch n { - case 8: - dst[0], dst[1], dst[2], dst[3] = src[7], src[4], src[3], src[2] - dst[4], dst[5], dst[6], dst[7] = src[1], src[6], src[5], src[0] - case 16: - dst[0], dst[1], dst[2], dst[3] = src[11], src[9], src[8], src[15] - dst[4], dst[5], dst[6], dst[7] = src[13], src[10], src[12], src[14] - dst[8], dst[9], dst[10], dst[11] = src[2], src[1], src[5], src[0] - dst[12], dst[13], dst[14], dst[15] = src[6], src[4], src[7], src[3] - default: - copy(dst, src[:n]) - } -} diff --git a/pkg/wyze/crypto/xxtea.go b/pkg/wyze/crypto/xxtea.go deleted file mode 100644 index a28901cb..00000000 --- a/pkg/wyze/crypto/xxtea.go +++ /dev/null @@ -1,147 +0,0 @@ -package crypto - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "strings" -) - -const delta = 0x9e3779b9 - -const ( - StatusDefault byte = 1 - StatusENR16 byte = 3 - StatusENR32 byte = 6 -) - -func XXTEADecrypt(data, key []byte) []byte { - if len(data) < 8 || len(key) < 16 { - return nil - } - - k := make([]uint32, 4) - for i := range 4 { - k[i] = binary.LittleEndian.Uint32(key[i*4:]) - } - - n := max(len(data)/4, 2) - v := make([]uint32, n) - for i := 0; i < len(data)/4; i++ { - v[i] = binary.LittleEndian.Uint32(data[i*4:]) - } - - rounds := 6 + 52/n - sum := uint32(rounds) * delta - y := v[0] - - for rounds > 0 { - e := (sum >> 2) & 3 - for p := n - 1; p > 0; p-- { - z := v[p-1] - v[p] -= mx(sum, y, z, p, e, k) - y = v[p] - } - z := v[n-1] - v[0] -= mx(sum, y, z, 0, e, k) - y = v[0] - sum -= delta - rounds-- - } - - result := make([]byte, n*4) - for i := range n { - binary.LittleEndian.PutUint32(result[i*4:], v[i]) - } - - return result[:len(data)] -} - -func XXTEAEncrypt(data, key []byte) []byte { - if len(data) < 8 || len(key) < 16 { - return nil - } - - k := make([]uint32, 4) - for i := range 4 { - k[i] = binary.LittleEndian.Uint32(key[i*4:]) - } - - n := max(len(data)/4, 2) - v := make([]uint32, n) - for i := 0; i < len(data)/4; i++ { - v[i] = binary.LittleEndian.Uint32(data[i*4:]) - } - - rounds := 6 + 52/n - var sum uint32 - z := v[n-1] - - for rounds > 0 { - sum += delta - e := (sum >> 2) & 3 - for p := 0; p < n-1; p++ { - y := v[p+1] - v[p] += mx(sum, y, z, p, e, k) - z = v[p] - } - y := v[0] - v[n-1] += mx(sum, y, z, n-1, e, k) - z = v[n-1] - rounds-- - } - - result := make([]byte, n*4) - for i := range n { - binary.LittleEndian.PutUint32(result[i*4:], v[i]) - } - - return result[:len(data)] -} - -func mx(sum, y, z uint32, p int, e uint32, k []uint32) uint32 { - return ((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (k[(p&3)^int(e)] ^ z)) -} - -func GenerateChallengeResponse(challengeBytes []byte, enr string, status byte) []byte { - var secretKey []byte - - switch status { - case StatusDefault: - secretKey = []byte("FFFFFFFFFFFFFFFF") - case StatusENR16: - if len(enr) >= 16 { - secretKey = []byte(enr[:16]) - } else { - secretKey = make([]byte, 16) - copy(secretKey, enr) - } - case StatusENR32: - if len(enr) >= 16 { - firstKey := []byte(enr[:16]) - challengeBytes = XXTEADecrypt(challengeBytes, firstKey) - } - if len(enr) >= 32 { - secretKey = []byte(enr[16:32]) - } else if len(enr) > 16 { - secretKey = make([]byte, 16) - copy(secretKey, []byte(enr[16:])) - } else { - secretKey = []byte("FFFFFFFFFFFFFFFF") - } - default: - secretKey = []byte("FFFFFFFFFFFFFFFF") - } - - return XXTEADecrypt(challengeBytes, secretKey) -} - -func CalculateAuthKey(enr, mac string) []byte { - data := enr + strings.ToUpper(mac) - hash := sha256.Sum256([]byte(data)) - b64 := base64.StdEncoding.EncodeToString(hash[:6]) - b64 = strings.ReplaceAll(b64, "+", "Z") - b64 = strings.ReplaceAll(b64, "/", "9") - b64 = strings.ReplaceAll(b64, "=", "A") - return []byte(b64) -} diff --git a/pkg/wyze/producer.go b/pkg/wyze/producer.go index 4eb70ab3..16219c44 100644 --- a/pkg/wyze/producer.go +++ b/pkg/wyze/producer.go @@ -10,7 +10,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h264/annexb" "github.com/AlexxIT/go2rtc/pkg/h265" - "github.com/AlexxIT/go2rtc/pkg/wyze/tutk" + "github.com/AlexxIT/go2rtc/pkg/tutk" "github.com/pion/rtp" ) @@ -96,21 +96,21 @@ func (p *Producer) Start() error { Payload: annexb.EncodeToAVCC(pkt.Payload), } - case tutk.AudioCodecG711U: + case tutk.CodecPCMU: name = core.CodecPCMU pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecG711A: + case tutk.CodecPCMA: name = core.CodecPCMA pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecAACADTS, tutk.AudioCodecAACWyze, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + case tutk.CodecAACADTS, tutk.CodecAACAlt, tutk.CodecAACRaw, tutk.CodecAACLATM: name = core.CodecAAC payload := pkt.Payload if aac.IsADTS(payload) { @@ -121,21 +121,21 @@ func (p *Producer) Start() error { Payload: payload, } - case tutk.AudioCodecOpus: + case tutk.CodecOpus: name = core.CodecOpus pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecPCM: + case tutk.CodecPCML: name = core.CodecPCML pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, Payload: pkt.Payload, } - case tutk.AudioCodecMP3: + case tutk.CodecMP3: name = core.CodecMP3 pkt2 = &core.Packet{ Header: rtp.Header{Version: 2, Marker: true, SequenceNumber: uint16(pkt.FrameNo), Timestamp: pkt.Timestamp}, @@ -167,7 +167,7 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { client.SetDeadline(time.Now().Add(core.ProbeTimeout)) var vcodec, acodec *core.Codec - var tutkAudioCodec uint16 + var tutkAudioCodec byte for { if client.verbose { @@ -197,33 +197,33 @@ func probe(client *Client, quality byte) ([]*core.Media, error) { vcodec = h265.AVCCToCodec(buf) } } - case tutk.AudioCodecG711U: + case tutk.CodecPCMU: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCMU, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecG711A: + case tutk.CodecPCMA: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCMA, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecAACWyze, tutk.AudioCodecAACADTS, tutk.AudioCodecAACRaw, tutk.AudioCodecAACLATM: + case tutk.CodecAACAlt, tutk.CodecAACADTS, tutk.CodecAACRaw, tutk.CodecAACLATM: if acodec == nil { config := aac.EncodeConfig(aac.TypeAACLC, pkt.SampleRate, pkt.Channels, false) acodec = aac.ConfigToCodec(config) tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecOpus: + case tutk.CodecOpus: if acodec == nil { acodec = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecPCM: + case tutk.CodecPCML: if acodec == nil { acodec = &core.Codec{Name: core.CodecPCML, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec } - case tutk.AudioCodecMP3: + case tutk.CodecMP3: if acodec == nil { acodec = &core.Codec{Name: core.CodecMP3, ClockRate: pkt.SampleRate, Channels: pkt.Channels} tutkAudioCodec = pkt.Codec diff --git a/pkg/wyze/tutk/README.md b/pkg/wyze/tutk/README.md deleted file mode 100644 index 36fa4728..00000000 --- a/pkg/wyze/tutk/README.md +++ /dev/null @@ -1,1329 +0,0 @@ -# TUTK/IOTC Protocol Reference for Wyze Cameras - -This document provides a complete reverse-engineering reference for the ThroughTek TUTK/IOTC protocol as used by Wyze cameras. It covers the entire protocol stack from UDP transport through encrypted P2P streaming, enabling implementation of native Wyze camera streaming without the proprietary TUTK SDK. - -## Table of Contents - -1. [Protocol Stack Overview](#1-protocol-stack-overview) -2. [Encryption Layers](#2-encryption-layers) -3. [Connection Flow](#3-connection-flow) -4. [IOTC Packet Structures](#4-iotc-packet-structures) -5. [DTLS Transport](#5-dtls-transport) -6. [AV Login](#6-av-login) -7. [K-Command Authentication](#7-k-command-authentication) -8. [K-Command Control](#8-k-command-control) -9. [AV Frame Structure](#9-av-frame-structure) -10. [FRAMEINFO Structure](#10-frameinfo-structure) -11. [Codec Reference](#11-codec-reference) -12. [Two-Way Audio (Backchannel)](#12-two-way-audio-backchannel) -13. [Frame Reassembly](#13-frame-reassembly) -14. [Wyze Cloud API](#14-wyze-cloud-api) -15. [Cryptography Details](#15-cryptography-details) -16. [Constants Reference](#16-constants-reference) -17. [NEW Protocol (0xCC51) Overview](#17-new-protocol-0xcc51-overview) -18. [NEW Protocol Discovery](#18-new-protocol-discovery) -19. [NEW Protocol DTLS Wrapper](#19-new-protocol-dtls-wrapper) - ---- - -## 1. Protocol Stack Overview - -Wyze cameras support two protocol variants depending on firmware version: - -| Protocol | Firmware | Magic | Discovery | Encryption | -|----------|----------|-------|-----------|------------| -| OLD | Cam v4 ≤ 4.52.9.4188 | TransCode | 0x0601/0x0602 | TransCode + DTLS | -| NEW | Cam v4 ≥ 4.52.9.5332 | 0xCC51 | 0x1002 | HMAC-SHA1 + DTLS | - -### OLD Protocol Stack (TransCode-based) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Frame Layer │ -│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ K-Command Authentication │ -│ K10000-K10003 (XXTEA Challenge-Response) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Login Layer │ -│ Credentials + Capabilities Exchange │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ DTLS 1.2 Encryption │ -│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ IOTC Session │ -│ Discovery (0x0601) + Session Setup (0x0402) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ TransCode Cipher ("Charlie") │ -│ XOR + Bit Rotation Obfuscation │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ UDP Transport │ -│ Port 32761 (default) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### NEW Protocol Stack (0xCC51-based) - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Application Layer │ -│ Video (H.264/H.265) + Audio (AAC/G.711/Opus) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Frame Layer │ -│ Frame Types, Channels, FRAMEINFO, Packet Reassembly │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ K-Command Authentication │ -│ K10000-K10003 (XXTEA Challenge-Response) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ AV Login Layer │ -│ Credentials + Capabilities Exchange │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ DTLS 1.2 Encryption │ -│ PSK = SHA256(ENR), ChaCha20-Poly1305 AEAD │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ NEW Protocol Wrapper (0xCC51) │ -│ Discovery (0x1002) + DTLS Wrapper (0x1502) + HMAC-SHA1 │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ UDP Transport │ -│ Port 32761 (default) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Required Credentials - -| Parameter | Description | Source | -|-----------|-------------|--------| -| UID | Device P2P identifier (20 chars) | Wyze Cloud API | -| ENR | Encryption key (16+ bytes) | Wyze Cloud API | -| MAC | Device MAC address | Wyze Cloud API | -| AuthKey | SHA256(ENR + MAC)[:6] in Base64 | Calculated | - -### Credential Derivation - -``` -AuthKey = Base64(SHA256(ENR + uppercase(MAC))[0:6]) - with substitutions: '+' → 'Z', '/' → '9', '=' → 'A' - -PSK = SHA256(ENR) // 32 bytes for DTLS -``` - ---- - -## 2. Encryption Layers - -The protocol uses three distinct encryption layers: - -### Layer 1: TransCode ("Charlie" Cipher) - -Applied to all IOTC Discovery and Session packets before UDP transmission. - -**Algorithm:** -- XOR with magic string: `"Charlie is the designer of P2P!!"` -- 32-bit left rotation on each block -- Byte permutation/swapping - -**When Applied:** -- Disco Request/Response (0x0601/0x0602) -- Session Request/Response (0x0402/0x0404) -- Data TX/RX wrappers (0x0407/0x0408) - -### Layer 2: DTLS 1.2 - -Encrypts all data after session establishment. - -| Parameter | Value | -|-----------|-------| -| Version | DTLS 1.2 | -| Cipher Suite | TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256 (0xCCAC) | -| PSK Identity | `AUTHPWD_admin` | -| PSK | SHA256(ENR) - 32 bytes | -| Curve | X25519 | - -### Layer 3: XXTEA - -Used for K-Command challenge-response authentication. - -| Status | Key Derivation | -|--------|----------------| -| 1 (Default) | Key = `"FFFFFFFFFFFFFFFF"` (16 x 0xFF) | -| 3 (ENR16) | Key = ENR[0:16] | -| 6 (ENR32) | Double: decrypt with ENR[0:16], then with ENR[16:32] | - ---- - -## 3. Connection Flow - -### 3.1 OLD Protocol Flow (TransCode-based) - -``` -Client Camera - │ │ - │ ═══════════ Phase 1: IOTC Discovery ═══════════════ │ - │ │ - │ Disco Stage 1 (0x0601, broadcast) ───────────────► │ - │ ◄─────────────────────── Disco Response (0x0602) │ - │ Disco Stage 2 (0x0601, direct) ──────────────────► │ - │ │ - │ ═══════════ Phase 2: IOTC Session ═════════════════ │ - │ │ - │ Session Request (0x0402) ────────────────────────► │ - │ ◄───────────────────── Session Response (0x0404) │ - │ │ - │ ═══════════ Phase 3: DTLS Handshake ═══════════════ │ - │ │ - │ ClientHello (in DATA_TX 0x0407) ─────────────────► │ - │ ◄───────────────────── ServerHello + KeyExchange │ - │ ClientKeyExchange + Finished ────────────────────► │ - │ ◄───────────────────────────────── DTLS Finished │ - │ │ - │ ═══════════ Phase 4: AV Login ═════════════════════ │ - │ │ - │ AV Login #1 (magic=0x0000) ──────────────────────► │ - │ AV Login #2 (magic=0x2000) ──────────────────────► │ - │ ◄───────────────────── AV Login Response (0x2100) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 5: K-Authentication ═════════════ │ - │ │ - │ K10000 (Auth Request) ───────────────────────────► │ - │ ◄───────────────────────── K10001 (Challenge 16B) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ K10002 (Response 38B) ───────────────────────────► │ - │ ◄───────────────────────── K10003 (Result, JSON) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 6: Streaming ════════════════════ │ - │ │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ... │ -``` - -### 3.2 NEW Protocol Flow (0xCC51-based) - -``` -Client Camera - │ │ - │ ═══════════ Phase 1: Discovery (0x1002) ═══════════ │ - │ │ - │ seq=0, ticket=0 (broadcast) ────────────────────► │ - │ ◄─────────────── seq=1, ticket=T (response) │ - │ seq=2, ticket=T (echo) ─────────────────────────► │ - │ ◄───────────────────────────── seq=3, ticket=T │ - │ │ - │ ═══════════ Phase 2: DTLS Handshake (0x1502) ══════ │ - │ │ - │ ClientHello (wrapped in 0x1502) ────────────────► │ - │ ◄───────────────────── ServerHello + KeyExchange │ - │ ClientKeyExchange + Finished ───────────────────► │ - │ ◄───────────────────────────────── DTLS Finished │ - │ │ - │ ═══════════ Phase 3: AV Login ═════════════════════ │ - │ │ - │ AV Login #1 (magic=0x0000) ──────────────────────► │ - │ AV Login #2 (magic=0x2000) ──────────────────────► │ - │ ◄───────────────────── AV Login Response (0x2100) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 4: K-Authentication ═════════════ │ - │ │ - │ K10000 (Auth Request) ───────────────────────────► │ - │ ◄───────────────────────── K10001 (Challenge 16B) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ K10002 (Response 38B) ───────────────────────────► │ - │ ◄───────────────────────── K10003 (Result, JSON) │ - │ ACK (0x0009) ────────────────────────────────────► │ - │ │ - │ ═══════════ Phase 5: Streaming ════════════════════ │ - │ │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ◄─────────────────────────────── Video/Audio Data │ - │ ... │ -``` - -**Key Differences from OLD Protocol:** -- Discovery uses 4-packet handshake (seq 0→1→2→3) instead of 2-stage discovery + session setup -- No TransCode encryption layer - packets use HMAC-SHA1 authentication instead -- DTLS records wrapped in 0x1502 frames with auth bytes appended - ---- - -## 4. IOTC Packet Structures - -### 4.1 IOTC Frame Header (16 bytes) - -All IOTC packets share this outer wrapper: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Marker1 Always 0x04 -[1] 1 Marker2 Always 0x02 -[2] 1 Marker3 Always 0x1A -[3] 1 Mode 0x02 (Disco), 0x0A (Session), 0x0B (Data) -[4-5] 2 BodySize Body length in bytes (LE) -[6-7] 2 Sequence Packet sequence number (LE) -[8-9] 2 Command Command ID (LE) -[10-11] 2 Flags Command-specific flags (LE) -[12-15] 4 RandomID Random identifier or metadata -``` - -### 4.2 Disco Request (0x0601) - 80 bytes total - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0601) -[16-35] 20 UID Device UID (null-padded ASCII) -[36-51] 16 Reserved Zero-filled -[52-59] 8 RandomID 8 random bytes for session -[60] 1 Stage 1=broadcast, 2=direct -[61-71] 11 Reserved Zero-filled -[72-79] 8 AuthKey Calculated auth key -``` - -### 4.3 Session Request (0x0402) - 52 bytes total - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0402) -[16-35] 20 UID Device UID (null-padded ASCII) -[36-43] 8 RandomID Same as Disco -[44-47] 4 Reserved Zero-filled -[48-51] 4 Timestamp Unix timestamp (LE) -``` - -### 4.4 Data TX (0x0407) - Variable - -Wraps DTLS records for transmission: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 Header IOTC Frame Header (cmd=0x0407) -[16-17] 2 RandomID[0:2] -[18] 1 Channel 0=Main (DTLS client), 1=Back (DTLS server) -[19] 1 Marker Always 0x01 -[20-23] 4 Const Always 0x0000000C -[24-31] 8 RandomID Full 8-byte random ID -[32+] var Payload DTLS record data -``` - ---- - -## 5. DTLS Transport - -DTLS records are wrapped in IOTC DATA_TX (0x0407) packets for transmission and extracted from DATA_RX (0x0408) packets on reception. - -### PSK Callback - -``` -Identity: "AUTHPWD_admin" -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 - -``` -nonce[12] = IV[12] XOR (epoch[2] || sequenceNumber[6] || padding[4]) -``` - -### AEAD Additional Data - -``` -additional_data = epoch[2] || sequenceNumber[6] || contentType[1] || version[2] || payloadLength[2] -``` - ---- - -## 6. AV Login - -After DTLS handshake, two login packets establish the AV session. - -### AV Login Packet #1 (570 bytes) - -``` -Offset Size Field Value/Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x0000 (LE) -[2-3] 2 Version 0x000C (12) -[4-15] 12 Reserved Zero-filled -[16-17] 2 PayloadSize 0x0222 (546) -[18-19] 2 Flags 0x0001 -[20-23] 4 RandomID 4 random bytes -[24-279] 256 Username "admin" (null-padded) -[280-535] 256 Password ENR string (null-padded) -[536-539] 4 Resend 0=disabled, 1=enabled (see 9.6) -[540-543] 4 SecurityMode 0x00000002 (AV_SECURITY_AUTO) -[544-547] 4 AuthType 0x00000000 (PASSWORD) -[548-551] 4 SyncRecvData 0x00000000 -[552-555] 4 Capabilities 0x001F07FB -[556-569] 14 Reserved Zero-filled -``` - -### AV Login Packet #2 (572 bytes) - -Same structure as #1 with: -- Magic = 0x2000 -- PayloadSize = 0x0224 (548) -- Flags = 0x0000 -- RandomID[0] incremented by 1 - -### AV Login Response (0x2100) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x2100 -[2-3] 2 Version 0x000C -[4] 1 ResponseType 0x10 = success -[5-15] 11 Reserved -[16-19] 4 PayloadSize 0x00000024 (36) -[20-23] 4 Checksum Echo from request -[24-27] 4 Reserved -[28] 1 Flag1 -[29] 1 EnableFlag 0x01 if enabled -[30] 1 Flag2 -[31] 1 TwoWayAudio 0x01 if intercom supported -[32-35] 4 Reserved -[36-39] 4 BufferConfig 0x00000004 -[40-43] 4 Capabilities 0x001F07FB -[44-57] 14 Reserved -``` - ---- - -## 7. K-Command Authentication - -K-Commands use the "HL" header format and are sent inside IOCTRL frames. - -### IOCTRL Frame Wrapper (40+ bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0x000C -[2-3] 2 Version 0x000C -[4-7] 4 AVSeq AV sequence number (LE) -[8-15] 8 Reserved Zero-filled -[16-17] 2 IOCTRLMagic 0x7000 -[18-19] 2 SubChannel Command sequence (increments) -[20-23] 4 IOCTRLSeq Always 0x00000001 -[24-27] 4 PayloadSize HL payload size + 4 -[28-31] 4 Flag Matches SubChannel -[32-35] 4 Reserved -[36-37] 2 IOType 0x0100 -[38-39] 2 Reserved -[40+] var HLPayload K-Command data -``` - -### HL Header (16 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic "HL" (0x48 0x4C) -[2] 1 Version 5 -[3] 1 Reserved 0x00 -[4-5] 2 CommandID 10000, 10001, 10002, etc. (LE) -[6-7] 2 PayloadLen Payload length after header (LE) -[8-15] 8 Reserved Zero-filled -[16+] var Payload Command-specific data -``` - -### K10000 - Auth Request (16 + JSON bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10000, PayloadLen = len(JSON) -[16+] var JSONPayload Audio codec preferences -``` - -**JSON Payload:** -```json -{"cameraInfo":{"audioEncoderList":[137,138,140]}} -``` - -Where audioEncoderList contains supported codec IDs: 137=PCMU, 138=PCMA, 140=PCM. - -### K10001 - Challenge (33+ bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10001 -[16] 1 Status Key selection: 1, 3, or 6 -[17-32] 16 Challenge XXTEA-encrypted challenge bytes -``` - -**Status Interpretation:** -| Status | Key Source | -|--------|------------| -| 1 | Default key: 16 x 0xFF | -| 3 | ENR[0:16] | -| 6 | Double decrypt: first ENR[0:16], then ENR[16:32] | - -### K10002 - Challenge Response (38 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10002, PayloadLen = 22 -[16-31] 16 Response XXTEA-decrypted challenge -[32-35] 4 SessionID Random 4-byte session identifier -[36] 1 VideoFlag 1 = enable video stream -[37] 1 AudioFlag 1 = enable audio stream -``` - -### K10003 - Auth Result - -Variable length, contains JSON payload: - -```json -{ - "connectionRes": "1", - "cameraInfo": { - "basicInfo": { - "firmware": "4.52.9.4188", - "mac": "AABBCCDDEEFF", - "model": "HL_CAM4" - }, - "channelResquestResult": { - "audio": "1", - "video": "1" - } - } -} -``` - -After K10003, video/audio streaming begins automatically. - ---- - -## 8. K-Command Control - -### K10010 - Control Channel (18 bytes) - -Start or stop media streams: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10010, PayloadLen = 2 -[16] 1 MediaType 1=Video, 2=Audio, 3=ReturnAudio -[17] 1 Enable 1=Enable, 2=Disable -``` - -**Media Types:** -| Value | Type | Description | -|-------|------|-------------| -| 1 | Video | Main video stream | -| 2 | Audio | Audio from camera | -| 3 | ReturnAudio | Intercom (audio to camera) | -| 4 | RDT | Raw data transfer | - -### K10056 - Set Resolution (21 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10056, PayloadLen = 5 -[16] 1 FrameSize Resolution + 1 (see table) -[17-18] 2 Bitrate KB/s value (LE) -[19-20] 2 FPS Frames per second, 0 = auto -``` - -**Frame Sizes:** -| Value | Resolution | -|-------|------------| -| 1 | 1080P (1920x1080) | -| 2 | 360P (640x360) | -| 3 | 720P (1280x720) | -| 4 | 2K (2560x1440) | - -**Bitrate Values:** -| Value | Rate | -|-------|------| -| 0xF0 (240) | Maximum | -| 0x3C (60) | SD quality | - -### K10052 - Set Resolution Doorbell (22 bytes) - -Used by doorbell models (WYZEDB3, WVOD1, HL_WCO2, WYZEC1) instead of K10056: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-15] 16 HLHeader CommandID = 10052, PayloadLen = 6 -[16-17] 2 Bitrate KB/s value (LE) -[18] 1 FrameSize Resolution + 1 (see table above) -[19] 1 FPS Frames per second, 0 = auto -[20-21] 2 Reserved Zero-filled -``` - -**Note:** K10052 has a different field order than K10056 (bitrate before frameSize). - ---- - -## 9. AV Frame Structure - -### 9.1 Channels - -| Value | Name | Description | -|-------|------|-------------| -| 0x03 | Audio | Audio frames (always single-packet) | -| 0x05 | I-Video | Keyframes (can be multi-packet) | -| 0x07 | P-Video | Predictive frames (can be multi-packet) | - -### 9.2 Frame Types - -| Type | Name | Header Size | Has FRAMEINFO | -|------|------|-------------|---------------| -| 0x00 | Cont | 28 bytes | No | -| 0x01 | EndSingle | 28 bytes | Yes (40B) | -| 0x04 | ContAlt | 28 bytes | No | -| 0x05 | EndMulti | 28 bytes | Yes (40B) | -| 0x08 | Start | 36 bytes | No | -| 0x09 | StartAlt | 36 bytes | Yes if pkt_total=1 | -| 0x0D | EndExt | 36 bytes | Yes (40B) | - -### 9.3 28-Byte Header Layout - -Used by: Cont (0x00), EndSingle (0x01), ContAlt (0x04), EndMulti (0x05) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03/0x05/0x07 -[1] 1 FrameType 0x00/0x01/0x04/0x05 -[2-3] 2 Version 0x000B (11) -[4-5] 2 TxSequence Global incrementing sequence (LE) -[6-7] 2 Magic 0x507E ("P~") -[8] 1 Channel Duplicate of [0] -[9] 1 StreamIndex 0x00 normal, 0x01 for End packets -[10-11] 2 PacketCounter Running counter (does NOT reset per frame) -[12-13] 2 pkt_total Total packets in this frame (LE) -[14-15] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present -[16-17] 2 PayloadSize Payload bytes (LE) -[18-19] 2 Reserved 0x0000 -[20-23] 4 PrevFrameNo Previous frame number (LE) -[24-27] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY -``` - -### 9.4 36-Byte Header Layout - -Used by: Start (0x08), StartAlt (0x09), EndExt (0x0D) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03/0x05/0x07 -[1] 1 FrameType 0x08/0x09/0x0D -[2-3] 2 Version 0x000B (11) -[4-5] 2 TxSequence Global incrementing sequence (LE) -[6-7] 2 Magic 0x507E ("P~") -[8-11] 4 TimestampOrID Variable (not reliable) -[12-15] 4 Flags Variable -[16] 1 Channel Duplicate of [0] -[17] 1 StreamIndex 0x00 normal, 0x01 for End/Audio -[18-19] 2 ChannelFrameIdx Per-channel index (NOT for reassembly) -[20-21] 2 pkt_total Total packets in this frame (LE) -[22-23] 2 pkt_idx/Marker Packet index OR 0x0028 = FRAMEINFO present -[24-25] 2 PayloadSize Payload bytes (LE) -[26-27] 2 Reserved 0x0000 -[28-31] 4 PrevFrameNo Previous frame number (LE) -[32-35] 4 FrameNo Current frame number (LE) → USE FOR REASSEMBLY -``` - -### 9.5 FRAMEINFO Marker (0x0028) - -The value at offset [14-15] (28-byte) or [22-23] (36-byte) has dual meaning: - -| Condition | Interpretation | -|-----------|----------------| -| End packet AND value == 0x0028 | FRAMEINFO present (40 bytes at payload end) | -| Otherwise | Actual packet index within frame | - -**Note:** 0x0028 hex = 40 decimal. For non-End packets, this could be pkt_idx=40. - -### 9.6 Resend Mode - -The `resend` field in the AV Login packet (offset [536-539]) controls the packet format used for streaming. Setting this value determines whether retransmission support is enabled: - -#### resend=0: Direct Format (Simpler) - -``` -[channel][frameType][version 2B][seq 2B]...[payload] -``` - -Example: -``` -0000: 05 00 0b 00 6d 00 81 4e 05 00 63 00 86 00 00 00 - ^^ ^^ - | frameType=0x00 (continuation) - channel=0x05 (I-Video) -``` - -**Characteristics:** -- First byte is channel: 0x03=Audio, 0x05=I-Video, 0x07=P-Video -- No 0x0c wrapper overhead -- No Frame Index packets (1080 bytes) -- Simpler parsing, less bandwidth - -#### resend=1: Wrapped Format (With Resend Support) - -``` -[0x0c][variant][version 2B][seq 2B]...[channel at offset 16/24] -``` - -Example: -``` -0000: 0c 05 0b 00 e4 00 64 00 0a 00 00 14 01 00 00 00 - ^^ ^^ - | variant=0x05 - 0x0c wrapper (resend marker) -0010: 07 01 c8 00 01 00 28 00 ... - ^^ - channel=0x07 (P-Video) at offset 16 -``` - -**Characteristics:** -- First byte is always 0x0c (resend wrapper) -- Channel byte at offset 16 (variant < 0x08) or 24 (variant >= 0x08) -- Additional 1080-byte Frame Index packets sent periodically -- Enables packet retransmission for reliable delivery - -#### Header Size Rule - -| Variant | Header Size | Channel Offset | -|---------|-------------|----------------| -| < 0x08 | 36 bytes | 16 | -| >= 0x08 | 44 bytes | 24 | - -### 9.7 Frame Index Packets (Inner Byte 0x0c) - -When using `resend=1`, the camera sends periodic **Frame Index** packets (also called Resend Buffer Status). - -#### Packet Structure (1080 bytes total) - -``` -OUTER HEADER (16 bytes): -0000: 0c 00 0b 00 [seq 2B] [sub 2B] [counter 2B] 14 14 01 00 00 00 - ^^^^ ^^^^^ - cmd=0x0c magic - -INNER HEADER (20 bytes): -0010: 0c 00 00 00 00 00 00 00 14 04 00 00 00 00 00 00 00 00 00 00 - ^^^^ ^^^^^ - inner cmd payload_size = 0x0414 = 1044 bytes - -PAYLOAD DATA (starting at offset 0x20): -0020: 00 00 00 00 // 4 zero bytes -0024: [ch] [ft] // channel + frame type -0026: [data 2B] [data 2B] // varies by packet type -... -0030: [prev_frame 4B LE] // previous frame number -0034: [curr_frame 4B LE] // current frame number -``` - -#### Key Offsets - -| Offset | Size | Field | -|--------|------|-------| -| 0x24 (36) | 1 | Channel (0x05=I-Video, 0x07=P-Video) | -| 0x25 (37) | 1 | Frame type | -| 0x30 (48) | 4 | Previous frame number (LE) | -| 0x34 (52) | 4 | Current frame number (LE) | - -#### Packet Types - -| Channel | Description | -|---------|-------------| -| 0x05 | I-Video Frame Index - consecutive frame numbers for GOP sync | -| 0x07 | P-Video - buffer window status (oldest/newest buffered frame) | - ---- - -## 10. FRAMEINFO Structure - -### 10.1 RX FRAMEINFO (40 bytes) - From Camera - -Appended to the end of End packets (0x01, 0x05, 0x0D, or 0x09 when pkt_total=1): - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 codec_id Video: 0x4E (H.264), 0x50 (H.265) - Audio: 0x90 (AAC), 0x89 (G.711μ), etc. -[2] 1 flags Video: 0x00=P-frame, 0x01=I-frame (keyframe) - Audio: (sr_idx << 2) | (bits16 << 1) | stereo -[3] 1 cam_index Camera index (usually 0) -[4] 1 online_num Number of viewers -[5] 1 framerate FPS (e.g., 20, 30) -[6] 1 frame_size 0=1080P, 1=SD, 2=360P, 4=2K -[7] 1 bitrate Bitrate value -[8-11] 4 timestamp_us Microseconds within second (0-999999) -[12-15] 4 timestamp Unix timestamp in seconds (LE) -[16-19] 4 payload_size Total payload size for validation (LE) -[20-23] 4 frame_no Absolute frame counter (LE) -[24-39] 16 device_id MAC address as ASCII + padding -``` - -### 10.2 TX FRAMEINFO (16 bytes) - To Camera - -Used for audio backchannel (intercom): - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 codec_id 0x0090 (AAC Wyze), 0x0089 (G.711μ), etc. -[2] 1 flags (sr_idx << 2) | (bits16 << 1) | stereo -[3] 1 cam_index 0 -[4] 1 online_num 1 (for TX) -[5] 1 tags 0 -[6-11] 6 reserved Zero-filled -[12-15] 4 timestamp_ms Cumulative: (frame_no - 1) * frame_duration_ms -``` - -### 10.3 Audio Flags Encoding - -``` -flags = (sample_rate_index << 2) | (bits16 << 1) | stereo - -Example: 16kHz, 16-bit, Mono - sr_idx=3, bits16=1, stereo=0 - flags = (3 << 2) | (1 << 1) | 0 = 0x0E -``` - ---- - -## 11. Codec Reference - -### 11.1 Video Codecs - -| ID (Hex) | ID (Dec) | Name | -|----------|----------|------| -| 0x4C | 76 | MPEG-4 | -| 0x4D | 77 | H.263 | -| 0x4E | 78 | H.264/AVC | -| 0x4F | 79 | MJPEG | -| 0x50 | 80 | H.265/HEVC | - -### 11.2 Audio Codecs - -| ID (Hex) | ID (Dec) | Name | -|----------|----------|------| -| 0x86 | 134 | AAC Raw | -| 0x87 | 135 | AAC ADTS | -| 0x88 | 136 | AAC LATM | -| 0x89 | 137 | G.711 μ-law (PCMU) | -| 0x8A | 138 | G.711 A-law (PCMA) | -| 0x8B | 139 | ADPCM | -| 0x8C | 140 | PCM 16-bit LE | -| 0x8D | 141 | Speex | -| 0x8E | 142 | MP3 | -| 0x8F | 143 | G.726 | -| 0x90 | 144 | AAC Wyze | -| 0x92 | 146 | Opus | - -### 11.3 Sample Rate Index - -| Index | Frequency | -|-------|-----------| -| 0x00 | 8000 Hz | -| 0x01 | 11025 Hz | -| 0x02 | 12000 Hz | -| 0x03 | 16000 Hz | -| 0x04 | 22050 Hz | -| 0x05 | 24000 Hz | -| 0x06 | 32000 Hz | -| 0x07 | 44100 Hz | -| 0x08 | 48000 Hz | - ---- - -## 12. Two-Way Audio (Backchannel) - -### 12.1 Activation Flow - -1. Send K10010 with MediaType=3 (ReturnAudio), Enable=1 -2. Wait for K10011 response confirming activation -3. Camera initiates DTLS connection back (we become DTLS **server**) -4. Use Channel 1 (IOTCChannelBack) for audio transmission - -### 12.2 Audio TX Frame Format - -All audio TX uses 0x09 single-packet frames with 36-byte header: - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0] 1 Channel 0x03 (Audio) -[1] 1 FrameType 0x09 (StartAlt/Single) -[2-3] 2 Version 0x000C (12) -[4-7] 4 TxSeq Audio TX sequence number (LE) -[8-11] 4 TimestampUS Timestamp in microseconds (LE) -[12-15] 4 Flags 0x00000001 (first), 0x00100001 (subsequent) -[16] 1 Channel 0x03 -[17] 1 FrameType 0x01 (EndSingle) -[18-19] 2 PrevFrameNo prev_frame_no (16-bit, LE) -[20-21] 2 pkt_total 0x0001 (always single packet) -[22-23] 2 Flags 0x0010 -[24-27] 4 PayloadSize audio_len + 16 (includes FRAMEINFO) -[28-31] 4 PrevFrameNo prev_frame_no (32-bit, LE) -[32-35] 4 FrameNo Current frame number (LE) -[36...] AudioPayload AAC/G.711/Opus data -[end-16] 16 FRAMEINFO TX FRAMEINFO (16 bytes) -``` - ---- - -## 13. Frame Reassembly - -### Algorithm - -``` -1. Parse packet header to extract: - - channel, frameType, pkt_idx, pkt_total, frame_no - -2. Detect frame transition: - - If frame_no changed from previous packet: - - Emit previous frame if complete - - Log incomplete frames - -3. Store packet data: - - Key: pkt_idx (0 to pkt_total-1) - - Value: payload bytes (COPY - buffer is reused!) - -4. Store FRAMEINFO if present: - - Only in End packets (0x01, 0x05, 0x0D) - - Or 0x09 when pkt_total == 1 - -5. Check completion: - - All pkt_total packets received? - - FRAMEINFO present? - -6. Assemble frame: - - Concatenate: packets[0] + packets[1] + ... + packets[pkt_total-1] - - Validate size against FRAMEINFO.payload_size - - Emit to consumer -``` - -### Example: Multi-Packet I-Frame (14 packets) - -``` -Packet 1: ch=0x05 type=0x08 pkt=0/14 frame=1 ← Start (36B header) -Packet 2: ch=0x05 type=0x00 pkt=1/14 frame=1 ← Cont (28B header) -Packet 3: ch=0x05 type=0x00 pkt=2/14 frame=1 ← Cont -... -Packet 13: ch=0x05 type=0x00 pkt=12/14 frame=1 ← Cont -Packet 14: ch=0x05 type=0x05 pkt=13/14 frame=1 ← EndMulti + FRAMEINFO -``` - -### Example: Single-Packet P-Frame - -``` -Packet 1: ch=0x07 type=0x01 pkt=0/1 frame=42 ← EndSingle + FRAMEINFO -``` - ---- - -## 14. Wyze Cloud API - -### 14.1 Authentication - -**Endpoint:** `POST https://auth-prod.api.wyze.com/api/user/login` - -**Password Hashing:** Triple MD5 -``` -hash = password -for i in range(3): - hash = MD5(hash).hex() -``` - -**Request Headers:** -``` -Content-Type: application/json -X-API-Key: WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ -Phone-Id: -User-Agent: wyze_ios_2.50.0 -``` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "" -} -``` - -**Response:** -```json -{ - "access_token": "...", - "refresh_token": "...", - "user_id": "..." -} -``` - -### 14.2 Device List - -**Endpoint:** `POST https://api.wyzecam.com/app/v2/home_page/get_object_list` - -**Request Body:** -```json -{ - "access_token": "", - "phone_id": "", - "app_name": "com.hualai.WyzeCam", - "app_ver": "com.hualai.WyzeCam___2.50.0", - "app_version": "2.50.0", - "phone_system_type": 1, - "sc": "9f275790cab94a72bd206c8876429f3c", - "sv": "9d74946e652647e9b6c9d59326aef104", - "ts": -} -``` - -**Response (filtered for cameras):** -```json -{ - "device_list": [ - { - "mac": "AABBCCDDEEFF", - "p2p_id": "HSBJYB5HSETGCDWD111A", - "enr": "roTRg3tiuL3TjXhm...", - "ip": "192.168.1.100", - "nickname": "Front Door", - "product_model": "HL_CAM4", - "dtls": 1, - "firmware_ver": "4.52.9.4188" - } - ] -} -``` - ---- - -## 15. Cryptography Details - -### 15.1 XXTEA Algorithm - -Block cipher used for K-Auth challenge-response: - -``` -Constants: - DELTA = 0x9E3779B9 - -Function mx(sum, y, z, p, e, k): - return (((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ - ((sum ^ y) + (k[(p & 3) ^ e] ^ z)) - -Decrypt(data, key): - v = data as uint32[] (little-endian) - k = key as uint32[] - n = len(v) - rounds = 6 + 52/n - sum = rounds * DELTA - - for round in range(rounds): - e = (sum >> 2) & 3 - for p in range(n-1, 0, -1): - z = v[p-1] - v[p] -= mx(sum, y=v[(p+1) mod n], z, p, e, k) - y = v[p] - z = v[n-1] - v[0] -= mx(sum, y=v[1], z, 0, e, k) - y = v[0] - sum -= DELTA - - return v as bytes -``` - -### 15.2 TransCode ("Charlie" Cipher) - -Obfuscation cipher for IOTC packets: - -``` -Magic string: "Charlie is the designer of P2P!!" - -Process in 16-byte blocks: - 1. XOR each byte with corresponding position in magic string - 2. Treat as 4 x uint32, rotate left by varying amounts - 3. Apply byte permutation pattern - -Permutation for 16-byte block: - [11, 9, 8, 15, 13, 10, 12, 14, 2, 1, 5, 0, 6, 4, 7, 3] -``` - -### 15.3 AuthKey Calculation - -``` -input = ENR + uppercase(MAC) -hash = SHA256(input) -raw = hash[0:6] -b64 = Base64Encode(raw) -authkey = b64.replace('+', 'Z').replace('/', '9').replace('=', 'A') -``` - ---- - -## 16. Constants Reference - -### 16.1 IOTC Commands - -| Command | Value | Description | -|---------|-------|-------------| -| CmdDiscoReq | 0x0601 | Discovery request | -| CmdDiscoRes | 0x0602 | Discovery response | -| CmdSessionReq | 0x0402 | Session request | -| CmdSessionRes | 0x0404 | Session response | -| CmdDataTX | 0x0407 | Data transmission | -| CmdDataRX | 0x0408 | Data reception | -| CmdKeepaliveReq | 0x0427 | Keepalive request | -| CmdKeepaliveRes | 0x0428 | Keepalive response | - -### 16.2 Magic Values - -| Magic | Value | Description | -|-------|-------|-------------| -| MagicAVLogin1 | 0x0000 | AV Login packet 1 | -| MagicAVLogin2 | 0x2000 | AV Login packet 2 | -| MagicAVLoginResp | 0x2100 | AV Login response | -| MagicIOCtrl | 0x7000 | IOCTRL frame | -| MagicChannelMsg | 0x1000 | Channel message | -| MagicACK | 0x0009 | ACK frame | - -### 16.3 K-Commands - -| Command | ID | Description | -|---------|-----|-------------| -| KCmdAuth | 10000 | Auth request (with JSON) | -| KCmdChallenge | 10001 | Challenge from camera | -| KCmdChallengeResp | 10002 | Challenge response | -| KCmdAuthResult | 10003 | Auth result (JSON) | -| KCmdControlChannel | 10010 | Start/stop media | -| KCmdControlChannelResp | 10011 | Control response | -| KCmdSetResolutionDB | 10052 | Set resolution (doorbell) | -| KCmdSetResolutionDBResp | 10053 | Resolution response (doorbell) | -| KCmdSetResolution | 10056 | Set resolution/bitrate | -| KCmdSetResolutionResp | 10057 | Resolution response | - -### 16.4 IOTYPE Values - -| Type | Value | Description | -|------|-------|-------------| -| IOTypeVideoStart | 0x01FF | Start video | -| IOTypeVideoStop | 0x02FF | Stop video | -| IOTypeAudioStart | 0x0300 | Start audio | -| IOTypeAudioStop | 0x0301 | Stop audio | -| IOTypeSpeakerStart | 0x0350 | Start intercom | -| IOTypeSpeakerStop | 0x0351 | Stop intercom | -| IOTypeDevInfoReq | 0x0340 | Device info request | -| IOTypeDevInfoRes | 0x0341 | Device info response | -| IOTypePTZCommand | 0x1001 | PTZ control | -| IOTypeReceiveFirstFrame | 0x1002 | Request keyframe | - -### 16.5 Protocol Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| DefaultPort | 32761 | TUTK discovery port | -| ProtocolVersion | 0x000C | Version 12 | -| DefaultCapabilities | 0x001F07FB | Standard caps | -| MaxPacketSize | 2048 | Max UDP packet | -| IOTCChannelMain | 0 | Main channel (DTLS client) | -| IOTCChannelBack | 1 | Backchannel (DTLS server) | - -### 16.6 NEW Protocol Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| MagicNewProto | 0xCC51 | NEW protocol magic (LE) | -| CmdNewProtoDiscovery | 0x1002 | Discovery command | -| CmdNewProtoDTLS | 0x1502 | DTLS data command | -| NewProtoPayloadSize | 0x0028 | 40 bytes payload | -| NewProtoPacketSize | 52 | Total discovery packet size | -| NewProtoHeaderSize | 28 | DTLS packet header size | -| NewProtoAuthSize | 20 | Auth bytes (HMAC-SHA1) | - ---- - -## 17. NEW Protocol (0xCC51) Overview - -The NEW protocol (magic 0xCC51) is used by Wyze Cam v4 with firmware 4.52.9.5332 and later. It replaces the TransCode cipher layer with HMAC-SHA1 authentication and simplifies the discovery process. - -### Key Differences from OLD Protocol - -| Aspect | OLD Protocol | NEW Protocol | -|--------|--------------|--------------| -| Magic | TransCode encoded | 0xCC51 | -| Discovery | 0x0601/0x0602 + 0x0402/0x0404 | 0x1002 (4-packet handshake) | -| Encryption | TransCode + DTLS | HMAC-SHA1 + DTLS | -| DTLS Wrapper | DATA_TX 0x0407 | 0x1502 with auth bytes | -| P2P Servers | Required for relay | Not required (LAN only) | - -### Authentication - -All NEW protocol packets include a 20-byte HMAC-SHA1 authentication field: - -```go -// Key derivation -authKey := CalculateAuthKey(enr, mac) // 8-byte key from ENR + MAC -key := append([]byte(uid), authKey...) // UID (20 bytes) + AuthKey (8 bytes) - -// HMAC-SHA1 calculation -h := hmac.New(sha1.New, key) -h.Write(packetHeader) // Header bytes before auth field -authBytes := h.Sum(nil) // 20 bytes -``` - ---- - -## 18. NEW Protocol Discovery - -Discovery uses command 0x1002 with a 4-packet handshake sequence. - -### 18.1 Discovery Packet Structure (52 bytes) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0xCC51 (little-endian) -[2-3] 2 Flags 0x0000 (constant) -[4-5] 2 Command 0x1002 (Discovery) -[6-7] 2 PayloadSize 0x0028 (40 bytes) -[8-9] 2 Direction 0x0000=Request, 0xFFFF=Response -[10-11] 2 Reserved 0x0000 -[12-13] 2 Sequence 0, 1, 2, or 3 -[14-15] 2 Ticket 0x0000 initially, then from camera -[16-23] 8 SessionID Random[2] + Constant[6] -[24-31] 8 Capabilities 0x00 08 03 04 1d 00 00 00 -[32-51] 20 AuthBytes HMAC-SHA1(key, header[0:32]) -``` - -### 18.2 Handshake Sequence - -``` -Step Direction Seq Ticket Description -──────────────────────────────────────────────────────────────── -1 Client→Cam 0 0x0000 Discovery request (broadcast) -2 Cam→Client 1 T Discovery response (ticket assigned) -3 Client→Cam 2 T Echo request (confirms ticket) -4 Cam→Client 3 T Echo ACK (handshake complete) -``` - -### 18.3 SessionID Generation - -```go -sessionID := make([]byte, 8) -rand.Read(sessionID[:2]) // Random prefix -copy(sessionID[2:], []byte{0x76, 0x0a, 0x9d, 0x24, 0x88, 0xba}) // Constant suffix -``` - ---- - -## 19. NEW Protocol DTLS Wrapper - -After discovery, DTLS records are wrapped in command 0x1502 frames with HMAC-SHA1 authentication. - -### 19.1 DTLS Wrapper Structure (variable size) - -``` -Offset Size Field Description -────────────────────────────────────────────────────────────── -[0-1] 2 Magic 0xCC51 (little-endian) -[2-3] 2 Flags 0x0000 -[4-5] 2 Command 0x1502 (DTLS) -[6-7] 2 PayloadSize 16 + dtls_len + 20 -[8-9] 2 Direction 0x0000=Request -[10-11] 2 Reserved 0x0000 -[12-13] 2 Sequence 0x0010 (fixed for DTLS) -[14-15] 2 Ticket From discovery handshake -[16-23] 8 SessionID 8 bytes from discovery -[24-27] 4 Channel 1=Main (client), 2=Back (server) -[28-N] var DTLSPayload Raw DTLS record -[N:N+20] 20 AuthBytes HMAC-SHA1(key, bytes[0:N]) -``` - -### 19.2 PayloadSize Calculation - -``` -PayloadSize = 16 + len(DTLSPayload) + 20 - -Where: - 16 = seq(2) + ticket(2) + sessionID(8) + channel(4) - 20 = AuthBytes (HMAC-SHA1) -``` - -### 19.3 TX/RX Processing - -**Transmit (TX):** -1. Build header with magic, command, payload size -2. Append session fields (seq, ticket, sessionID, channel) -3. Append DTLS payload -4. Calculate HMAC-SHA1 over entire packet (excluding auth bytes position) -5. Append auth bytes - -**Receive (RX):** -1. Verify magic == 0xCC51 -2. Extract DTLS payload from position 28 to (length - 20) -3. Strip 20 auth bytes from end -4. Pass DTLS payload to DTLS layer diff --git a/pkg/wyze/tutk/proto.go b/pkg/wyze/tutk/proto.go deleted file mode 100644 index 5614d643..00000000 --- a/pkg/wyze/tutk/proto.go +++ /dev/null @@ -1,281 +0,0 @@ -package tutk - -type AVLoginResponse struct { - ServerType uint32 - Resend int32 - TwoWayStreaming int32 - SyncRecvData int32 - SecurityMode uint32 - VideoOnConnect int32 - AudioOnConnect int32 -} - -const ( - CodecUnknown uint16 = 0x00 - CodecMPEG4 uint16 = 0x4C // 76 - CodecH263 uint16 = 0x4D // 77 - CodecH264 uint16 = 0x4E // 78 - CodecMJPEG uint16 = 0x4F // 79 - CodecH265 uint16 = 0x50 // 80 -) - -const ( - AudioCodecAACRaw uint16 = 0x86 // 134 - AudioCodecAACADTS uint16 = 0x87 // 135 - AudioCodecAACLATM uint16 = 0x88 // 136 - AudioCodecG711U uint16 = 0x89 // 137 - AudioCodecG711A uint16 = 0x8A // 138 - AudioCodecADPCM uint16 = 0x8B // 139 - AudioCodecPCM uint16 = 0x8C // 140 - AudioCodecSPEEX uint16 = 0x8D // 141 - AudioCodecMP3 uint16 = 0x8E // 142 - AudioCodecG726 uint16 = 0x8F // 143 - AudioCodecAACWyze uint16 = 0x90 // 144 - AudioCodecOpus uint16 = 0x92 // 146 -) - -const ( - SampleRate8K uint8 = 0x00 - SampleRate11K uint8 = 0x01 - SampleRate12K uint8 = 0x02 - SampleRate16K uint8 = 0x03 - SampleRate22K uint8 = 0x04 - SampleRate24K uint8 = 0x05 - SampleRate32K uint8 = 0x06 - SampleRate44K uint8 = 0x07 - SampleRate48K uint8 = 0x08 -) - -var sampleRates = map[uint8]int{ - SampleRate8K: 8000, - SampleRate11K: 11025, - SampleRate12K: 12000, - SampleRate16K: 16000, - SampleRate22K: 22050, - SampleRate24K: 24000, - SampleRate32K: 32000, - SampleRate44K: 44100, - SampleRate48K: 48000, -} - -var samplesPerFrame = map[uint16]uint32{ - AudioCodecAACRaw: 1024, - AudioCodecAACADTS: 1024, - AudioCodecAACLATM: 1024, - AudioCodecAACWyze: 1024, - AudioCodecG711U: 160, - AudioCodecG711A: 160, - AudioCodecPCM: 160, - AudioCodecADPCM: 160, - AudioCodecSPEEX: 160, - AudioCodecMP3: 1152, - AudioCodecG726: 160, - AudioCodecOpus: 960, -} - -const ( - IOTypeVideoStart = 0x01FF - IOTypeVideoStop = 0x02FF - IOTypeAudioStart = 0x0300 - IOTypeAudioStop = 0x0301 - IOTypeSpeakerStart = 0x0350 - IOTypeSpeakerStop = 0x0351 - IOTypeGetAudioOutFormatReq = 0x032A - IOTypeGetAudioOutFormatRes = 0x032B - IOTypeSetStreamCtrlReq = 0x0320 - IOTypeSetStreamCtrlRes = 0x0321 - IOTypeGetStreamCtrlReq = 0x0322 - IOTypeGetStreamCtrlRes = 0x0323 - IOTypeDevInfoReq = 0x0340 - IOTypeDevInfoRes = 0x0341 - IOTypeGetSupportStreamReq = 0x0344 - IOTypeGetSupportStreamRes = 0x0345 - IOTypeSetRecordReq = 0x0310 - IOTypeSetRecordRes = 0x0311 - IOTypeGetRecordReq = 0x0312 - IOTypeGetRecordRes = 0x0313 - IOTypePTZCommand = 0x1001 - IOTypeReceiveFirstFrame = 0x1002 - IOTypeGetEnvironmentReq = 0x030A - IOTypeGetEnvironmentRes = 0x030B - IOTypeSetVideoModeReq = 0x030C - IOTypeSetVideoModeRes = 0x030D - IOTypeGetVideoModeReq = 0x030E - IOTypeGetVideoModeRes = 0x030F - IOTypeSetTimeReq = 0x0316 - IOTypeSetTimeRes = 0x0317 - IOTypeGetTimeReq = 0x0318 - IOTypeGetTimeRes = 0x0319 - IOTypeSetWifiReq = 0x0102 - IOTypeSetWifiRes = 0x0103 - IOTypeGetWifiReq = 0x0104 - IOTypeGetWifiRes = 0x0105 - IOTypeListWifiAPReq = 0x0106 - IOTypeListWifiAPRes = 0x0107 - IOTypeSetMotionDetectReq = 0x0306 - IOTypeSetMotionDetectRes = 0x0307 - IOTypeGetMotionDetectReq = 0x0308 - IOTypeGetMotionDetectRes = 0x0309 -) - -// OLD Protocol (IOTC/TransCode) -const ( - CmdDiscoReq uint16 = 0x0601 - CmdDiscoRes uint16 = 0x0602 - CmdSessionReq uint16 = 0x0402 - CmdSessionRes uint16 = 0x0404 - CmdDataTX uint16 = 0x0407 - CmdDataRX uint16 = 0x0408 - CmdKeepaliveReq uint16 = 0x0427 - CmdKeepaliveRes uint16 = 0x0428 - - OldHeaderSize = 16 - OldDiscoBodySize = 72 - OldDiscoSize = OldHeaderSize + OldDiscoBodySize - OldSessionBody = 36 - OldSessionSize = OldHeaderSize + OldSessionBody -) - -// NEW Protocol (0xCC51) -const ( - MagicNewProto uint16 = 0xCC51 - CmdNewDisco uint16 = 0x1002 - CmdNewKeepalive uint16 = 0x1202 - CmdNewClose uint16 = 0x1302 - CmdNewDTLS uint16 = 0x1502 - NewPayloadSize uint16 = 0x0028 - NewPacketSize = 52 - NewHeaderSize = 28 - NewAuthSize = 20 - NewKeepaliveSize = 48 -) - -const ( - UIDSize = 20 - RandIDSize = 8 -) - -const ( - MagicAVLoginResp uint16 = 0x2100 - MagicIOCtrl uint16 = 0x7000 - MagicChannelMsg uint16 = 0x1000 - MagicACK uint16 = 0x0009 - MagicAVLogin1 uint16 = 0x0000 - MagicAVLogin2 uint16 = 0x2000 -) - -const ( - ProtoVersion uint16 = 0x000c - DefaultCaps uint32 = 0x001f07fb -) - -const ( - IOTCChannelMain = 0 // Main AV (we = DTLS Client) - IOTCChannelBack = 1 // Backchannel (we = DTLS Server) -) - -const ( - PSKIdentity = "AUTHPWD_admin" - DefaultUser = "admin" - DefaultPort = 32761 -) - -func CodecName(id uint16) string { - switch id { - case CodecH264: - return "H264" - case CodecH265: - return "H265" - case CodecMPEG4: - return "MPEG4" - case CodecH263: - return "H263" - case CodecMJPEG: - return "MJPEG" - default: - return "Unknown" - } -} - -func AudioCodecName(id uint16) string { - switch id { - case AudioCodecG711U: - return "PCMU" - case AudioCodecG711A: - return "PCMA" - case AudioCodecPCM: - return "PCM" - case AudioCodecAACLATM, AudioCodecAACRaw, AudioCodecAACADTS, AudioCodecAACWyze: - return "AAC" - case AudioCodecOpus: - return "Opus" - case AudioCodecSPEEX: - return "Speex" - case AudioCodecMP3: - return "MP3" - case AudioCodecG726: - return "G726" - case AudioCodecADPCM: - return "ADPCM" - default: - return "Unknown" - } -} - -func SampleRateValue(idx uint8) int { - if rate, ok := sampleRates[idx]; ok { - return rate - } - return 16000 -} - -func SampleRateIndex(hz uint32) uint8 { - switch hz { - case 8000: - return SampleRate8K - case 11025: - return SampleRate11K - case 12000: - return SampleRate12K - case 16000: - return SampleRate16K - case 22050: - return SampleRate22K - case 24000: - return SampleRate24K - case 32000: - return SampleRate32K - case 44100: - return SampleRate44K - case 48000: - return SampleRate48K - default: - return SampleRate16K - } -} - -func BuildAudioFlags(sampleRate uint32, bits16, stereo bool) uint8 { - flags := SampleRateIndex(sampleRate) << 2 - if bits16 { - flags |= 0x02 - } - if stereo { - flags |= 0x01 - } - return flags -} - -func IsVideoCodec(id uint16) bool { - return id >= CodecMPEG4 && id <= CodecH265 -} - -func IsAudioCodec(id uint16) bool { - return id >= AudioCodecAACRaw && id <= AudioCodecOpus -} - -func GetSamplesPerFrame(codecID uint16) uint32 { - if samples, ok := samplesPerFrame[codecID]; ok { - return samples - } - return 1024 -} diff --git a/pkg/xiaomi/legacy/client.go b/pkg/xiaomi/legacy/client.go index a35592d4..242fda3d 100644 --- a/pkg/xiaomi/legacy/client.go +++ b/pkg/xiaomi/legacy/client.go @@ -107,7 +107,7 @@ func (c *Client) ReadPacket() (hdr, payload []byte, err error) { switch hdr[0] { case tutk.CodecH264, tutk.CodecH265: payload, err = DecodeVideo(payload, c.key) - case tutk.CodecAAC: + case tutk.CodecAACLATM: payload, err = crypto.Decode(payload, c.key) } } diff --git a/pkg/xiaomi/legacy/producer.go b/pkg/xiaomi/legacy/producer.go index 5c1f795d..92375faf 100644 --- a/pkg/xiaomi/legacy/producer.go +++ b/pkg/xiaomi/legacy/producer.go @@ -98,7 +98,7 @@ func probe(client *Client) ([]*core.Media, error) { if acodec == nil { acodec = &core.Codec{Name: core.CodecPCML, ClockRate: 8000} } - case tutk.CodecAAC: + case tutk.CodecAACLATM: if acodec == nil { acodec = aac.ADTSToCodec(payload) if acodec != nil { @@ -187,7 +187,7 @@ func (c *Producer) Start() error { audioTS += uint32(n / 2) // because 16bit } - case tutk.CodecAAC: + case tutk.CodecAACLATM: pkt = &core.Packet{ Header: rtp.Header{ SequenceNumber: audioSeq,