cbyge

package module
v0.0.0-...-06da525 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2023 License: BSD-2-Clause Imports: 17 Imported by: 0

README

cbyge

For this project, I reverse engineered the "C by GE" app for controlling GE WiFi-connected smart lightbulbs. To do this, I started by decompiling the Android app, and then reverse engineered the binary protocol that the app uses to talk to a server. For more details, see Reverse Engineering C by GE.

The final products of this project are:

  1. A web API and front-end website for controlling lightbulbs.
  2. A high-level Go API for enumerating lightbulbs, getting their status, and changing their properties (e.g. brightness and color tone).

Disclaimer: this code is the result of reverse engineering and has not been informed by a protocol specification. As a result, there is no guarantee that it will continue to work or that it will work for every network or smart device. While others have successfully used this API in some cases, it is possible that the code makes incorrect assumptions that do not hold up under every use case.

Website

The server directory is a self-contained web-app and JSON API endpoint for C by GE lightbulbs. The website looks like this:

Screenshot of the website

If you run the website wih a -email and -password argument, then the website will bring up a two-factor authentication page the first time you load it. You will hit a button and enter the verification code sent to your email. Alternatively, you can login ahead of time by running the login_2fa command with the -email and -password flags set to your account's information. The command will prompt you for the 2FA verification code. Once you enter this code, the command will spit out session info as a JSON blob. You can then pass this JSON to the -sessinfo argument of the server, e.g. as -sessinfo 'JSON HERE'. Note that part of the session expires after a week, but a running server instance will continue to work after this time since the expirable part of the session is only used once to enumerate devices.

Go API

Newer accounts require the use of two-factor authentication. You can perform a 2FA handshake to create a session like so:

callback, err := cbyge.Login2FA("my_email", "my_password", "")
// Handle error...

sessionInfo, err := callback("2FA code from email")
// Handle error...

session, err := cbyge.NewController(sessionInfo, 0)
// Handle error...

For older accounts that have never used 2FA before, you may be able to login directly:

session, err := cbyge.NewControllerLogin("my_email", "my_password")
// Handle error...

Once you have a session, you can enumerate devices like so:

devs, err := session.Devices()
// Handle error...
for _, x := range devs {
    fmt.Println(x.Name())
}

You can control bulbs like so:

x := devs[0]
session.SetDeviceStatus(x, true) // turn on
session.SetDeviceLum(x, 50)      // set brightness
session.SetDeviceCT(x, 100)      // set color tone (100=blue, 0=orange)

You can also query a bulb's current settings:

status, err := session.DeviceStatus(x)
// Handle error...
fmt.Println(status.IsOn)
fmt.Println(status.ColorTone)

Reverse Engineering C by GE

In this section, I'll take you through how I reverse-engineered parts of the C by GE protocol.

The first step was to disassemble the Android app with Apktool. This produces Smali disassembly for the app. Poking around, I searched for URLs and domain names. Initially, I found this:

.field public static final API_VERSION:Ljava/lang/String; = "v2/"

.field public static final BASE_URL:Ljava/lang/String; = "https://api-ge.xlink.cn:443/"

Seeing where this API endpoint was used quickly led me to a set of JSON-based HTTP calls for logging in, listing devices, etc. However, this endpoint didn't seem to provide a way to 1) get the status of devices, or 2) update the color or brightness of the devices.

There had to be some other way the app was communicating with the smart bulbs. However, the disassembly was riddled with code for Bluetooth and LAN communication, and I was a bit worried there was no global API endpoint for controlling the bulbs. What was worse, the C by GE app complained whenever I turned off Bluetooth and then tried to use it. However, I eventually found that I could open the app, let it do its thing, and then turn off Bluetooth and WiFi while still having control over the bulbs. All I had to do was hit the Android "back" button whenever the app opened a popup asking me to "Turn on Location Tracking" (a weird name for Bluetooth and WiFi, mind you).

At this point, I was fairly sure the app wasn't making some other mysterious HTTP(S) connections. Interestingly, though, I did find the domain "xlink.cn" elsewhere in the Smali code:

.field public static final CM_SERVER_ADDRESS:Ljava/lang/String; = "cm-ge.xlink.cn"

