The Essentials bulb by nanoleaf recently gained local control via Thread. Using an Elements device as a Thread Border Router causes Essentials devices to be advertised via a mDNS service _hap._udp
and _ltpdu._udp
. Those services reference the standard CoAP port and accessing the CoRE resource discovery endpoint reveals the following:
</.well-known/core>; </nlpublic>; </nlltpdu>; </nlsecure>; </>; </2>; </1>; </0>;
The vendor applications communicate with this endpoint when performing the Identify (bulb flash) function. The packet format is clear and used throughout:
Request: CoAP POST /nlpublic Data sent (binary, shown as hex below): 0001 0007 6c622f302f6964 0002 0000 Data sent (decoded): Tag Len Value Tag Len Value 1 7 "lb/0/id" 2 0 (null)
All numbers are in network byte order. Requests to all endpoints contain pairs of TLVs, the first designating the device's function (lb/0/id
in this case), and the second providing any arguments. The CoAP status seems to always be 2.04 and should not be relied on for error checking. The returned tag should instead be checked for the expected value.
This endpoint is used to authenticate to the device, either via 8-digit PIN or a previously obtained access token. A CoAP session must be authenticated by one of these two methods before proceeding to call further APIs.
To begin, a X25519 key exchange is performed:
CoAP POST /nlsecure 0101 0020 [public key bytes]
The response is as follows:
CoAP 2.04 Changed 0101 0020 [device public key bytes]
This keypair is only used to create symmetric keys immediately following the key exchange and can then be discarded. After obtaining the X25519 shared secret, the key and IV are computed:
key = SHA1("AES-NL-OPENAPI-KEY" || shared_secret)[0:16] iv = SHA1("AES-NL-OPENAPI-IV" || shared_secret)[0:16]
Initialize a 128-bit AES-CTR cipher with the key and IV. All encryption and decryption operations are performed via one context; responses are decrypted using the same context that was used to encrypt the request. Further communication to the device using this CoAP session/context MUST be encrypted and decrypted using this cipher context. It is possible that at some point (after X requests, Y time) the device could invalidate the session. If so, just re-authenticate starting from the X25519 key exchange.
The 8-digit PIN printed on the bulb is used the first time communication is established and results in a long-lived access token. PIN authentication is performed as follows:
CoAP POST /nlsecure 0103 0008 "12345678"
The PIN digits are passed as ASCII characters (0x30-0x39) not their binary representation (0x00-0x09). A successful response contains the access token:
CoAP 2.04 Changed 0104 0008 XXXXXXXX
The response is a 64-bit binary access token and should be securely stored for later use.
To authenticate a CoAP session using an access token, send the following payload:
CoAP POST /nlsecure 0104 0008 XXXXXXXX
The 8-byte data field is identical to the data received in the PIN auth response.
See aiocoap and cryptography for useful libraries. Make sure to use the same CoAP client and AES context throughout your code after authenticating with the device!
# generate our keys
ourSK = X25519PrivateKey.generate()
ourPK = ourSK.public_key()
ourPKbytes = ourPK.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
# create and send payload
payload = create_tlv(0x0101, ourPKbytes)
request = Message(code=POST, payload=payload, uri=uri)
response = await coapClient.request(request).response
# get shared secret
devPK = X25519PublicKey.from_public_bytes(response.payload[4:])
sharedSecret = ourSK.exchange(devPK)
# get key/iv
digest = hashes.Hash(hashes.SHA1())
digest.update(bytearray(b'AES-NL-OPENAPI-KEY') + sharedSecret)
aesKey = digest.finalize()[0:16]
digest = hashes.Hash(hashes.SHA1())
digest.update(bytearray(b'AES-NL-OPENAPI-IV') + sharedSecret)
aesIv = digest.finalize()[0:16]
aesCipher = ciphers.Cipher(ciphers.algorithms.AES(aesKey), ciphers.modes.CTR(aesIv))
aesCtx = aesCipher.encryptor()
# all further payloads (sent & received) must be wrapped in aesCtx.update
Queries to this endpoint follow the format of the nlpublic endpoint. Multiple queries can be concatenated in a single request payload; responses are returned concatenated in the same order as the request. The entire payload must be encrypted with the context created above. The received payload is decrypted with the same context. Do not send a request while you are waiting for a response until a timeout has passed! This will desynchronize your cipher context due to the decision to share the enc/dec context. Make use of multiple requests in a payload instead.
Function | Endpoint | Length |
---|---|---|
Control | ci | variable |
DeviceInfo | di | 36 |
On/Off | lb/0/oo | 1 |
Brightness | lb/0/pb | 2 |
Hue | lb/0/hu | 2 |
Saturation | lb/0/sa | 2 |
CCT (temp) | lb/0/ct | 2 |
Color appears to be HSV (hue, saturation, value) versus HSL (hue, saturation, lightness).
Requests for information use GET:
CoAP GET /nlltpdu 0001 LLLL ENDPOINT 0002 0000
Responses typically have their second TLV as type 0003 which contains a status code after the length but before the payload:
CoAP 2.04 Changed 0001 LLLL ENDPOINT 0003 LLLL SC XX[len-1]
As an example, querying for the device info:
CoAP GET /nlltpdu 0001 0002 "di" 0002 0000
And the response:
CoAP 2.04 Changed 0001 0002 "di" 0003 0026 00 hwver[10] fwver[8] serial[11] eui64[8]
Writes are similar to queries, with POST as the method and any arguments carried in the second TLV:
CoAP POST /nlltpdu 0001 EP-LEN ENDPOINT 0002 ARG-LEN ARGS
As an example, turn on a bulb (if it isn't already) and set the color to a pleasing Halloween orange #F25C00:
CoAP POST /nlltpdu 0001 0007 "lb/0/oo" 0002 0001 01 0001 0007 "lb/0/hu" 0002 0002 0017 0001 0007 "lb/0/sa" 0002 0002 0064 0001 0007 "lb/0/pb" 0002 0002 005f
Device control is performed via the ci
endpoint. Inside the 0002
arguments TLV are the same New Nanoleaf TLV tags as seen over HAP:
CoAP POST /nlltpdu 0001 0002 "ci" 0002 0012 ...
Which breaks down as:
Top-level endpoint TLV 0001 0002 "ci" Top-level argument TLV 0002 0012 New Nanoleaf TLV tags ...
The iOS application talks HAP over CoAP to these endpoints.
- / is for encrypted HAP PDUs
- /0 is equivalent to identify
- /1 is equivalent to pair-setup
- /2 is equivalent to pair-verify
So far multiple new PDU opcodes have been seen versus what is publicly available. After pair-setup and pair-verify, the Home app sends opcode 0x09
to the accessory. The reply appears to be a GATT attribute table of sorts. Replying with this data to the Home app causes pairing to complete and it prompts for a name and room for the accessory. The app then begins to query the accessory in the background with HAP-Characteristic-Read (0x03
) and another unknown opcode, 0x0b
(starts a subscription to a characteristic).
Performing a function like list pairings or remove pairings is no longer a simple REST call away. You must first write your request to the control point and then read the return code. You must then read the actual result from the control point.
- find "list pairings" characteristic IID
- construct State=M1 and Method=ListPairings TLV
- encode that TLV inside a HAP Param Value TLV
- construct a HAP PDU with opcode Characteristic Write for the "list pairings" IID
- write the encrypted request and decrypt the response
- construct a HAP PDU with opcode Characteristic Read for the "list pairings" IID
- write the encrypted request and decrypt the response
- unwrap the expected "list pairings" M2 response TLV from a HAP Param Value TLV
The "remove pairing" operation takes place over the "list pairing" characteristic.
The hidden HAP characteristic at UUID a28e1902-cfa1-4d37-a10f-0071ceeeeebd supports some sort of Thread control. The Nanoleaf app sends the Thread network info packed in the TLV frame format described above in the nlXXX endpoint sections.
0x0201:
An LTPDU access token, used during firmware update
0x0202:
Read EUI64 TAG LEN DATA 0202 0002 0000 Response TAG LEN DATA (REDACTED) 8202 0008 ......fffe......
0x0701:
Preview scene
0x0702:
Add scene
0x0703:
List scene identifiers
0x0704:
Get scene details
0x0705:
Delete scene
0x0706:
Execute scene
0x0707:
Get currently executing scene
0x0801:
Device control Nanoleaf LTPDU TLVs with a R/W prefix TAG L0 RW TAG L1 EP TAG L2 DATA 0801 004b 01 0001 0005 ascii 0002 003d ... L0: overall length RW: 0=read, 1=write L1: length of endpoint (0001) tag EP: ascii string indicating command target L2: length of argument (0002) tag Currently known endpoints: ac: Circadian Lighting? (tlv8 data) lglt: Circadian Lighting? (8 byte data) tm: Current time in seconds since epoch (8 byte unsigned int) tz: Timezone offset in seconds (8 byte signed int) ac/en: ? th/nc: Thread node capabilities (1 byte bitfield, same as HAP 702) 0x01: Minimal 0x02: Sleepy 0x04: Full 0x08: Router-eligible 0x10: Border Router capable th/tc: Thread network info (tlv8 data, same as HAP 704) th/tr: Thread role (1 byte bitfield, same as HAP 703) 0x01: Disabled 0x02: Detached 0x04: Joining 0x08: Child 0x10: Router 0x20: Leader 0x40: Border Router th/tc TLV8 tags: 1: unknown, seen as TLV 01 01 01 2: Thread network info 3: unknown, seen as TLV 03 01 00 Thread network info TLV8 tags: 1: NetworkName (16 byte ascii string) 2: Channel (2 byte int) 3: PanID (2 byte data) 4: ExtendedPanID (8 byte data) 5: MasterKey (16 byte data)
0x0902:
?