DISCLAIMER: was translated with translate.google.com. Original.

This article will focus on the Apple HomeKit Accessory Protocol (HAP): internals and controller development.

Apple HomeKit is designed to interact with a controller (by default, iOS devices, the Home app) and a variety of devices (accessories). The protocol is open for non-commercial use and can be downloaded from the Apple website. Several open-source projects have been created based on this version of the protocol, and when people talk about HomeKit on a Raspberry Pi, they usually mean installing homebridge and plugins to create compatible accessories.

The reverse task - creating a controller - is not so common, and from the projects I managed to find only pypi.org/project/homekit/ .

Let’s set the task of creating a controller, for example, to control accessories from an Android phone and try to solve it. For simplicity, we will work only with IP networks, without Bluetooth.

How should it work?

  1. Device discovery

In order to start working with accessories, they must first be discovered. Devices advertise themselves in accordance with the Multicast DNS and DNS service discovery protocols.

Simply put, you can find a device on the local network by sending a multicast request _hap._tcp.local to 224.0.0.251 , and, after receiving the response, parse the DNS records A, SRV, TXT. After that, you can connect to the service using the information received.

  1. Setting up a secure connection

Two scenarios are possible: the devices are already connected, or the connection (pairing) only needs to be established. In the first case, you need to move to the /pair-verify step, in the case of a new connection, the first step is to perform the /pair-setup step.

Apple HomeKit uses Stanford’s Secure Remote Password (SRP) protocol using a password (pin).

  1. Working with accessories, characteristics and their values.

After secure connection was established we can work with accessories and their values.

/pair-setup

Communication takes place over an established TCP connection. All requests in this step are regular HTTP POST requests with the application/pairing+tlv8 data type and, accordingly, with the TLV -encoded body.

The following is a summary of what happens at this stage:

  • M1: The controller sends a connection request (SRP Start Request)

  • M2: The accessory initiates a new SRP session, generates the necessary randoms and a key pair. In response, the generated public key and salt are sent to the controller. (SRP Start Response)

  • M3: The controller sends a data verification request (SRP Verify Request). At this step, the controller generates its session key pair, asks the user to enter a pin code, calculates the SRP session common key and proof (SRP proof). The generated public key and proof are sent to the accessory.

  • M4: The accessory verifies the controller proof and sends its proof in response (SRP Verify Response).

  • M5: controller -> accessory (‘Exchange Requestʼ). First of all, the controller checks the proof of the accessory. After that, a long-term key pair (LTPK and LTSK) is generated on the ed25519 curve. The controller generates a new key (HKDF) from the session key, concatenates it with the controller ID (iOSDevicePairingID) and its public key (iOSDeviceLTPK), signs it with a secret LTSK. The identifier, public key and signature are written in a TLV message, encrypted with the ChaCha20-Poly1305 algorithm using a common session key. The encrypted message is again written as a TLV message and sent to the accessory.

  • M6: accessory -> controller (‘Exchange Responseʼ). Here, the accessory extracts information (iOSDeviceLTPK, iOSDevicePairingID), verifies the signature. Further, similarly, he signs and sends his identifier, long-term public key, signature.

After successfully completing all steps M1-M6, the controller and iOS device retain each other’s identifiers and public keys (LTPK) for a long time.

/pair-verify

The procedure is used each time to establish a secure connection. Here, there are already fewer steps (M1-M4).

Each participant: both the Controller and the Accessory generate Curve25519 key pairs, send public keys to each other and generate a symmetric shared key, from which the session key is formed. Long-term keys (LTPK and LTSK) are only used to verify signatures. Secure connection

After the successful completion of the pair-verify procedure, the TCP connection remains open and all data inside it is encrypted with the session key. It turns out that the Keep-Alive HTTP connection is “updated” (similar to the WebSocket Upgrade) and now, in order to get the correct HTTP, the data must first be decrypted.

Data - just like HTTP requests and responses, but already standard json.

The solution: choice