.field public static final CM_SERVER_PORT:I = 0x5ce2

Holy cow, could this be a raw socket-based protocol? I tried, and sure enough I could open a TCP connection to cm-ge.xlink.cn:23778. However, the Smali was also riddled with logic for UDP packets, so I was unsure which protocol the app would be using. With this in mind, I created packet-proxy and set it listening on port 23778. Then I replaced the domain cm-ge.xlink.cn with my IP address in the Smali code, recompiled the app into an APK, and installed it on my phone.

Surely enough, my patched C by GE app immediately connected to my packet-proxy instance and started chatting away. Notably, it only did this when Bluetooth and WiFi were turned off. Otherwise, it seemed to prefer one of those for locally communicating with the smart bulbs.

The protocol the app chose to use was by far the easiest outcome to deal with: 1) it was TCP rather than UDP, 2) it was completely unencrypted. The lack of encryption is rather alarming in hindsight, since the first message includes an authorization token which never seems to change for my account.

I found that the messages from the app to the server could be "replayed" effectively. Once I figured out which bytes (or "packets", thanks to packet-proxy) were for turning on and off lights, I could simply open a new socket and send these same packets and get the same outcomes. This was a great sign. Worst case scenario, I already had a way of implementing what I wanted for myself, even if it wouldn't be very general.

At this point, it was time to dig deeper into the protocol. After a combination of experimentation with packet-proxy and digging into the Smali disassembly, I had a fairly general understanding of what communication was taking place. The first thing I noticed was that the communication took place in "messages", which started with a type and a length field (in big endian). The next thing was figuring out which packet types where which, and eventually how the specific packets themselves were encoded. Here's an example of a packet from the server containing the statuses of my three devices:

73 00 00 00 60 47 e2 be ab 00 37 00 7e 00 01 00 00 f9 52 4e
00 03 00 00 00 03 00 03 00 81 01 00 00 81 01 00 00 00 00 35
00 00 00 27 00 00 00 00 00 00 00 02 00 00 01 00 00 00 01 00
00 00 00 35 00 00 00 27 00 00 00 00 00 00 00 01 00 00 01 00
00 00 01 00 00 00 00 35 00 00 00 27 00 00 00 00 00 00 00 c8
7e 

Once I had enough of the protocol worked out, I created an API for it. This API can list devices, get their statuses, and update various properties of the devices (e.g. brightness and color tone). Surprisingly, I found my API to be much faster and more reliable than the app itself. It seems that trying to use Bluetooth or WiFi before falling back to a remote server causes the app to be much flakier and less reliable than it could be.

As a final note, I don't own all of the devices supported by this app, so I wasn't motivated (or easily able) to reverse-engineer how these devices would work. For example, the same company produces smart outlets, sensors, and light switches.

Documentation

Index

Constants

View Source
const (
	RemoteErrorCodeAccessTokenRefresh = 4031022
	RemoteErrorCodePasswordError      = 4001007
	RemoteErrorCodeUserNotExists      = 4041011
	RemoteErrorCodePropertyNotExists  = 4041009
)
View Source
const (
	PacketTypeAuth     uint8 = 1
	PacketTypeSync           = 4
	PacketTypePipe           = 7
	PacketTypePipeSync       = 8
)
View Source
const (
	PacketPipeTypeSetStatus          uint8 = 0xd0
	PacketPipeTypeSetLum                   = 0xd2
	PacketPipeTypeSetCT                    = 0xe2
	PacketPipeTypeGetStatus                = 0xdb
	PacketPipeTypeGetStatusPaginated       = 0x52
)
View Source
const DefaultCorpID = "1007d2ad150c4000"

DefaultCorpID is the corporation ID used by the C by GE app.

View Source
const DefaultPacketConnHost = "cm.gelighting.com:23778"
View Source
const DefaultTimeout = time.Second * 10
View Source
const PacketConnTimeout = time.Second * 10

Variables

View Source
var RemoteCallError = errors.New("the server returned with an error")

A RemoteCallError is triggered when the packet server returns an unspecified error.

View Source
var UnreachableError = errors.New("the device cannot be reached")

