mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-05 08:16:55 +08:00
370 lines
7.9 KiB
Go
370 lines
7.9 KiB
Go
package hap
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/chacha20poly1305"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/curve25519"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/ed25519"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/hkdf"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/secure"
|
|
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
|
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
|
)
|
|
|
|
const (
|
|
ConnDialTimeout = time.Second * 3
|
|
ConnDeadline = time.Second * 3
|
|
)
|
|
|
|
// Client for HomeKit. DevicePublic can be null.
|
|
type Client struct {
|
|
DeviceAddress string // including port
|
|
DeviceID string // aka. Accessory
|
|
DevicePublic []byte
|
|
ClientID string // aka. Controller
|
|
ClientPrivate []byte
|
|
|
|
OnEvent func(res *http.Response)
|
|
//Output func(msg any)
|
|
|
|
Conn net.Conn
|
|
reader *bufio.Reader
|
|
|
|
res chan *http.Response
|
|
err error
|
|
}
|
|
|
|
func NewClient(rawURL string) (*Client, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := u.Query()
|
|
c := &Client{
|
|
DeviceAddress: u.Host,
|
|
DeviceID: query.Get("device_id"),
|
|
DevicePublic: DecodeKey(query.Get("device_public")),
|
|
ClientID: query.Get("client_id"),
|
|
ClientPrivate: DecodeKey(query.Get("client_private")),
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Client) ClientPublic() []byte {
|
|
return c.ClientPrivate[32:]
|
|
}
|
|
|
|
func (c *Client) URL() string {
|
|
return fmt.Sprintf(
|
|
"homekit://%s?device_id=%s&device_public=%16x&client_id=%s&client_private=%32x",
|
|
c.DeviceAddress, c.DeviceID, c.DevicePublic, c.ClientID, c.ClientPrivate,
|
|
)
|
|
}
|
|
|
|
func (c *Client) DeviceHost() string {
|
|
if i := strings.IndexByte(c.DeviceAddress, ':'); i > 0 {
|
|
return c.DeviceAddress[:i]
|
|
}
|
|
return c.DeviceAddress
|
|
}
|
|
|
|
func (c *Client) Dial() (err error) {
|
|
if len(c.ClientID) == 0 || len(c.ClientPrivate) == 0 {
|
|
return errors.New("hap: can't dial witout client_id or client_private")
|
|
}
|
|
|
|
// update device address (host and/or port) before dial
|
|
_ = mdns.QueryOrDiscovery(c.DeviceHost(), mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
|
if entry.Complete() && entry.Info["id"] == c.DeviceID {
|
|
c.DeviceAddress = entry.Addr()
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
if c.Conn, err = net.DialTimeout("tcp", c.DeviceAddress, ConnDialTimeout); err != nil {
|
|
return
|
|
}
|
|
|
|
c.reader = bufio.NewReader(c.Conn)
|
|
|
|
// STEP M1: send our session public to device
|
|
sessionPublic, sessionPrivate := curve25519.GenerateKeyPair()
|
|
|
|
// 1. Send sessionPublic
|
|
plainM1 := struct {
|
|
PublicKey string `tlv8:"3"`
|
|
State byte `tlv8:"6"`
|
|
}{
|
|
PublicKey: string(sessionPublic),
|
|
State: StateM1,
|
|
}
|
|
res, err := c.Post(PathPairVerify, MimeTLV8, tlv8.MarshalReader(plainM1))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// STEP M2: unpack deviceID from response
|
|
var cipherM2 struct {
|
|
PublicKey string `tlv8:"3"`
|
|
EncryptedData string `tlv8:"5"`
|
|
State byte `tlv8:"6"`
|
|
}
|
|
if err = tlv8.UnmarshalReader(res.Body, &cipherM2); err != nil {
|
|
return err
|
|
}
|
|
if cipherM2.State != StateM2 {
|
|
return newResponseError(plainM1, cipherM2)
|
|
}
|
|
|
|
// 1. generate session shared key
|
|
sessionShared, err := curve25519.SharedSecret(sessionPrivate, []byte(cipherM2.PublicKey))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
sessionKey, err := hkdf.Sha512(
|
|
sessionShared, "Pair-Verify-Encrypt-Salt", "Pair-Verify-Encrypt-Info",
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// 2. decrypt M2 response with session key
|
|
b, err := chacha20poly1305.Decrypt(sessionKey, "PV-Msg02", []byte(cipherM2.EncryptedData))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// 3. unpack payload from TLV8
|
|
var plainM2 struct {
|
|
Identifier string `tlv8:"1"`
|
|
Signature string `tlv8:"10"`
|
|
}
|
|
if err = tlv8.Unmarshal(b, &plainM2); err != nil {
|
|
return
|
|
}
|
|
|
|
// 4. verify signature for M2 response with device public
|
|
// device session + device id + our session
|
|
if c.DevicePublic != nil {
|
|
b = Append(cipherM2.PublicKey, plainM2.Identifier, sessionPublic)
|
|
if !ed25519.ValidateSignature(c.DevicePublic, b, []byte(plainM2.Signature)) {
|
|
return errors.New("hap: ValidateSignature")
|
|
}
|
|
}
|
|
|
|
// STEP M3: send our clientID to device
|
|
// 1. generate signature with our private key
|
|
// (our session + our ID + device session)
|
|
b = Append(sessionPublic, c.ClientID, cipherM2.PublicKey)
|
|
if b, err = ed25519.Signature(c.ClientPrivate, b); err != nil {
|
|
return
|
|
}
|
|
|
|
// 2. generate payload
|
|
plainM3 := struct {
|
|
Identifier string `tlv8:"1"`
|
|
Signature string `tlv8:"10"`
|
|
}{
|
|
Identifier: c.ClientID,
|
|
Signature: string(b),
|
|
}
|
|
if b, err = tlv8.Marshal(plainM3); err != nil {
|
|
return
|
|
}
|
|
|
|
// 4. encrypt payload with session key
|
|
if b, err = chacha20poly1305.Encrypt(sessionKey, "PV-Msg03", b); err != nil {
|
|
return
|
|
}
|
|
|
|
// 4. generate request
|
|
cipherM3 := struct {
|
|
EncryptedData string `tlv8:"5"`
|
|
State byte `tlv8:"6"`
|
|
}{
|
|
State: StateM3,
|
|
EncryptedData: string(b),
|
|
}
|
|
if res, err = c.Post(PathPairVerify, MimeTLV8, tlv8.MarshalReader(cipherM3)); err != nil {
|
|
return
|
|
}
|
|
|
|
// STEP M4. Read response
|
|
var plainM4 struct {
|
|
State byte `tlv8:"6"`
|
|
}
|
|
if err = tlv8.UnmarshalReader(res.Body, &plainM4); err != nil {
|
|
return
|
|
}
|
|
if plainM4.State != StateM4 {
|
|
return newResponseError(cipherM3, plainM4)
|
|
}
|
|
|
|
// like tls.Client wrapper over net.Conn
|
|
if c.Conn, err = secure.Client(c.Conn, sessionShared, true); err != nil {
|
|
return
|
|
}
|
|
// new reader for new conn
|
|
c.reader = bufio.NewReader(c.Conn)
|
|
|
|
return
|
|
}
|
|
|
|
func (c *Client) Close() error {
|
|
if c.Conn == nil {
|
|
return nil
|
|
}
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
func (c *Client) eventsReader() {
|
|
c.res = make(chan *http.Response)
|
|
|
|
for {
|
|
var res *http.Response
|
|
if res, c.err = ReadResponse(c.reader, nil); c.err != nil {
|
|
break
|
|
}
|
|
|
|
var body []byte
|
|
if body, c.err = io.ReadAll(res.Body); c.err != nil {
|
|
break
|
|
}
|
|
|
|
res.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
if res.Proto != ProtoEvent {
|
|
c.res <- res
|
|
} else if c.OnEvent != nil {
|
|
c.OnEvent(res)
|
|
}
|
|
}
|
|
|
|
close(c.res)
|
|
}
|
|
|
|
func (c *Client) GetAccessories() ([]*Accessory, error) {
|
|
res, err := c.Get(PathAccessories)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var v JSONAccessories
|
|
if err = json.NewDecoder(res.Body).Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return v.Value, nil
|
|
}
|
|
|
|
func (c *Client) GetFirstAccessory() (*Accessory, error) {
|
|
accs, err := c.GetAccessories()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(accs) == 0 {
|
|
return nil, errors.New("hap: GetAccessories zero answer")
|
|
}
|
|
return accs[0], nil
|
|
}
|
|
|
|
func (c *Client) GetCharacters(query string) ([]JSONCharacter, error) {
|
|
res, err := c.Get(PathCharacteristics + "?id=" + query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var v JSONCharacters
|
|
if err = json.Unmarshal(data, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
return v.Value, nil
|
|
}
|
|
|
|
func (c *Client) GetCharacter(char *Character) error {
|
|
query := fmt.Sprintf("%d.%d", DeviceAID, char.IID)
|
|
chars, err := c.GetCharacters(query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
char.Value = chars[0].Value
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) PutCharacters(characters ...*Character) error {
|
|
var v JSONCharacters
|
|
for i, char := range characters {
|
|
v.Value = append(v.Value, JSONCharacter{
|
|
AID: 1,
|
|
IID: char.IID,
|
|
Value: char.Value,
|
|
})
|
|
characters[i] = char
|
|
}
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
res, err := c.Put(PathCharacteristics, MimeJSON, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = io.ReadAll(res.Body) // important to "clear" body
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetImage(width, height int) ([]byte, error) {
|
|
s := fmt.Sprintf(
|
|
`{"image-width":%d,"image-height":%d,"resource-type":"image","reason":0}`,
|
|
width, height,
|
|
)
|
|
res, err := c.Post(PathResource, MimeJSON, bytes.NewBufferString(s))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.ReadAll(res.Body)
|
|
}
|
|
|
|
func (c *Client) LocalIP() string {
|
|
if c.Conn == nil {
|
|
return ""
|
|
}
|
|
addr := c.Conn.LocalAddr().(*net.TCPAddr)
|
|
return addr.IP.String()
|
|
}
|
|
|
|
func DecodeKey(s string) []byte {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
data, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return data
|
|
}
|