The choice settled on Go and the brutella/hap package. The module does not contain the implementation of the controller and there are no plans to add it, so you will need to do everything yourself. But this is simple, given that all cryptographic procedures are implemented for the server side.

The Go solution was also supported by the fact that you can write the graphic part on it, including for Android ( fyne.io , gioui.org ).

The module has been forked, the superfluous has been removed, files for the controller part have been added.

Implementation:

I won’t go into detail on the implementation, just a few points.

  • When devices are detected, the controller tries to connect via TCP one by one for different ip-addresses of the device. After the first successful attempt, the data is stored for the subsequent establishment of a permanent connection.

  • Since all requests are http, you can use Go’s native http.Client implementation. The question arose how to make it work with a regular TCP connection? To do this, you need to support the RoundTripper interface:

func (c *conn) RoundTrip(req *http.Request) (*http.Response, error) {
  err := req.Write(c)
  if err != nil {
    return nil, err
  }
  if c.inBackground {
    res := <-c.response
    return res, nil
  }
  rd := bufio.NewReader(c)
  res, err := http.ReadResponse(rd, nil)
  if err != nil {
    return nil, err
  }

  return res, nil
}

After that, we can assign http.Client and use it:

	d.httpc = &http.Client{
		Transport: c,
	}

        // to use:
	res, err := d.httpc.Get("/accessories")
        ...
  • And the most interesting. If you look at the code above, you can see the condition on the inBackground flag. After all, it was possible to get by with one http.ReadResponse. And at the stage of pair-setup and pair-verify it works. The problem occurs after a secure session has been established. The fact is that accessories can send notifications about changes in values. And such notifications look like this:
EVENT/1.0 200 OK
Content-Type: application/hap+json
Content-Length: <length>
{
  ”characteristics” : [{
    ”aid” : 1,
    ”iid” : 4,
    ”value” : 23.0
  }]
}

What we have? First, all data must be read in a loop so as not to miss notifications. Second, http.ReadResponse can’t handle it because EVENT is not a standard http header.

The first one is easy to deal with - we launch the goroutine that reads the data:

func (c *conn) backgroundRead() {
  rd := bufio.NewReader(c)

  for {
	b, err := rd.Peek(len(eventHeader)) // len of EVENT string
	if err != nil {
		fmt.Println(err)
		if errors.Is(err, io.EOF) {
			return
		}
		continue
	}
	if string(b) == eventHeader {
      // handling event:
      // transforming (EVENT to HTTP)
      // read http.Response with res := http.ReadResponse()
      // read all := io.ReadAll(res.Body)
      // reassign res.Body = io.NopCloser(bytes.NewReader(all))
      // callback(res)
    } else {
      // handling response:
      // read res := http.ReadResponse()
      // read all := io.ReadAll(res.Body)
      // reassign res.Body = io.NopCloser(bytes.NewReader(all))
      // send res to channel
    }
  }
}

Each iteration, we check the header for a match with EVENT and in this case - “transform” - we replace EVENT with HTTP for successful processing by the http.ReadResponse method. To replace, we write a structure with an implementation of the io.Reader interface.

The next problem that occurred was that in some cases (long answer) when iterating through the loop, an error occurred due to an invalid HTTP header. The problem is that ReadResponse returns a response with a Body field in which the data is not read, which means that it is not read in our connection either. The solution is to read res.Body completely and only after that you can move on to the next iteration.

GUI

The gioui.org module was used to sketch the graphical application . At the moment, the application is not rich in functionality - device discovery, authentication and connection establishment, control of relay accessories and lamps (On-Off).

The application was tested in tandem with homebridge.

PS: unfortunately, when running on Android, the application could not detect any device.

avc: denied { bind } for scontext=u:r:untrusted_app:s0:c31,c257,c512,c768 tcontext=u:r:untrusted_app:s0:c31,c257,c512,c768 tclass=netlink_route_socket permissive=0 b/155595000 app=localhost.hkapp

If you are interested in open-source development, welcome to participate.