An UnreachableError is triggered when a device cannot be reached through any wifi-connected switch.

Functions

func IsAccessTokenError

func IsAccessTokenError(err error) bool

IsAccessTokenError returns true if the error is an API error that can be solved by refreshing the access token.

func IsCredentialsError

func IsCredentialsError(err error) bool

IsCredentialsError returns true if the error was the result of a bad username or password.

func IsPropertyNotExistsError

func IsPropertyNotExistsError(err error) bool

IsPropertyNotExistsError returns true if an error was the result of looking up properties for a device without properties.

func IsStatusPaginatedResponse

func IsStatusPaginatedResponse(p *Packet) bool

func Login2FA

func Login2FA(email, password, corpID string) (func(code string) (*SessionInfo, error), error)

Login2FA authenticates using two-factor authentication, which is required for newer "Cync" accounts.

This method returns a callback which should be called with the emailed verification code.

func Login2FAStage1

func Login2FAStage1(email, corpID string) error

Login2FAStage1 sends a two-factor authentication email to the user. Complete the login using Login2FAStage2.

Types

type Controller

type Controller struct {
	// contains filtered or unexported fields
}

A Controller is a high-level API for manipulating C by GE devices.

func NewController

func NewController(s *SessionInfo, timeout time.Duration) *Controller

NewController creates a Controller using a pre-created session and a specified timeout.

If timeout is 0, then DefaultTimeout is used.

func NewControllerLogin

func NewControllerLogin(email, password string) (*Controller, error)

NewControllerLogin creates a Controller by logging in with a username and password.

func (*Controller) BlastDeviceStatuses

func (c *Controller) BlastDeviceStatuses(ds []*ControllerDevice, statuses []bool,
	numSwitches int) error

BlastDeviceStatuses asynchronously turns on or off many devices in bulk. It will use up to numSwitches switches per device, providing redundancy if some switches are not connected. If numSwitches is 0, one switch will be used per device.

func (*Controller) DeviceStatus

DeviceStatus gets the status for a previously enumerated device.

If no error occurs, the status is updated in d.LastStatus() in addition to being returned.

func (*Controller) DeviceStatuses

func (c *Controller) DeviceStatuses(devs []*ControllerDevice) ([]ControllerDeviceStatus, []error)

DeviceStatuses gets the status for previously enumerated devices.

Each device will have its own status, and can have an independent error when fetching the status.

Each device's status is updated in d.LastStatus() if no error occurred for that device.

func (*Controller) Devices

func (c *Controller) Devices() ([]*ControllerDevice, error)

Devices enumerates the devices available to the account.

Each device's status is available through its LastStatus() method.

func (*Controller) Login

func (c *Controller) Login(email, password string) error

Login creates a new authentication token on the session using the username and password.

func (*Controller) SetDeviceCT

func (c *Controller) SetDeviceCT(d *ControllerDevice, ct int) error

SetDeviceCT changes a device's color tone.

Color tone values are in [0, 100].

func (*Controller) SetDeviceCTAsync

func (c *Controller) SetDeviceCTAsync(d *ControllerDevice, ct int) error

SetDeviceCTAsync is like SetDeviceCT, but does not wait for the device's status to change.

func (*Controller) SetDeviceLum

func (c *Controller) SetDeviceLum(d *ControllerDevice, lum int) error

SetDeviceLum changes a device's brightness.

Brightness values are in [1, 100].

func (*Controller) SetDeviceLumAsync

func (c *Controller) SetDeviceLumAsync(d *ControllerDevice, lum int) error

SetDeviceLumAsync is like SetDeviceLum, but does not wait for the device's status to change.

func (*Controller) SetDeviceRGB

func (c *Controller) SetDeviceRGB(d *ControllerDevice, r, g, b uint8) error

SetDeviceRGB changes a device's RGB.

func (*Controller) SetDeviceRGBAsync

func (c *Controller) SetDeviceRGBAsync(d *ControllerDevice, r, g, b uint8) error

SetDeviceRGBAsync is like SetDeviceRGB, but does not wait for the device's status to change.

func (*Controller) SetDeviceStatus

