From 80aac9a2da274b4f539e5b6cafbfd06b1a75c9d9 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 17 Sep 2016 20:44:37 -0700 Subject: Initial import --- LICENSE.txt | 27 ++++++++++ README.md | 13 +++++ example.go | 49 +++++++++++++++++++ inform/codec.go | 83 +++++++++++++++++++++++++++++++ inform/crypto.go | 88 +++++++++++++++++++++++++++++++++ inform/inform.go | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++ inform/rx_messages.go | 99 +++++++++++++++++++++++++++++++++++++ inform/tx_messages.go | 28 +++++++++++ 8 files changed, 520 insertions(+) create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 example.go create mode 100644 inform/codec.go create mode 100644 inform/crypto.go create mode 100644 inform/inform.go create mode 100644 inform/rx_messages.go create mode 100644 inform/tx_messages.go diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..db8d0b9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (c) 2016, Michael Crute +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5edeae --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +Ubiquiti Inform Protocol in Golang +================================== +This repo contains a Golang implemntation of the Ubiquiti Networks Inform +protocol used by the Unifi access points and the mFi machine networking +components. The primary focus is an implemenation compatible with mFi +components but the library should also be compatible with Unifi devices. + +This repo is a work in progress and is not yet considered stable. If you find +it useful patches are welcome, just open a pull request. + +There is a feature-complete Python version of this API, a set of tools useful +for reverse engineering the protocol and an in-progress protocol spec at +[ubntmfi](https://github.com/mcrute/ubntmfi/blob/master/inform_protocol.md) diff --git a/example.go b/example.go new file mode 100644 index 0000000..8e10090 --- /dev/null +++ b/example.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/mcrute/go-inform/inform" + "io/ioutil" + "os" +) + +func main() { + fp, err := os.Open("data/test_files/1.bin") + if err != nil { + fmt.Println("Error loading file") + return + } + defer fp.Close() + + kp, err := os.Open("data/device_keys.json") + if err != nil { + fmt.Println("Error loading key file") + return + } + defer kp.Close() + + var keys map[string]string + kd, _ := ioutil.ReadAll(kp) + json.Unmarshal(kd, &keys) + + codec := &inform.Codec{keys} + + msg, err := codec.Unmarshal(fp) + if err != nil { + fmt.Println(err.Error()) + return + } + + fmt.Printf("%s", msg) + + out, _ := os.Create("test.out") + defer out.Close() + + pkt, err := codec.Marshal(msg) + if err != nil { + fmt.Println(err.Error()) + return + } + out.Write(pkt) +} diff --git a/inform/codec.go b/inform/codec.go new file mode 100644 index 0000000..e74c3d7 --- /dev/null +++ b/inform/codec.go @@ -0,0 +1,83 @@ +package inform + +import ( + "bytes" + "encoding/binary" + "errors" + "io" +) + +type Codec struct { + // KeyBag contains a mapping of comma-separated MAC addresses to their AES + // keys + KeyBag map[string]string +} + +func (c *Codec) Unmarshal(fp io.Reader) (*InformWrapper, error) { + w := NewInformWrapper() + + var magic int32 + binary.Read(fp, binary.BigEndian, &magic) + if magic != PROTOCOL_MAGIC { + return nil, errors.New("Invalid magic number") + } + + binary.Read(fp, binary.BigEndian, &w.Version) + io.ReadFull(fp, w.MacAddr) + binary.Read(fp, binary.BigEndian, &w.Flags) + + iv := make([]byte, 16) + io.ReadFull(fp, iv) + + binary.Read(fp, binary.BigEndian, &w.DataVersion) + + var dataLen int32 + binary.Read(fp, binary.BigEndian, &dataLen) + + p := make([]byte, dataLen) + io.ReadFull(fp, p) + + key, ok := c.KeyBag[w.FormattedMac()] + if !ok { + return nil, errors.New("No key found") + } + + u, err := Decrypt(p, iv, key) + if err != nil { + return nil, err + } + + w.Payload = u + + return w, nil +} + +func (c *Codec) Marshal(msg *InformWrapper) ([]byte, error) { + b := &bytes.Buffer{} + payload := msg.Payload + var iv []byte + + if msg.IsEncrypted() { + key, ok := c.KeyBag[msg.FormattedMac()] + if !ok { + return nil, errors.New("No key found") + } + + var err error + payload, iv, err = Encrypt(payload, key) + if err != nil { + return nil, err + } + } + + binary.Write(b, binary.BigEndian, PROTOCOL_MAGIC) + binary.Write(b, binary.BigEndian, msg.Version) + b.Write(msg.MacAddr) + binary.Write(b, binary.BigEndian, msg.Flags) + b.Write(iv) + binary.Write(b, binary.BigEndian, msg.DataVersion) + binary.Write(b, binary.BigEndian, int32(len(payload))) + b.Write(payload) + + return b.Bytes(), nil +} diff --git a/inform/crypto.go b/inform/crypto.go new file mode 100644 index 0000000..90118c1 --- /dev/null +++ b/inform/crypto.go @@ -0,0 +1,88 @@ +package inform + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" +) + +func Pad(src []byte, blockSize int) []byte { + padLen := blockSize - (len(src) % blockSize) + padText := bytes.Repeat([]byte{byte(padLen)}, padLen) + return append(src, padText...) +} + +func Unpad(src []byte, blockSize int) ([]byte, error) { + srcLen := len(src) + paddingLen := int(src[srcLen-1]) + if paddingLen >= srcLen || paddingLen > blockSize { + return nil, errors.New("Padding size error") + } + return src[:srcLen-paddingLen], nil +} + +func decodeHexKey(key string) (cipher.Block, error) { + decodedKey, err := hex.DecodeString(key) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(decodedKey) + if err != nil { + return nil, err + } + + return block, nil +} + +func makeAESIV() ([]byte, error) { + iv := make([]byte, 16) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + return iv, nil +} + +// Returns ciphertext and IV, does not modify payload +func Encrypt(payload []byte, key string) ([]byte, []byte, error) { + ct := make([]byte, len(payload)) + copy(ct, payload) + ct = Pad(ct, aes.BlockSize) + + iv, err := makeAESIV() + if err != nil { + return nil, nil, err + } + + block, err := decodeHexKey(key) + if err != nil { + return nil, nil, err + } + + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ct, ct) + + return ct, iv, nil +} + +func Decrypt(payload, iv []byte, key string) ([]byte, error) { + b := make([]byte, len(payload)) + + block, err := decodeHexKey(key) + if err != nil { + return nil, err + } + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(b, payload) + + u, err := Unpad(b, aes.BlockSize) + if err != nil { + return nil, err + } + + return u, nil +} diff --git a/inform/inform.go b/inform/inform.go new file mode 100644 index 0000000..b159084 --- /dev/null +++ b/inform/inform.go @@ -0,0 +1,133 @@ +package inform + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/mitchellh/mapstructure" +) + +const ( + PROTOCOL_MAGIC int32 = 1414414933 // TNBU + INFORM_VERSION int32 = 0 + DATA_VERSION int32 = 1 + + ENCRYPTED_FLAG = 1 + COMPRESSED_FLAG = 2 +) + +// Wrapper around an inform message, serializes directly into the wire +// protocol +type InformWrapper struct { + Version int32 + MacAddr []byte + Flags int16 + DataVersion int32 + Payload []byte +} + +// Create InformWrapper with sane defaults +func NewInformWrapper() *InformWrapper { + return &InformWrapper{ + Version: INFORM_VERSION, + MacAddr: make([]byte, 6), + Flags: 0, + DataVersion: DATA_VERSION, + } +} + +// Update the payload data with JSON value +func (i *InformWrapper) UpdatePayload(v interface{}) error { + if d, err := json.Marshal(v); err != nil { + return err + } else { + i.Payload = d + return nil + } +} + +// Format Mac address bytes as lowercase string with colons +func (i *InformWrapper) FormattedMac() string { + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + i.MacAddr[0], i.MacAddr[1], i.MacAddr[2], + i.MacAddr[3], i.MacAddr[4], i.MacAddr[5]) +} + +func (i *InformWrapper) String() string { + b := &bytes.Buffer{} + + fmt.Fprintf(b, "Version: \t%d\n", i.Version) + fmt.Fprintf(b, "Mac Addr: \t%s\n", i.FormattedMac()) + fmt.Fprintf(b, "Flags: \t%d\n", i.Flags) + fmt.Fprintf(b, " Encrypted: \t%t\n", i.IsEncrypted()) + fmt.Fprintf(b, " Compressed: \t%t\n", i.IsCompressed()) + fmt.Fprintf(b, "Data Version: \t%d\n", i.DataVersion) + fmt.Fprintf(b, "Payload: \t%q\n", i.Payload) + + return b.String() +} + +func (i *InformWrapper) IsEncrypted() bool { + return i.Flags&ENCRYPTED_FLAG != 0 +} + +func (i *InformWrapper) SetEncrypted(e bool) { + if e { + i.Flags |= ENCRYPTED_FLAG + } else { + i.Flags &= ENCRYPTED_FLAG + } +} + +func (i *InformWrapper) IsCompressed() bool { + return i.Flags&COMPRESSED_FLAG != 0 +} + +func (i *InformWrapper) SetCompressed(c bool) { + if c { + i.Flags |= COMPRESSED_FLAG + } else { + i.Flags &= COMPRESSED_FLAG + } +} + +// Decode payload to a map and try to determine the inform type +func (i *InformWrapper) decodePayload() (map[string]interface{}, string, error) { + var m map[string]interface{} + + if err := json.Unmarshal(i.Payload, &m); err != nil { + return nil, "", err + } + + t, ok := m["_type"] + if !ok { + return nil, "", errors.New("Message contains no type") + } + + st, ok := t.(string) + if !ok { + return nil, "", errors.New("Message type is not a string") + } + + return m, st, nil +} + +// Decode payload JSON as a inform message +func (i *InformWrapper) JsonMessage() (interface{}, error) { + msg, t, err := i.decodePayload() + if err != nil { + return nil, err + } + + switch t { + case "noop": + var o NoopMessage + if err := mapstructure.Decode(msg, &o); err != nil { + return nil, err + } + return o, nil + } + + return nil, errors.New(fmt.Sprintf("Message type %s is invalid", t)) +} diff --git a/inform/rx_messages.go b/inform/rx_messages.go new file mode 100644 index 0000000..3f80fcd --- /dev/null +++ b/inform/rx_messages.go @@ -0,0 +1,99 @@ +package inform + +// TODO: Convert string time to time.Time +// Response packet +type NoopMessage struct { + Type string `json:"_type"` + Interval int `json:"interval"` + ServerTimeUTC string `json:"server_time_in_utc"` +} + +type AlarmEntry struct { + Tag string `json:"tag"` + Type string `json:"string"` + Value string `json:"val"` // float or int observed +} + +type AlarmMessage struct { + Entries []*AlarmEntry `json:"entries"` + Index string `json:"index"` + Id string `json:"sId"` + Time int `json:"time"` +} + +type InterfaceMessage struct { + IP string `json:"ip"` + MacAddress string `json:"mac"` + Name string `json:"name"` + Type string `json:"type"` + ReceivedBytes int `json:"rx_bytes"` + ReceivedDropped int `json:"rx_dropped"` + ReceivedErrors int `json:"rx_errors"` + ReceivedPackets int `json:"rx_packets"` + TransmittedBytes int `json:"tx_bytes"` + TransmittedDropped int `json:"tx_dropped"` + TransmittedErrors int `json:"tx_errors"` + TransmittedPackets int `json:"tx_packets"` +} + +type RadioMessage struct { + Gain int `json:"builtin_ant_gain"` + BuiltinAntenna bool `json:"builtin_antenna"` + MaxTransmitPower int `json:"max_txpower"` + Name string `json:"name"` + RadioProfile string `json:"radio"` + // "scan_table": [] +} + +type AccessPointMessage struct { + BasicSSID string `json:"bssid"` + ExtendedSSID string `json:"essid"` + ClientConnectionQuality int `json:"ccq"` + Channel int `json:"channel"` + Id string `json:"id"` + Name string `json:"name"` + StationNumber string `json:"num_sta"` // int? + RadioProfile string `json:"radio"` + Usage string `json:"usage"` + ReceivedBytes int `json:"rx_bytes"` + ReceivedDropped int `json:"rx_dropped"` + ReceivedErrors int `json:"rx_errors"` + ReceivedPackets int `json:"rx_packets"` + ReceivedCrypts int `json:"rx_crypts"` + ReceivedFragments int `json:"rx_frags"` + ReceivedNetworkIDs int `json:"rx_nwids"` + TransmittedBytes int `json:"tx_bytes"` + TransmittedDropped int `json:"tx_dropped"` + TransmittedErrors int `json:"tx_errors"` + TransmittedPackets int `json:"tx_packets"` + TransmitPower int `json:"tx_power"` + TransmitRetries int `json:"tx_retries"` +} + +// TODO: Convert time to time.Time +type IncomingMessage struct { + Alarms []*AlarmMessage `json:"alarm"` + ConfigVersion string `json:"cfgversion"` + Default bool `json:"default"` + GuestToken string `json:"guest_token"` + Hostname string `json:"hostname"` + InformURL string `json:"inform_url"` + IP string `json:"ip"` + Isolated bool `json:"isolated"` + LocalVersion string `json:"localversion"` + Locating bool `json:"locating"` + MacAddress string `json:"mac"` + IsMfi string `json:"mfi"` // boolean as string + Model string `json:"model"` + ModelDisplay string `json:"model_display"` + PortVersion string `json:"portversion"` + Version string `json:"version"` + Serial string `json:"serial"` + Time int `json:"time"` + Trackable string `json:"trackable"` // boolean as string + Uplink string `json:"uplink"` + Uptime int `json:"uptime"` + Interfaces []*InterfaceMessage `json:"if_table"` + Radios []*RadioMessage `json:"radio_table"` + AccessPoints []*AccessPointMessage `json:"vap_table"` +} diff --git a/inform/tx_messages.go b/inform/tx_messages.go new file mode 100644 index 0000000..5efddd6 --- /dev/null +++ b/inform/tx_messages.go @@ -0,0 +1,28 @@ +package inform + +type AdminMetadata struct { + Id string `json:"_id"` + Language string `json:"lang"` + Username string `json:"name"` + Password string `json:"x_password"` +} + +// TODO: Convert string time to time.Time +type CommandMessage struct { + Metadata *AdminMetadata `json:"_admin"` + Id string `json:"_id"` + Type string `json:"_type"` // cmd + Command string `json:"cmd"` // mfi-output + DateTime string `json:"datetime"` // 2016-07-28T01:17:55Z + DeviceId string `json:"device_id"` + MacAddress string `json:"mac"` + Model string `json:"model"` + OffVoltage int `json:"off_volt"` + Port int `json:"port"` + MessageId string `json:"sId"` // ?? + ServerTime string `json:"server_time_in_utc"` + Time string `json:"time"` + Timer int `json:"timer"` + Value int `json:"val"` + Voltage int `json:"volt"` +} -- cgit v1.2.3