├── LICENSE.txt ├── README.md ├── go.mod ├── go.sum └── inform ├── codec.go ├── crypto.go └── inform.go /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Michael Crute 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ubiquiti Inform Protocol in Golang 2 | This repo contains a Golang implementation of the Ubiquiti Networks 3 | Inform protocol used by the Unifi access points and the mFi machine 4 | networking components. The primary purpose of this repository is to 5 | implement the core inform protocol for interoperability with Ubiquiti 6 | products. It is not recommended to use this protocol as the base for 7 | any new product development, it's rather Ubiquiti specific and not very 8 | secure in the bootstrap phase (uses well-known keys over plaintext 9 | channels). 10 | 11 | This work is largely based on 12 | [ubntmfi](https://github.com/mcrute/ubntmfi/blob/master/inform_protocol.md) 13 | 14 | ## Status 15 | This repo is a work in progress and semi-maintained, it is not yet 16 | considered stable. If you find it useful patches are welcome, just open 17 | a pull request. 18 | 19 | This repository should work well with older mFi components and modern 20 | Unifi components as well. The author has tested with devices as new as 21 | the U6-Mesh devices. 22 | 23 | ## Missing Features 24 | If you need these then feel free to implement them. Pull requests are 25 | accepted. 26 | 27 | - Support for writing AES GCM packets 28 | - Support for writing zlib compressed packets 29 | - Support for writing snappy compressed packets 30 | 31 | ## Protocol 32 | The inform protocol works over HTTP or HTTPS in the case of modern 33 | equipment, older equipment did not support HTTPS. The `mcad` daemon 34 | on the devices is responsible for speaking the inform protocol to the 35 | controller. 36 | 37 | A device will `POST` an HTTP request to the controller with a 38 | content-type of `application/x-binary` which contains a payload encoded 39 | in inform format. The server will respond with a payload that is inform 40 | encoded. 41 | 42 | ## Packet Format 43 | Packets are binary and transmitted in big-endian format. 44 | 45 | | Size | Type | Purpose | Notes | 46 | | -------- | -------- | ------------------------ | ------------------------------------ | 47 | | 4 bytes | `int32` | Protocol Magic Number | Must always be `1414414933` (`UBNT`) | 48 | | 4 bytes | `int32` | Packet Version | Currently this is `0` | 49 | | 6 bytes | `[]byte` | Device MAC Address | Used for crypto-key lookup | 50 | | 2 bytes | `int16` | Flags | See below | 51 | | 16 bytes | `[]byte` | Encryption IV | | 52 | | 4 bytes | `int32` | Data Version | Currently this is `1` | 53 | | 4 bytes | `int32` | Encrypted Payload Length | | 54 | | n bytes | `[]byte` | Encrypted Payload | See below | 55 | 56 | ### Flags 57 | | Flag | Name | Purpose | 58 | | ---- | ----------------- | ----------------------------------------------- | 59 | | `1` | Encrypted | Indicates that the payload is encrypted | 60 | | `2` | Zlib Compressed | Indicates that payload is zlib compressed | 61 | | `4` | Snappy Compressed | Indicates that payload is snappy compressed | 62 | | `8` | GCM Encrypted | Indicates that packet is encrypted with AES GCM | 63 | 64 | ### Encryption 65 | There are two encryption modes AES 128 CBC and AES 128 GCM. GCM is used 66 | in newer devices and CBC in older devices. The same key is used for 67 | either mode. It's stored as `x_authkey` in the Unifi database and is 68 | encoded as hex. The key must be decoded before decryption. 69 | 70 | AES GCM requires authentication data to decrypt the packet. The 71 | authentication data is the following fields encoded in big-endian binary 72 | format. 73 | 74 | - Protocol Magic Number 75 | - Packet Version 76 | - Device MAC Address 77 | - Flags 78 | - Encryption IV 79 | - Data Version 80 | - Data Length 81 | 82 | In CBC mode the data is padded to fit a full AES block. In GCM mode the 83 | final block appears to be a padding/garbage block. 84 | 85 | ### Payload 86 | The payload is a JSON string that is device and application specific. 87 | The payload may also (but is not required to be) be compressed using 88 | either [snappy](https://github.com/golang/snappy) or zlib compression as 89 | indicated by the flags. 90 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module code.crute.me/mcrute/go-inform 2 | 3 | go 1.18 4 | 5 | require github.com/golang/snappy v0.0.4 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 2 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 3 | -------------------------------------------------------------------------------- /inform/codec.go: -------------------------------------------------------------------------------- 1 | package inform 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "encoding/binary" 7 | "errors" 8 | "io" 9 | 10 | "github.com/golang/snappy" 11 | ) 12 | 13 | type Codec struct { 14 | // KeyBag contains a mapping of colon-separated MAC addresses to their AES 15 | // keys 16 | KeyBag map[string]string 17 | } 18 | 19 | func (c *Codec) Unmarshal(fp io.Reader) (*InformWrapper, error) { 20 | w := NewInformWrapper() 21 | 22 | var magic int32 23 | binary.Read(fp, binary.BigEndian, &magic) 24 | if magic != PROTOCOL_MAGIC { 25 | return nil, errors.New("Invalid magic number") 26 | } 27 | 28 | binary.Read(fp, binary.BigEndian, &w.Version) 29 | io.ReadFull(fp, w.MacAddr) 30 | binary.Read(fp, binary.BigEndian, &w.Flags) 31 | 32 | iv := make([]byte, 16) 33 | io.ReadFull(fp, iv) 34 | 35 | binary.Read(fp, binary.BigEndian, &w.DataVersion) 36 | 37 | var dataLen int32 38 | binary.Read(fp, binary.BigEndian, &dataLen) 39 | w.DataLength = dataLen 40 | 41 | p := make([]byte, dataLen) 42 | io.ReadFull(fp, p) 43 | 44 | key, ok := c.KeyBag[w.FormattedMac()] 45 | if !ok { 46 | return nil, errors.New("No key found") 47 | } 48 | 49 | u, err := Decrypt(p, iv, key, w) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | if w.IsSnappyCompressed() { 55 | w.Payload, err = snappy.Decode(nil, u) 56 | if err != nil { 57 | return nil, err 58 | } 59 | } else if w.IsZlibCompressed() { 60 | rb := bytes.NewReader(u) 61 | wb := &bytes.Buffer{} 62 | 63 | r, err := zlib.NewReader(rb) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | io.Copy(wb, r) 69 | r.Close() 70 | 71 | w.Payload = wb.Bytes() 72 | } else { 73 | w.Payload = u 74 | } 75 | 76 | return w, nil 77 | } 78 | 79 | func (c *Codec) Marshal(msg *InformWrapper) ([]byte, error) { 80 | b := &bytes.Buffer{} 81 | payload := msg.Payload 82 | var iv []byte 83 | 84 | if msg.IsEncrypted() { 85 | key, ok := c.KeyBag[msg.FormattedMac()] 86 | if !ok { 87 | return nil, errors.New("No key found") 88 | } 89 | 90 | var err error 91 | payload, iv, err = Encrypt(payload, key) 92 | if err != nil { 93 | return nil, err 94 | } 95 | } 96 | 97 | binary.Write(b, binary.BigEndian, PROTOCOL_MAGIC) 98 | binary.Write(b, binary.BigEndian, msg.Version) 99 | b.Write(msg.MacAddr) 100 | binary.Write(b, binary.BigEndian, msg.Flags) 101 | b.Write(iv) 102 | binary.Write(b, binary.BigEndian, msg.DataVersion) 103 | binary.Write(b, binary.BigEndian, int32(len(payload))) 104 | b.Write(payload) 105 | 106 | return b.Bytes(), nil 107 | } 108 | -------------------------------------------------------------------------------- /inform/crypto.go: -------------------------------------------------------------------------------- 1 | package inform 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "encoding/binary" 9 | "encoding/hex" 10 | "errors" 11 | ) 12 | 13 | func pad(src []byte, blockSize int) []byte { 14 | padLen := blockSize - (len(src) % blockSize) 15 | padText := bytes.Repeat([]byte{byte(padLen)}, padLen) 16 | return append(src, padText...) 17 | } 18 | 19 | func unpad(src []byte, blockSize int) ([]byte, error) { 20 | srcLen := len(src) 21 | paddingLen := int(src[srcLen-1]) 22 | if paddingLen >= srcLen || paddingLen > blockSize { 23 | return nil, errors.New("Padding size error") 24 | } 25 | return src[:srcLen-paddingLen], nil 26 | } 27 | 28 | func decodeHexKey(key string) (cipher.Block, error) { 29 | decodedKey, err := hex.DecodeString(key) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | block, err := aes.NewCipher(decodedKey) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return block, nil 40 | } 41 | 42 | func makeAESIV() ([]byte, error) { 43 | iv := make([]byte, 16) 44 | if _, err := rand.Read(iv); err != nil { 45 | return nil, err 46 | } 47 | return iv, nil 48 | } 49 | 50 | // Returns ciphertext and IV, does not modify payload 51 | func Encrypt(payload []byte, key string) ([]byte, []byte, error) { 52 | ct := make([]byte, len(payload)) 53 | copy(ct, payload) 54 | ct = pad(ct, aes.BlockSize) 55 | 56 | iv, err := makeAESIV() 57 | if err != nil { 58 | return nil, nil, err 59 | } 60 | 61 | block, err := decodeHexKey(key) 62 | if err != nil { 63 | return nil, nil, err 64 | } 65 | 66 | mode := cipher.NewCBCEncrypter(block, iv) 67 | mode.CryptBlocks(ct, ct) 68 | 69 | return ct, iv, nil 70 | } 71 | 72 | func Decrypt(payload, iv []byte, key string, w *InformWrapper) ([]byte, error) { 73 | if !w.IsEncrypted() { 74 | return nil, errors.New("payload is not encrypted") 75 | } 76 | 77 | if w.IsGCMEncrypted() { 78 | return decryptGCM(payload, iv, key, w) 79 | } else { 80 | return decryptCBC(payload, iv, key) 81 | } 82 | 83 | return nil, nil 84 | } 85 | 86 | func buildAuthData(w *InformWrapper, iv []byte) []byte { 87 | ad := &bytes.Buffer{} 88 | binary.Write(ad, binary.BigEndian, int32(PROTOCOL_MAGIC)) 89 | binary.Write(ad, binary.BigEndian, int32(w.Version)) 90 | ad.Write(w.MacAddr) 91 | binary.Write(ad, binary.BigEndian, int16(w.Flags)) 92 | ad.Write(iv) 93 | binary.Write(ad, binary.BigEndian, int32(w.DataVersion)) 94 | binary.Write(ad, binary.BigEndian, int32(w.DataLength)) 95 | return ad.Bytes() 96 | } 97 | 98 | func decryptGCM(payload, iv []byte, key string, w *InformWrapper) ([]byte, error) { 99 | block, err := decodeHexKey(key) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | mode, err := cipher.NewGCMWithNonceSize(block, 16) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | _, err = mode.Open(payload[:0], iv, payload, buildAuthData(w, iv)) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | // The last block always seems to be garbage, maybe it's padding or 115 | // something else. I have not looked carefully at it. 116 | return payload[:len(payload)-aes.BlockSize], nil 117 | } 118 | 119 | func decryptCBC(payload, iv []byte, key string) ([]byte, error) { 120 | b := make([]byte, len(payload)) 121 | 122 | block, err := decodeHexKey(key) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | mode := cipher.NewCBCDecrypter(block, iv) 128 | mode.CryptBlocks(b, payload) 129 | 130 | u, err := unpad(b, aes.BlockSize) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return u, nil 136 | } 137 | -------------------------------------------------------------------------------- /inform/inform.go: -------------------------------------------------------------------------------- 1 | package inform 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | const ( 10 | PROTOCOL_MAGIC int32 = 1414414933 // UBNT 11 | INFORM_VERSION int32 = 0 12 | DATA_VERSION int32 = 1 13 | 14 | ENCRYPTED_FLAG = 1 15 | ZLIB_COMPRESSED_FLAG = 2 16 | SNAPPY_COMPRESSED_FLAG = 4 17 | AES_GCM_FLAG = 8 18 | ) 19 | 20 | // Wrapper around an inform message, serializes directly into the wire 21 | // protocol 22 | type InformWrapper struct { 23 | Version int32 24 | MacAddr []byte 25 | Flags int16 26 | DataVersion int32 27 | DataLength int32 28 | Payload []byte 29 | } 30 | 31 | // Create InformWrapper with sane defaults 32 | func NewInformWrapper() *InformWrapper { 33 | w := &InformWrapper{ 34 | Version: INFORM_VERSION, 35 | MacAddr: make([]byte, 6), 36 | Flags: 0, 37 | DataVersion: DATA_VERSION, 38 | } 39 | 40 | // Almost all messages are encrypted outside of provisioning so default 41 | // this and make users explicitly disable it. 42 | w.SetEncrypted(true) 43 | 44 | return w 45 | } 46 | 47 | // Create an InformWrapper that is a response to an incoming wrapper. Copies 48 | // all necessary data for a response so callers can just set a payload 49 | func NewInformWrapperResponse(msg *InformWrapper) *InformWrapper { 50 | w := NewInformWrapper() 51 | copy(w.MacAddr, msg.MacAddr) 52 | return w 53 | } 54 | 55 | // Update the payload data with JSON value 56 | func (i *InformWrapper) UpdatePayload(v interface{}) error { 57 | if d, err := json.Marshal(v); err != nil { 58 | return err 59 | } else { 60 | i.Payload = d 61 | return nil 62 | } 63 | } 64 | 65 | // Format Mac address bytes as lowercase string with colons 66 | func (i *InformWrapper) FormattedMac() string { 67 | return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", 68 | i.MacAddr[0], i.MacAddr[1], i.MacAddr[2], 69 | i.MacAddr[3], i.MacAddr[4], i.MacAddr[5]) 70 | } 71 | 72 | func (i *InformWrapper) String() string { 73 | b := &bytes.Buffer{} 74 | 75 | fmt.Fprintf(b, "Version: \t%d\n", i.Version) 76 | fmt.Fprintf(b, "Mac Addr: \t%s\n", i.FormattedMac()) 77 | fmt.Fprintf(b, "Flags: \t%d\n", i.Flags) 78 | fmt.Fprintf(b, " Encrypted: \t%t\n", i.IsEncrypted()) 79 | fmt.Fprintf(b, " Compressed: \t%t", i.IsCompressed()) 80 | if i.IsCompressed() { 81 | if i.IsZlibCompressed() { 82 | fmt.Fprintf(b, " (zlib)\n") 83 | } else if i.IsSnappyCompressed() { 84 | fmt.Fprintf(b, " (snappy)\n") 85 | } else { 86 | fmt.Fprintf(b, " (unknown)\n") 87 | } 88 | } else { 89 | fmt.Fprintf(b, "\n") 90 | } 91 | fmt.Fprintf(b, "Data Version: \t%d\n", i.DataVersion) 92 | fmt.Fprintf(b, "Payload: \t%q\n", i.Payload) 93 | 94 | return b.String() 95 | } 96 | 97 | func (i *InformWrapper) IsEncrypted() bool { 98 | return i.Flags&ENCRYPTED_FLAG != 0 99 | } 100 | 101 | func (i *InformWrapper) SetEncrypted(e bool) { 102 | if e { 103 | i.Flags |= ENCRYPTED_FLAG 104 | } else { 105 | i.Flags &= ENCRYPTED_FLAG 106 | } 107 | } 108 | 109 | func (i *InformWrapper) IsGCMEncrypted() bool { 110 | return i.Flags&AES_GCM_FLAG != 0 111 | } 112 | 113 | func (i *InformWrapper) SetGCMEncrypted(e bool) { 114 | if e { 115 | i.Flags |= AES_GCM_FLAG 116 | } else { 117 | i.Flags &= AES_GCM_FLAG 118 | } 119 | } 120 | 121 | func (i *InformWrapper) IsCompressed() bool { 122 | return i.IsZlibCompressed() || i.IsSnappyCompressed() 123 | } 124 | 125 | func (i *InformWrapper) IsZlibCompressed() bool { 126 | return i.Flags&ZLIB_COMPRESSED_FLAG != 0 127 | } 128 | 129 | func (i *InformWrapper) IsSnappyCompressed() bool { 130 | return i.Flags&SNAPPY_COMPRESSED_FLAG != 0 131 | } 132 | 133 | func (i *InformWrapper) SetSnappyCompressed(c bool) { 134 | if c { 135 | i.Flags |= SNAPPY_COMPRESSED_FLAG 136 | } else { 137 | i.Flags &= SNAPPY_COMPRESSED_FLAG 138 | } 139 | } 140 | 141 | func (i *InformWrapper) SetZlibCompressed(c bool) { 142 | if c { 143 | i.Flags |= ZLIB_COMPRESSED_FLAG 144 | } else { 145 | i.Flags &= ZLIB_COMPRESSED_FLAG 146 | } 147 | } 148 | --------------------------------------------------------------------------------