func (c *Controller) SetDeviceStatus(d *ControllerDevice, status bool) error

SetDeviceStatus turns on or off a device.

func (*Controller) SetDeviceStatusAsync

func (c *Controller) SetDeviceStatusAsync(d *ControllerDevice, status bool) error

SetDeviceStatusAsync is like SetDeviceStatus, but does not wait for the device's state to change.

type ControllerDevice

type ControllerDevice struct {
	// contains filtered or unexported fields
}

func (*ControllerDevice) DeviceID

func (c *ControllerDevice) DeviceID() string

DeviceID gets a unique identifier for the device.

func (*ControllerDevice) LastStatus

func (c *ControllerDevice) LastStatus() ControllerDeviceStatus

LastStatus gets the last known status of the device.

This is not updated automatically, but it will be updated on a device object when Controller.DeviceStatus() is called.

func (*ControllerDevice) Name

func (c *ControllerDevice) Name() string

Name gets the user-assigned name of the device.

type ControllerDeviceStatus

type ControllerDeviceStatus struct {
	StatusPaginatedResponse

	// If IsOnline is false, all other fields are invalid.
	// This means that the device could not be reached.
	IsOnline bool
}

type DeviceInfo

type DeviceInfo struct {
	AccessKey       int64        `json:"access_key"`
	ActiveCode      string       `json:"active_code"`
	ActiveDate      OptionalDate `json:"active_date"`
	AuthorizeCode   string       `json:"authorize_code"`
	FirmwareVersion int          `json:"firmware_version"`
	Groups          string       `json:"groups"`
	ID              uint32       `json:"id"`
	IsActive        bool         `json:"is_active"`
	IsOnline        bool         `json:"is_online"`
	LastLogin       OptionalDate `json:"last_login"`
	MAC             string       `json:"mac"`
	MCUVersion      int          `json:"mcu_version"`
	Name            string       `json:"name"`
	ProductID       string       `json:"product_id"`
	Role            int          `json:"role"`
	Source          int          `json:"source"`
	SubscribeDate   string       `json:"subscribe_date"`
}

func GetDevices

func GetDevices(userID uint32, accessToken string) ([]*DeviceInfo, error)

GetDevices gets the devices using information from Login.

type DeviceProperties

type DeviceProperties struct {
	Bulbs []struct {
		DeviceID    int64  `json:"deviceID"`
		DisplayName string `json:"displayName"`
		SwitchID    uint64 `json:"switchID"`
	} `json:"bulbsArray"`
}

func GetDeviceProperties

func GetDeviceProperties(accessToken, productID string, deviceID uint32) (*DeviceProperties, error)

GetDeviceProperties gets extended device information.

The resulting error can be checked with IsPropertyNotExistsError(), to check if the device has no properties.

type OptionalDate

type OptionalDate struct {
	Date *time.Time
}

func (*OptionalDate) UnmarshalJSON

func (o *OptionalDate) UnmarshalJSON(d []byte) error

type Packet

type Packet struct {
	Type       uint8
	IsResponse bool
	Data       []byte
}

func NewPacketGetStatusPaginated

func NewPacketGetStatusPaginated(deviceID uint32, seq uint16) *Packet

NewPacketGetStatusPaginated creates a packet for requesting the status of a device.

func NewPacketPipe

func NewPacketPipe(deviceID uint32, seq uint16, subtype uint8, data []byte) *Packet

NewPacketPipe creates a "pipe buffer" packet with a given subtype.

func NewPacketSetCT

func NewPacketSetCT(deviceID uint32, seq uint16, device, ct int) *Packet

NewPacketSetCT creates a packet for setting a device's color tone.

Set tone is a number in [0, 100], where 100 is blue and 0 is orange.

func NewPacketSetDeviceStatus

func NewPacketSetDeviceStatus(deviceID uint32, seq uint16, device, status int) *Packet

NewPacketSetDeviceStatus creates a packet for turning on or off a device.

Set status to 1 to turn on, or 0 to turn off.

func NewPacketSetLum

func NewPacketSetLum(deviceID uint32, seq uint16, device, brightness int) *Packet

NewPacketSetLum creates a packet for setting a device's brightness.

Set brightness to a number in [1, 100].

func NewPacketSetRGB

func NewPacketSetRGB(deviceID uint32, seq uint16, device int, r, g, b uint8) *Packet

NewPacketSetRGB creates a packet for setting a device's RGB color.

func (*Packet) Encode

func (p *Packet) Encode() []byte

Encode the packet in raw binary form.

func (*Packet) Seq

func (p *Packet) Seq() (uint16, error)

Seq gets the sequence number of a pipe packet.

func (*Packet) String

func (p *Packet) String() string

type PacketConn

type PacketConn struct {
	// contains filtered or unexported fields
}

func NewPacketConn

func NewPacketConn() (*PacketConn, error)

NewPacketConn creates a PacketConn connected to the default server.

func NewPacketConnWrap

func NewPacketConnWrap(conn net.Conn) *PacketConn

NewPacketConnWrap creates a PacketConn on top of an existing socket.

func (*PacketConn) Auth

func (p *PacketConn) Auth(userId uint32, code string, timeout time.Duration) error

Auth does an authentication exchange with the server.

Provide an authorization code, as obtained by Login(). If timeout is non-zero, it is a socket read/write timeout; otherwise, no timeout is used.

func (*PacketConn) Close

func (p *PacketConn) Close() error

func (*PacketConn) Read

func (p *PacketConn) Read() (*Packet, error)

func (*PacketConn) Write

func (p *PacketConn) Write(packet *Packet) error

type RemoteError

type RemoteError struct {
	Msg     string `json:"msg"`
	Code    int    `json:"code"`
	Context string
}

A RemoteError is an error message returned by the HTTPS API server.

func (*RemoteError) Error

func (l *RemoteError) Error() string

type SessionInfo

type SessionInfo struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	UserID       uint32 `json:"user_id"`
	ExpireIn     int    `json:"expire_in"`
	Authorize    string `json:"authorize"`
}

func Login

func Login(email, password, corpID string) (*SessionInfo, error)

Login authenticates with the server to create a new session. This only works for older accounts, not newer "Cync" accounts.

The resulting error can be checked with IsCredentialsError() to see if it resulted from a bad login.

If corpID is "", then DefaultCorpID is used.

func Login2FAStage2

func Login2FAStage2(email, password, corpID, code string) (*SessionInfo, error)

Login2FAStage2 completes the two-factor authentication process, creating a session if the code and password is correct.

type StatusPaginatedResponse

type StatusPaginatedResponse struct {
	Device     int
	Brightness uint8
	ColorTone  uint8
	IsOn       bool

	UseRGB bool
	RGB    [3]uint8
}

func DecodeStatusPaginatedResponse

func DecodeStatusPaginatedResponse(p *Packet) ([]StatusPaginatedResponse, error)

type UserInfo

type UserInfo struct {
	Gender          int          `json:"gender"`
	ActiveDate      OptionalDate `json:"active_date"`
	Source          int          `json:"source"`
	PasswordInited  bool         `json:"passwd_inited"`
	IsValid         bool         `json:"is_valid"`
	Nickname        string       `json:"nickname"`
	ID              uint32       `json:"id"`
	CreateDate      OptionalDate `json:"create_date"`
	Email           string       `json:"email"`
	RegionID        int          `json:"region_id"`
	AuthorizeCode   string       `json:"authorize_code"`
	CertificateNo   string       `json:"certificate_no"`
	CertificateType int          `json:"certificate_type"`
	CorpID          string       `json:"corp_id"`
	PrivacyCode     string       `json:"privacy_code"`
	Account         string       `json:"account"`
	Age             int          `json:"age"`
	Status          int          `json:"status"`
}

func GetUserInfo

func GetUserInfo(userID uint32, accessToken string) (*UserInfo, error)

GetUserInfo gets UserInfo using information from Login.

Directories

Path Synopsis
Command login_2fa performs two-factor authentication for a C by GE (Cync) account, returning a session as JSON if the login succeeds.
Command login_2fa performs two-factor authentication for a C by GE (Cync) account, returning a session as JSON if the login succeeds.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL