diff --git a/protocol/aprs/address.go b/protocol/aprs/address.go index 9a0c559..f595505 100644 --- a/protocol/aprs/address.go +++ b/protocol/aprs/address.go @@ -1,6 +1,7 @@ package aprs import ( + "encoding/json" "errors" "fmt" "strings" @@ -46,6 +47,25 @@ func (a Address) Secret() int16 { return h & 0x7fff } +func (a Address) MarshalJSON() ([]byte, error) { + return json.Marshal(a.String()) +} + +func (a *Address) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + p, err := ParseAddress(s) + if err != nil { + return err + } + a.Call = p.Call + a.SSID = p.SSID + a.IsRepeated = p.IsRepeated + return nil +} + func ParseAddress(s string) (Address, error) { r := strings.HasSuffix(s, "*") if r { diff --git a/protocol/aprs/aprsis/proxy.go b/protocol/aprs/aprsis/proxy.go new file mode 100644 index 0000000..3e61579 --- /dev/null +++ b/protocol/aprs/aprsis/proxy.go @@ -0,0 +1,188 @@ +package aprsis + +import ( + "bufio" + "bytes" + "fmt" + "net" + "strings" + "sync" + + "git.maze.io/go/ham/protocol" + "github.com/sirupsen/logrus" +) + +const ( + DefaultListenAddr = ":14580" + DefaultServerAddr = "rotate.aprs2.net:14580" +) + +type Proxy struct { + Logger *logrus.Logger + Filter string + server string + listen net.Listener + packets chan *protocol.Packet +} + +func NewProxy(listen, server string) (*Proxy, error) { + if _, err := net.ResolveTCPAddr("tcp", server); err != nil { + return nil, fmt.Errorf("aprsis: error resolving %q: %v", server, err) + } + + listenAddr, err := net.ResolveTCPAddr("tcp", listen) + if err != nil { + return nil, fmt.Errorf("aprsis: error listening on %s: %v", listen, err) + } + + listener, err := net.ListenTCP("tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("aprsis: error listening on %s: %v", listen, err) + } + + proxy := &Proxy{ + Logger: logrus.New(), + server: server, + listen: listener, + } + go proxy.accept() + + return proxy, nil +} + +func (proxy *Proxy) Close() error { + if proxy.packets != nil { + close(proxy.packets) + proxy.packets = nil + } + return proxy.listen.Close() +} + +func (proxy *Proxy) RawPackets() <-chan *protocol.Packet { + if proxy.packets == nil { + proxy.packets = make(chan *protocol.Packet, 16) + } + return proxy.packets +} + +func (proxy *Proxy) accept() { + for { + client, err := proxy.listen.Accept() + if err != nil { + proxy.Logger.Errorf("aprs-is proxy: error accepting client: %v", err) + continue + } + + go proxy.handle(client) + } +} + +func (proxy *Proxy) handle(client net.Conn) { + defer func() { _ = client.Close() }() + + host, _, _ := net.SplitHostPort(client.RemoteAddr().String()) + proxy.Logger.Infof("aprs-is proxy[%s]: new connection", host) + + server, err := net.Dial("tcp", proxy.server) + if err != nil { + proxy.Logger.Warnf("aprs-is proxy[%s]: can't connecto to APRS-IS server %s: %v", host, proxy.server, err) + return + } + defer func() { _ = server.Close() }() + + var ( + wait sync.WaitGroup + call string + ) + wait.Go(func() { proxy.proxy(client, server, host, "->", &call) }) + wait.Go(func() { proxy.proxy(server, client, host, "<-", nil) }) + wait.Wait() +} + +func (proxy *Proxy) proxy(dst, src net.Conn, host, dir string, call *string) { + defer func() { + if tcp, ok := dst.(*net.TCPConn); ok { + _ = tcp.CloseWrite() + } else { + _ = dst.Close() + } + }() + + reader := bufio.NewReader(src) + for { + line, err := reader.ReadBytes('\n') + if err != nil { + proxy.Logger.Warnf("aprs-is proxy[%s]: %s read error: %v", host, src.RemoteAddr(), err) + return + } + + // proxy to remote unaltered + if len(line) > 0 { + if _, err = dst.Write(line); err != nil { + proxy.Logger.Warnf("aprs-is proxy[%s]: %s write error: %v", host, dst.RemoteAddr(), err) + return + } + } + + // parse line + line = bytes.TrimRight(line, "\r\n") + if len(line) > 0 { + proxy.Logger.Tracef("aprs-is proxy[%s]: %s %s", host, dir, string(line)) + + if call != nil && strings.HasPrefix(string(line), "# logresp ") { + // server responds to client login + part := strings.SplitN(string(line), " ", 5) + if len(part) > 4 && part[3] == "verified," { + *call = part[2] + proxy.Logger.Infof("aprs-is proxy[%s]: logged in as %s", host, *call) + + if proxy.Filter != "" { + proxy.Logger.Tracef("aprs-is proxy[%s]: %s filter %s", host, dir, proxy.Filter) + if _, err = fmt.Fprintf(src, "filter %s\r\n", proxy.Filter); err != nil { + proxy.Logger.Warnf("aprs-is proxy[%s]: %s write error: %v", host, src.RemoteAddr(), err) + return + } + } + } + } + + if !isCommand(line) { + proxy.handleRawPacket(line) + } + } + } +} + +func (proxy *Proxy) handleRawPacket(data []byte) { + if proxy.packets == nil { + return + } + + select { + case proxy.packets <- &protocol.Packet{ + Protocol: "aprs", + Raw: data, + }: + default: + proxy.Logger.Warn("aprs-is proxy: raw packet channel full, dropping packet") + } +} + +func isCommand(line []byte) bool { + if len(line) == 0 { + return true + } + + if line[0] == '#' { + return true + } + + if i := bytes.IndexByte(line, ' '); i > -1 { + switch strings.ToLower(string(line[:i])) { + case "user", "filter": + return true + } + } + + return false +} diff --git a/protocol/aprs/packet.go b/protocol/aprs/packet.go index f8c6522..0abe39d 100644 --- a/protocol/aprs/packet.go +++ b/protocol/aprs/packet.go @@ -140,7 +140,7 @@ func (o OmniDFStrength) Directivity() float64 { // Packet contains an APRS packet. type Packet struct { // Raw packet (as captured from the air or APRS-IS). - Raw string `json:"raw"` + Raw string `json:"-"` // Src is the source address. Src Address `json:"src"` diff --git a/protocol/meshcore/crypto/jwt/go.mod b/protocol/meshcore/crypto/jwt/go.mod deleted file mode 100644 index 19c3a8c..0000000 --- a/protocol/meshcore/crypto/jwt/go.mod +++ /dev/null @@ -1,12 +0,0 @@ -module git.maze.io/go/ham/protocol/meshcore/crypto/jwt - -go 1.25.6 - -replace git.maze.io/go/ham => ../../../.. - -require ( - git.maze.io/go/ham v0.0.0-20260214171233-1d56998dd300 - github.com/golang-jwt/jwt/v5 v5.3.1 -) - -require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/protocol/meshcore/crypto/jwt/go.sum b/protocol/meshcore/crypto/jwt/go.sum deleted file mode 100644 index f276846..0000000 --- a/protocol/meshcore/crypto/jwt/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/protocol/meshcore/crypto/jwt/jwt.go b/protocol/meshcore/crypto/jwt/jwt.go index 62f0e0e..5871b26 100644 --- a/protocol/meshcore/crypto/jwt/jwt.go +++ b/protocol/meshcore/crypto/jwt/jwt.go @@ -10,6 +10,7 @@ import ( ) func init() { + SigningMethod = new(SigningMethodEd25519) jwt.RegisterSigningMethod(SigningMethod.Alg(), func() jwt.SigningMethod { return SigningMethod }) @@ -17,10 +18,6 @@ func init() { var SigningMethod jwt.SigningMethod -func init() { - SigningMethod = new(SigningMethodEd25519) -} - type SigningMethodEd25519 struct{} func (m *SigningMethodEd25519) Alg() string { diff --git a/protocol/meshcore/crypto/x25519.go b/protocol/meshcore/crypto/x25519.go new file mode 100644 index 0000000..2acbaa9 --- /dev/null +++ b/protocol/meshcore/crypto/x25519.go @@ -0,0 +1,141 @@ +package crypto + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + + "golang.org/x/crypto/curve25519" +) + +type SharedSecret struct { + aBytes [32]byte +} + +func MakeSharedSecret(key []byte) SharedSecret { + var secret SharedSecret + copy(secret.aBytes[:], key) + return secret +} + +func MakeSharedSecretFromGroupSecret(group []byte) SharedSecret { + var secret SharedSecret + copy(secret.aBytes[:16], group) + return secret +} + +func (ss *SharedSecret) HMAC(message []byte) uint16 { + h := hmac.New(sha256.New, ss.aBytes[:]) + h.Write(message) + r := h.Sum(nil) + return binary.BigEndian.Uint16(r[:2]) +} + +func (ss *SharedSecret) key() []byte { + return ss.aBytes[:aes.BlockSize] +} + +func (ss *SharedSecret) Decrypt(text []byte) ([]byte, error) { + block, err := aes.NewCipher(ss.key()) + if err != nil { + return nil, err + } + + length := len(text) + message := make([]byte, length) + copy(message, text) + + const chunkSize = aes.BlockSize + remain := length % chunkSize + + if remain > 0 { + padding := chunkSize - remain + message = append(message, make([]byte, padding)...) + } + + for i, l := 0, len(message); i < l; i += chunkSize { + block.Decrypt(message[i:i+chunkSize], message[i:i+chunkSize]) + } + + return message[:length], nil +} + +func (ss *SharedSecret) MACThenDecrypt(text []byte, mac uint16) ([]byte, error) { + if our := ss.HMAC(text); our != mac { + return nil, fmt.Errorf("expected MAC %04X, got %04X", mac, our) + } + return ss.Decrypt(text) +} + +func (ss *SharedSecret) Encrypt(message []byte) ([]byte, error) { + block, err := aes.NewCipher(ss.key()) + if err != nil { + return nil, err + } + + text := make([]byte, len(message)) + copy(text, message) + + const chunkSize = aes.BlockSize + remain := len(text) % chunkSize + + if remain > 0 { + padding := chunkSize - remain + text = append(text, make([]byte, padding)...) + } + + for i, l := 0, len(text); i < l; i += chunkSize { + block.Encrypt(text[i:i+chunkSize], text[i:i+chunkSize]) + } + + return text, nil +} + +func (ss *SharedSecret) EncryptThenMAC(message []byte) (uint16, []byte, error) { + text, err := ss.Encrypt(message) + if err != nil { + return 0, nil, err + } + return ss.HMAC(text), text, nil +} + +type StaticSecret [32]byte + +func (ss StaticSecret) PublicKey() (*PublicKey, error) { + pub, err := curve25519.X25519(ss[:], curve25519.Basepoint) + if err != nil { + return nil, err + } + return NewPublicKey(pub) +} + +func (ss StaticSecret) DiffieHellman(other *PublicKey) (SharedSecret, error) { + shared, err := curve25519.X25519(ss[:], other.Bytes()) + if err != nil { + return SharedSecret{}, err + } + + var montgomery SharedSecret + copy(montgomery.aBytes[:], shared) + return montgomery, nil +} + +type Signature [64]byte + +func (sig Signature) MarshalJSON() ([]byte, error) { + s := hex.EncodeToString(sig[:]) + return json.Marshal(s) +} + +func (sig *Signature) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + copy((*sig)[:], []byte(s)) + return nil +} diff --git a/protocol/meshcore/identity.go b/protocol/meshcore/identity.go new file mode 100644 index 0000000..67e4184 --- /dev/null +++ b/protocol/meshcore/identity.go @@ -0,0 +1,75 @@ +package meshcore + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "git.maze.io/go/ham/protocol/meshcore/crypto" +) + +type Identity struct { + Name string + PrivateKey *crypto.PrivateKey +} + +func (id *Identity) Hash() uint8 { + return id.PrivateKey.PublicKey()[0] +} + +type Contact struct { + Name string + PublicKey *crypto.PublicKey +} + +func (contact *Contact) Hash() uint8 { + return contact.PublicKey.Bytes()[0] +} + +type Group struct { + Name string `json:"name"` + Hash [32]byte `json:"hash"` + Secret crypto.SharedSecret `json:"-"` +} + +func (group Group) ChannelHash() uint8 { + return group.Hash[0] +} + +func (group *Group) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]string{ + "name": group.Name, + "hash": hex.EncodeToString(group.Hash[:]), + }) +} + +func (group *Group) UnmarshalJSON(b []byte) error { + var kv = make(map[string]string) + if err := json.Unmarshal(b, &kv); err != nil { + return err + } + group.Name = kv["name"] + h, err := hex.DecodeString(kv["hash"]) + if err != nil { + return err + } + copy(group.Hash[:], h) + return nil +} + +func PublicGroup(name string) *Group { + h := sha256.Sum256([]byte(name)) + return &Group{ + Name: name, + Hash: sha256.Sum256(h[:16]), + Secret: crypto.MakeSharedSecretFromGroupSecret(h[:16]), + } +} + +func SecretGroup(name string, key []byte) *Group { + return &Group{ + Name: name, + Hash: sha256.Sum256(key), + Secret: crypto.MakeSharedSecret(key), + } +} diff --git a/protocol/meshcore/node.go b/protocol/meshcore/node.go new file mode 100644 index 0000000..e20851d --- /dev/null +++ b/protocol/meshcore/node.go @@ -0,0 +1,341 @@ +package meshcore + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "log" + "strings" + "sync" + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/meshcore/crypto" +) + +const ( + maxCompanionFrameSize = 172 +) + +type Node struct { + OnPacket (*Packet) + + driver nodeDriver +} + +type NodeInfo struct { + Manufacturer string `json:"manufacturer"` + FirmwareVersion string `json:"firmware_version"` + Type NodeType `json:"node_type"` + Name string `json:"name"` + Power uint8 `json:"power"` + MaxPower uint8 `json:"max_power"` + PublicKey *crypto.PublicKey `json:"public_key"` + Position *Position `json:"position"` + Frequency float64 `json:"frequency"` // in MHz + Bandwidth float64 `json:"bandwidth"` // in kHz + SpreadingFactor uint8 `json:"sf"` + CodingRate uint8 `json:"cr"` +} + +// NewCompanion connects to a companion device type (over serial, TCP or BLE). +func NewCompanion(conn io.ReadWriteCloser) (*Node, error) { + driver := newCompanionDriver(conn) + + if err := driver.Setup(); err != nil { + return nil, err + } + + return &Node{ + driver: driver, + }, nil +} + +func (dev *Node) Close() error { + return dev.driver.Close() +} + +func (dev *Node) Packets() <-chan *Packet { + return dev.driver.Packets() +} + +func (dev *Node) RawPackets() <-chan *protocol.Packet { + return dev.driver.RawPackets() +} + +func (dev *Node) Info() *NodeInfo { + return dev.driver.Info() +} + +type nodeDriver interface { + Setup() error + + Close() error + + Packets() <-chan *Packet + RawPackets() <-chan *protocol.Packet + + Info() *NodeInfo +} + +type CompanionError struct { + Code byte +} + +func (err CompanionError) Error() string { + switch err.Code { + case companionErrCodeUnsupported: + return "meshcore: companion: unsupported" + case companionErrCodeNotFound: + return "meshcore: companion: not found" + case companionErrCodeTableFull: + return "meshcore: companion: table full" + case companionErrCodeBadState: + return "meshcore: companion: bad state" + case companionErrCodeFileIOError: + return "meshcore: companion: file input/output error" + case companionErrCodeIllegalArgument: + return "meshcore: companion: illegal argument" + default: + return fmt.Sprintf("meshcore: companion: unknown error code %#02x", err.Code) + } +} + +type companionDriver struct { + conn io.ReadWriteCloser + mu sync.Mutex + packets chan *Packet + rawPackets chan *protocol.Packet + info NodeInfo +} + +func newCompanionDriver(conn io.ReadWriteCloser) *companionDriver { + return &companionDriver{ + conn: conn, + } +} + +func (drv *companionDriver) Close() error { + return drv.conn.Close() +} + +func (drv *companionDriver) Setup() (err error) { + if err = drv.sendAppStart(); err != nil { + return + } + if err = drv.sendDeviceInfo(); err != nil { + return + } + go drv.poll() + return +} + +func (drv *companionDriver) Packets() <-chan *Packet { + if drv.packets == nil { + drv.packets = make(chan *Packet, 16) + } + return drv.packets +} + +func (drv *companionDriver) RawPackets() <-chan *protocol.Packet { + if drv.rawPackets == nil { + drv.rawPackets = make(chan *protocol.Packet, 16) + } + return drv.rawPackets +} + +func (drv *companionDriver) Info() *NodeInfo { + return &drv.info +} + +func (drv *companionDriver) readFrame() ([]byte, error) { + var frame [3 + maxCompanionFrameSize]byte + for { + n, err := drv.conn.Read(frame[:]) + if err != nil { + return nil, err + } else if n < 3 { + continue + } + + if frame[0] != '>' { + // not a companion frame + continue + } + + size := int(binary.LittleEndian.Uint16(frame[1:])) + if size > maxCompanionFrameSize { + return nil, fmt.Errorf("meshcore: companion sent frame size of %d, which exceeds maximum of %d", size, maxCompanionFrameSize) + } + + // Make sure we have read all bytes + o := n + for (o - 3) < size { + if n, err = drv.conn.Read(frame[o:]); err != nil { + return nil, err + } + o += n + } + + //log.Printf("read %d:\n%s", size, hex.Dump(frame[:3+size])) + return frame[3 : 3+size], nil + } +} + +func (drv *companionDriver) writeFrame(b []byte) (err error) { + if len(b) > maxCompanionFrameSize { + return fmt.Errorf("meshcore: companion: frame size %d exceed maximum of %d", len(b), maxCompanionFrameSize) + } + + var frame [3 + maxCompanionFrameSize]byte + frame[0] = '<' + binary.LittleEndian.PutUint16(frame[1:], uint16(len(b))) + n := copy(frame[3:], b) + + //log.Printf("send %d:\n%s", n, hex.Dump(frame[:3+n])) + _, err = drv.conn.Write(frame[:3+n]) + return +} + +func (drv *companionDriver) writeCommand(cmd byte, args []byte, wait ...byte) ([]byte, error) { + drv.mu.Lock() + defer drv.mu.Unlock() + + if err := drv.writeFrame(append([]byte{cmd}, args...)); err != nil { + return nil, err + } + + return drv.wait(wait...) +} + +func (drv *companionDriver) wait(wait ...byte) ([]byte, error) { + for { + b, err := drv.readFrame() + if err != nil { + return nil, err + } + if len(b) < 1 { + continue + } + switch { + case b[0] == companionResponseError: + return nil, CompanionError{Code: b[1]} + + case b[0] >= 0x80: + drv.handlePushFrame(b) + continue + + case bytes.Contains(wait, b[:1]): + return b, nil + + case wait == nil: + return b, nil + } + } +} + +func (drv *companionDriver) handlePushFrame(b []byte) { + switch b[0] { + case companionPushAdvert: + case companionPushMessageWaiting: + case companionPushLogRXData: + drv.handleRXData(b[1:]) + } +} + +func (drv *companionDriver) handleRXData(b []byte) { + if len(b) < 2+minPacketSize { + return + } + if drv.packets == nil { + return // not listening for packets, discard + } + + packet := new(Packet) + if err := packet.UnmarshalBytes(b[2:]); err == nil { + packet.SNR = float64(b[0]) / 4 + packet.RSSI = int8(b[1]) + select { + case drv.packets <- packet: + default: + log.Printf("meshcore: packet channel full, dropping packet") + } + if drv.rawPackets != nil { + select { + case drv.rawPackets <- &protocol.Packet{ + Protocol: "meshcore", + SNR: packet.SNR, + RSSI: packet.RSSI, + Raw: packet.Raw, + }: + default: + log.Printf("meshcore: raw packet channel full, dropping packet") + } + } + } +} + +func (drv *companionDriver) sendAppStart() (err error) { + var b []byte + if b, err = drv.writeCommand(companionAppStart, append(make([]byte, 8), []byte("git.maze.io/go/ham")...), companionResponseSelfInfo); err != nil { + return fmt.Errorf("meshcore: can't send application start: %v", err) + } + log.Printf("companion app start response:\n%s", hex.Dump(b)) + + const expect = 1 + 1 + 1 + 1 + 32 + 4 + 4 + 1 + 1 + 1 + 1 + 4 + 4 + 1 + 1 + if len(b) < expect { + return fmt.Errorf("companion: expected %d bytes of self info, got %d", expect, len(b)) + } + if b[0] != companionResponseSelfInfo { + return fmt.Errorf("companion: expected self info response, got %#02x", b[0]) + } + b = b[1:] + + drv.info.Type = NodeType(b[0]) + drv.info.Power = b[1] + drv.info.MaxPower = b[2] + drv.info.PublicKey, _ = crypto.NewPublicKey(b[3 : 3+crypto.PublicKeySize]) + drv.info.Position = new(Position) + drv.info.Position.Unmarshal(b[35:]) + //drv.info.HasMultiACKs = b[43] != 0 + //drv.info.AdvertLocationPolicy = b[44] + //drv.info.TelemetryFlags = b[45] + //drv.info.ManualAddContacts = b[46] + drv.info.Frequency = decodeFrequency(b[47:]) + drv.info.Bandwidth = decodeFrequency(b[51:]) + drv.info.SpreadingFactor = b[55] + drv.info.CodingRate = b[56] + drv.info.Name = strings.TrimRight(string(b[57:]), "\x00") + return +} + +func (drv *companionDriver) sendDeviceInfo() (err error) { + var b []byte + if b, err = drv.writeCommand(companionDeviceQuery, []byte{0x03}, companionResponseDeviceInfo); err != nil { + return + } + + const expect = 4 + 4 + 12 + 40 + 20 + if len(b) < expect { + return fmt.Errorf("companion: expected %d bytes of self info, got %d", expect, len(b)) + } + if b[0] != companionResponseDeviceInfo { + return fmt.Errorf("companion: expected device info response, got %#02x", b[0]) + } + b = b[1:] + + drv.info.Manufacturer = decodeCString(b[19:59]) + drv.info.FirmwareVersion = decodeCString(b[59:79]) + + return +} + +func (drv *companionDriver) poll() { + for { + if _, err := drv.wait(); err != nil { + log.Printf("meshcore: companion %s fatal error: %v", drv.info.Name, err) + return + } + } +} diff --git a/protocol/meshcore/node_const.go b/protocol/meshcore/node_const.go new file mode 100644 index 0000000..3298d25 --- /dev/null +++ b/protocol/meshcore/node_const.go @@ -0,0 +1,124 @@ +package meshcore + +const ( + companionErrCodeUnsupported byte = 1 + iota + companionErrCodeNotFound + companionErrCodeTableFull + companionErrCodeBadState + companionErrCodeFileIOError + companionErrCodeIllegalArgument +) + +// companion command bytes +const ( + companionAppStart byte = 1 + iota // + companionSendTextMessage // + companionSendChannelTextMessage // + companionGetContacts // with optional 'since' (for efficient sync) + companionGetDeviceTime // + companionSetDeviceTime // + companionSendSelfAdvert // + companionSetAdvertName // + companionAddUpdateContact // + companionSyncMessages // + companionSetRadioParams // + companionSetRadioTXPower // + companionResetPath // + companionSetAdvertLatLon // + companionRemoveContact // + companionShareContact // + companionExportContact // + companionImportContact // + companionReboot // + companionGetBatteryAndStorage // was CMD_GetBATTERY_VOLTAGE + companionSetTuningParams // + companionDeviceQuery // + companionExportPrivateKey // + companionImportPrivateKey // + companionSendRawData // + companionSendLogin // + companionSendStatusRequest // + companionHasConnection // + companionLogout // 'Disconnect' + companionGetContactByKey // + companionGetChannel // + companionSetChannel // + companionSignStart // + companionSignData // + companionSignFinish // + companionSendTracePath // + companionSetDevicePIN // + companionSetOtherParams // + companionSendTelemetryRequest // + companionGetCustomVars // + companionSetCustomVar // + companionGetAdvertPath // + companionGetTuningParams // + _ // parked + _ // parked + _ // parked + _ // parked + _ // parked + _ // parked + companionSendBinaryRequest // + companionFactoryReset // + companionSendPathDiscoveryRequest // + _ // parked + companionSetFloodScope // v8+ + companionSendControlData // v8+ + companionGetStats // v8+, second byte is stats type + companionSendAnonymousRequest // + companionSetAutoAddConfig // + companionGetAutoAddConfig // +) + +// companion response bytes +const ( + companionResponseOK byte = iota + companionResponseError + companionResponseContactsStart // first reply to CMD_GetCONTACTS + companionResponseContact // multiple of these (after CMD_GetCONTACTS) + companionResponseEndOfContacts // last reply to CMD_GetCONTACTS + companionResponseSelfInfo // reply to CMD_APP_START + companionResponseSent // reply to CMD_SEND_TXT_MSG + companionResponseContactMessageReceived // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3) + companionResponseChannelMessageReceived // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3) + companionResponseCurrentTime // a reply to CMD_GetDEVICE_TIME + companionResponseNoMoreMessages // a reply to CMD_SYNC_NEXT_MESSAGE + companionResponseExportContact // + companionResponseBatteryAndStorage // a reply to a CMD_GetBATT_AND_STORAGE + companionResponseDeviceInfo // a reply to CMD_DEVICE_QEURY + companionResponsePrivateKey // a reply to CMD_EXPORT_PRIVATE_KEY + companionResponseDisabled // + companionResponseContactMessageReceivedV3 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) + companionResponseChannelMessageReceivedV3 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) + companionResponseChannelInfo // a reply to CMD_GetCHANNEL + companionResponseSignatureStart // + companionResponseSignature // + companionResponseCustomVars // + companionResponseAdvertPath // + companionResponseTuningParams // + companionResponseStats // v8+, second byte is stats type + companionResponseAutoAddConfig // +) + +// companion push code bytes +const ( + companionPushAdvert byte = 0x80 + iota + companionPushPathUpdated + companionPushSendConfirmed + companionPushMessageWaiting + companionPushRawData + companionPushLoginSuccess + companionPushLoginFailure + companionPushStatusResponse + companionPushLogRXData + companionPushTraceData + companionPushNewAdvert + companionPushTelemetryResponse + companionPushBinaryResponse + companionPushPathDiscoveryResponse + companionPushControlData + companionPushContactDeleted + companionPushContactsFull +) diff --git a/protocol/meshcore/packet.go b/protocol/meshcore/packet.go new file mode 100644 index 0000000..cc38d54 --- /dev/null +++ b/protocol/meshcore/packet.go @@ -0,0 +1,302 @@ +package meshcore + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" +) + +const ( + minPacketSize = 2 + maxPathSize = 64 + maxPayloadSize = 184 +) + +type Packet struct { + // SNR is the signal-to-noise ratio. + SNR float64 `json:"snr"` + + // RSSI is the received signal strength indicator (in dBm). + RSSI int8 `json:"rssi"` + + // Raw bytes (optional). + Raw []byte `json:"raw,omitempty"` + + // RouteType is the type of route for this packet. + RouteType RouteType `json:"route_type"` + + // PayloadType is the type of payload for this packet. + PayloadType PayloadType `json:"payload_type"` + + // TransportCodes are set by transport route types. + TransportCodes []uint16 `json:"transport_codes,omitempty"` + + // Path are repeater hashes. + Path []byte `json:"path"` + + // Payload is the raw (encoded) payload. + Payload []byte `json:"payload,omitempty"` +} + +func Decode(data []byte) (Payload, error) { + packet := new(Packet) + if err := packet.UnmarshalBytes(data); err != nil { + return nil, err + } + return packet.Decode() +} + +func (packet *Packet) Decode() (Payload, error) { + var payload Payload + switch packet.PayloadType { + case TypeRequest: + payload = &Request{Raw: packet} + case TypeResponse: + payload = &Response{Raw: packet} + case TypeText: + payload = &Text{Raw: packet} + case TypeAck: + payload = &Acknowledgement{Raw: packet} + case TypeAdvert: + payload = &Advert{Raw: packet} + case TypeGroupText: + payload = &GroupText{Raw: packet} + case TypeGroupData: + payload = &GroupData{Raw: packet} + case TypeAnonRequest: + payload = &AnonymousRequest{Raw: packet} + case TypePath: + payload = &Path{Raw: packet} + case TypeTrace: + payload = &Trace{Raw: packet} + case TypeMultipart: + payload = &Multipart{Raw: packet} + case TypeControl: + payload = &Control{Raw: packet} + case TypeRawCustom: + payload = &RawCustom{Raw: packet} + default: + return nil, fmt.Errorf("meshcore: invalid payload type %#02x", packet.PayloadType) + } + + if err := payload.Unmarshal(packet.Payload); err != nil { + return nil, err + } + + return payload, nil +} + +func (packet *Packet) String() string { + s := []string{ + packet.RouteType.String(), + packet.PayloadType.String(), + } + if len(packet.TransportCodes) == 2 { + s = append(s, fmt.Sprintf("%02X%02X", packet.TransportCodes[0], packet.TransportCodes[1])) + } + if len(packet.Path) > 0 { + s = append(s, formatPath(packet.Path)) + } else { + s = append(s, "direct") + } + return strings.Join(s, " ") +} + +func (packet *Packet) MarshalBytes() []byte { + var ( + data [1 + 4 + 1 + maxPathSize + maxPayloadSize]byte + offset int + ) + data[offset] = byte(packet.RouteType&0x03) | byte((packet.PayloadType&0x0f)<<2) + offset += 1 + + if packet.RouteType.HasTransportCodes() { + binary.LittleEndian.PutUint16(data[offset:], packet.TransportCodes[0]) + offset += 2 + binary.LittleEndian.PutUint16(data[offset:], packet.TransportCodes[1]) + offset += 2 + } + + data[offset] = byte(len(packet.Path)) + offset += 1 + offset += copy(data[offset:], packet.Path) + offset += copy(data[offset:], packet.Payload) + return data[:offset] +} + +func (packet *Packet) UnmarshalBytes(data []byte) error { + if len(data) < minPacketSize { + return io.ErrUnexpectedEOF + } + + packet.Raw = make([]byte, len(data)) + copy(packet.Raw, data) + + packet.RouteType = RouteType(data[0] & 0x03) + packet.PayloadType = PayloadType((data[0] >> 2) & 0x0f) + offset := 1 + + if packet.RouteType.HasTransportCodes() { + if len(data) < minPacketSize+4 { + return io.ErrUnexpectedEOF + } + + packet.TransportCodes = []uint16{ + binary.LittleEndian.Uint16(data[offset+0:]), + binary.LittleEndian.Uint16(data[offset+2:]), + } + offset += 4 + } else { + packet.TransportCodes = nil + } + + pathLength := int(data[offset]) + offset += 1 + if pathLength > maxPathSize { + return fmt.Errorf("meshcore: path length %d exceeds maximum of %d", pathLength, maxPathSize) + } else if pathLength > len(data[offset:]) { + return io.ErrUnexpectedEOF + } + packet.Path = make([]byte, pathLength) + offset += copy(packet.Path, data[offset:]) + + payloadLength := len(data[offset:]) + if payloadLength > maxPayloadSize { + return fmt.Errorf("meshcore: payload length %d exceeds maximum of %d", payloadLength, maxPayloadSize) + } + packet.Payload = make([]byte, payloadLength) + copy(packet.Payload, data[offset:]) + + return nil +} + +func (packet *Packet) Hash() []byte { + h := sha256.New() + h.Write([]byte{byte(packet.PayloadType)}) + if packet.PayloadType == TypeTrace { + h.Write(packet.Path) + } + h.Write(packet.Payload) + return h.Sum(nil)[:8] +} + +func (packet *Packet) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + SNR float64 `json:"snr"` + RSSI int8 `json:"rssi"` + RouteType RouteType `json:"route_type"` + PayloadType PayloadType `json:"payload_type"` + TransportCodes []uint16 `json:"transport_codes,omitempty"` + Path string `json:"path"` + Payload string `json:"payload,omitempty"` + Hash string `json:"hash"` + }{ + packet.SNR, + packet.RSSI, + packet.RouteType, + packet.PayloadType, + packet.TransportCodes, + hex.EncodeToString(packet.Path), + hex.EncodeToString(packet.Payload), + hex.EncodeToString(packet.Hash()), + }) +} + +type RouteType byte + +// Route types. +const ( + TransportFlood RouteType = iota + Flood + Direct + TransportDirect +) + +// HasTransportCodes indicates if this route type has transport codes in the packet. +func (rt RouteType) HasTransportCodes() bool { + return rt == TransportFlood || rt == TransportDirect +} + +// IsDirect is a direct routing type. +func (rt RouteType) IsDirect() bool { + return rt == Direct || rt == TransportDirect +} + +// IsFlood is a flood routing type. +func (rt RouteType) IsFlood() bool { + return rt == Flood || rt == TransportFlood +} + +func (rt RouteType) String() string { + switch rt { + case TransportFlood: + return "F[T]" + case Flood: + return "F" + case Direct: + return "D" + case TransportDirect: + return "D[T]" + default: + return "?" + } +} + +type PayloadType byte + +// Payload types. +const ( + TypeRequest PayloadType = iota + TypeResponse + TypeText + TypeAck + TypeAdvert + TypeGroupText + TypeGroupData + TypeAnonRequest + TypePath + TypeTrace + TypeMultipart + TypeControl + _ // reserved + _ // reserved + _ // reserved + TypeRawCustom +) + +func (pt PayloadType) String() string { + switch pt { + case TypeRequest: + return "request" + case TypeResponse: + return "response" + case TypeText: + return "text" + case TypeAck: + return "ack" + case TypeAdvert: + return "advert" + case TypeGroupText: + return "group text" + case TypeGroupData: + return "group data" + case TypeAnonRequest: + return "anon request" + case TypePath: + return "path" + case TypeTrace: + return "trace" + case TypeMultipart: + return "multipart" + case TypeControl: + return "control" + case TypeRawCustom: + return "raw custom" + default: + return fmt.Sprintf("invalid %02x", byte(pt)) + } +} diff --git a/protocol/meshcore/packet_test.go b/protocol/meshcore/packet_test.go new file mode 100644 index 0000000..b8020f3 --- /dev/null +++ b/protocol/meshcore/packet_test.go @@ -0,0 +1,107 @@ +package meshcore + +import ( + "encoding/hex" + "testing" +) + +func TestDecode(t *testing.T) { + tests := []struct { + Name string + Data []byte + Payload Payload + }{ + { + "request/direct", + mustHexDecode("02000F6950F496ACE4B62F77C1CB31FF24E2FF27AAE9"), + &Request{}, + }, + { + "request/flood", + mustHexDecode("01152E4A72D025293EA6C0F2897D45FECC0162331DBFC5B903A2DE5FF12F012505AA12BA64258639F20EC3"), + &Request{}, + }, + { + "response", + mustHexDecode("05064CE383CC2BFEB1ADC8757CAB3B1F05AC4019AA77CF860278F7FD"), + &Response{}, + }, + { + "text", + mustHexDecode("092C1D9FF5EAE2A69AAAEBCE27F5317E1FB64FEC3E985EDFD27ACC905A5B48BADAFECCCC6AB8662CFAD9D6724E26667F5FC2B7B4D7DC7BAE8F08DE13AD9C42188249"), + &Text{}, + }, + { + "ack", + mustHexDecode("0D0152ED9C3667"), + &Acknowledgement{}, + }, + { + "advert/room", + mustHexDecode("110178DE95A0BB204F245F70BD08DEF93BAA4AEB9C4AD3DE3D0A81ECF888BDF830857D9BC96266A1CDDBA8F0EFF4DE1D59390C17BCBA0ABF4B452A0E24568E8BA18606A771553B095B1AA1F249E9CA22ECE6A1FB9494D00ABB31E0566D6E96A762D8F285D3C40393000000000000000030373120526F6F6D"), + &Advert{}, + }, + { + "advert/repeater", + mustHexDecode("11003E4E39B98F8923DCC1E1A4C65908C9C5002CF2A8CC837F4F8B1F5A62BAE9759C20BE4466E081F73083C86AF7F7F7BBA227A9176DB7B80C45DC9454F61A799C90F9B06251F6D79F07C26D5A34A2669577CE6B93CC2D02FF20A04D99AEE5E2AD89C2CF690D929EED0F03933F5E004E4C2D564E4C2D434E54522D52503031"), + &Advert{}, + }, + { + "grouptext", + mustHexDecode("15011C113EF72DDDBF0FAB26DF1AB902D8062FD94AF9CF337ACB8634E55105B9D77BE8E87BB3AB67CFA4E01044B7AC5EB8B510BD1B6CA395FC991CAFB6D338CDE8599DB45360"), + &GroupText{}, + }, + { + "groupdata", + mustHexDecode("1940EE145428BE90740D2B92838D171164ED40286C45EB40E1390323628D0422A98FA752108D5E7361A4D8B55D98E6EF7B7D6BB6289745C3288D92E09326274911544D9B376EDCF9FCE418B34E38806DB9F402649A683BA4633AA2602E13A455408E9E00CCAD3A2284BA2B7CEAF7EDDBB76FDE5C63D34A5D14C72C8F6ED6BAB2ADAB9BA033D83B"), + &GroupData{}, + }, + { + "anonrequest", + mustHexDecode("1D094379CC88D0E9451C457325C3AC56D53937E22FA358D0AF64562BC038ABF99ABC2911595A13A3748573DCC0B799BEE196939DDDAE210D4D979C665FF2"), + &AnonymousRequest{}, + }, + { + "path", + mustHexDecode("210720CCBED9FE20431125CC1EEA3480E9B06064A5F3B21E890BF91653"), + &Path{}, + }, + { + "trace", + mustHexDecode("E50DE8626B887827CCE9FE43CCCDFEB3EA419F807FD2F7838AA6D3B98A8F7490BA37F6"), + &Trace{}, + }, + { + "control", + mustHexDecode("AD17348E1E5B0FB88C22E948BE3A835161548C561DFBB1C91AC382C06746F6569B2D3911A749A57ACC"), + &Control{}, + }, + { + "multipart", + mustHexDecode("2A04A0BC07E0B2157ECCBA9E2A04A0BCC35A61DE61F43B18E1283721A28871ABD4B00AAEC753B8BEB4141DF364F71E0073E80F5F94B61FA046DA3B35EDF37DFC43F0A0E7"), + &Multipart{}, + }, + { + "raw", + mustHexDecode("7D0A83F2A5A7441F88D10E3F7CEA3B62D383C7C5DA58F5613057EA12730F543CC38E9B3CA5EAB84621714B0E56F8B3A7D51B83108B9E942F3C7C0A6A73DC2699363F9AE7D2D35558"), + &RawCustom{}, + }, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + payload, err := Decode(test.Data) + if err != nil { + t.Fatal(err) + } + t.Logf("%s %s", payload.Packet(), payload) + }) + } +} + +func mustHexDecode(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} diff --git a/protocol/meshcore/payload.go b/protocol/meshcore/payload.go new file mode 100644 index 0000000..6de8aa5 --- /dev/null +++ b/protocol/meshcore/payload.go @@ -0,0 +1,854 @@ +package meshcore + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "log" + "time" + + "git.maze.io/go/ham/protocol/meshcore/crypto" +) + +var ( + zeroTime time.Time + zeroPositionBytes = []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} +) + +type Payload interface { + fmt.Stringer + + // Packet returns the underlying raw packet (if available, can be nil). + Packet() *Packet + + // Marhal encodes the payload to bytes. + Marshal() []byte + + // Unmarshal decodes the payload from bytes. + Unmarshal([]byte) error +} + +type Acknowledgement struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + // Checksum of message timestamp, text and sender public key. + Checksum uint32 `json:"checksum"` +} + +func (ack *Acknowledgement) String() string { + return fmt.Sprintf("checksum=%08X", ack.Checksum) +} + +func (ack *Acknowledgement) Packet() *Packet { + return ack.Raw +} + +func (ack *Acknowledgement) Marshal() []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], ack.Checksum) + return b[:] +} + +func (ack *Acknowledgement) Unmarshal(b []byte) error { + if len(b) < 4 { + return io.ErrUnexpectedEOF + } + ack.Checksum = binary.BigEndian.Uint32(b) + return nil +} + +type RequestType byte + +const ( + GetStats RequestType = iota + 1 + KeepAlive + GetTelemetryData + GetMinMaxAvgData + GetAccessList + GetNeighbors + GetOwnerInfo +) + +func (rt RequestType) String() string { + switch rt { + case GetStats: + return "get stats" + case KeepAlive: + return "keep alive" + case GetTelemetryData: + return "get telemetry data" + case GetMinMaxAvgData: + return "get min/max/avg data" + case GetAccessList: + return "get access list" + case GetNeighbors: + return "get neighbors" + case GetOwnerInfo: + return "get owner info" + default: + return fmt.Sprintf("invalid %#02x", byte(rt)) + } +} + +type Request struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedData + + // Only available after successful decryption: + Content *RequestContent `json:"content,omitempty"` +} + +type RequestContent struct { + // Time of sending the request. + Time time.Time `json:"time"` + + // Type of request. + Type RequestType `json:"type"` + + // Data is the request payload. + Data []byte `json:"data"` +} + +func (req *RequestContent) String() string { + return fmt.Sprintf("time=%s, type=%s, data=(%d bytes)", req.Time, req.Type.String(), len(req.Data)) +} + +func (req *Request) String() string { + if req.Content == nil { + return req.EncryptedData.String() + } + return req.Content.String() +} + +func (req *Request) Packet() *Packet { + return req.Raw +} + +type Response struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedData + + // Only available after successful decryption: + Content *ResponseContent `json:"content,omitempty"` +} + +type ResponseContent struct { + // Tag. + Tag uint32 `json:"tag"` + + // Content of the response. + Content []byte `json:"content"` +} + +func (res *ResponseContent) String() string { + return fmt.Sprintf("tag=%08X content=%q", res.Tag, res.Content) +} + +func (res *Response) String() string { + if res.Content == nil { + return res.EncryptedData.String() + } + return res.Content.String() +} + +func (res *Response) Packet() *Packet { + return res.Raw +} + +type TextType byte + +const ( + PlainText TextType = iota + CLICommand + SignedPlainText +) + +type Text struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedData + + // Only available after successful decryption: + Content *TextContent `json:"content,omitempty"` +} + +type TextContent struct { + // Time of sending the message. + Time time.Time `json:"time"` + + // Type of text. + Type TextType `json:"type"` + + // Attempt for thie packet. + Attempt uint8 `json:"attempt"` + + // Message contains the text message. + Message []byte `json:"message"` +} + +func (text *Text) Packet() *Packet { + return text.Raw +} + +type EncryptedData struct { + // Destination hash is the first byte of the recipient public key. + Destination byte `json:"dst"` + + // Source hash is the first byte of the sender public key. + Source byte `json:"src"` + + // CipherMAC is the message authenticator. + CipherMAC uint16 `json:"cipher_mac"` + + // CipherText is the encrypted message. + CipherText []byte `json:"cipher_text"` +} + +func (enc *EncryptedData) String() string { + return fmt.Sprintf("dst=%02X src=%02X mac=%04X text=", + enc.Destination, + enc.Source, + enc.CipherMAC, + len(enc.CipherText)) +} + +func (enc *EncryptedData) Marshal() []byte { + b := make([]byte, 4) + b[0] = enc.Destination + b[1] = enc.Source + binary.BigEndian.PutUint16(b[2:], enc.CipherMAC) + return append(b, enc.CipherText...) +} + +func (enc *EncryptedData) Unmarshal(b []byte) error { + if len(b) < 4 { + return io.ErrUnexpectedEOF + } + + enc.Destination = b[0] + enc.Source = b[1] + enc.CipherMAC = binary.BigEndian.Uint16(b[2:]) + enc.CipherText = make([]byte, len(b)-4) + copy(enc.CipherText, b[4:]) + return nil +} + +type GroupText struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedGroupData + + // Only available after successful decryption: + Content *GroupTextContent `json:"content,omitempty"` +} + +type GroupTextContent struct { + // Group this was sent on (not part of the packet). + Group *Group `json:"group"` + + // Time of sending. + Time time.Time `json:"time"` + + // Flags is generally 0x00 indicating a plain text message. + Type TextType `json:"type"` + + // Attempt is the number of retries. + Attempt uint8 `json:"attempt"` + + // Text sent to the group. Typically contains a ": " prefix. + Text string `json:"text"` +} + +func (text *GroupTextContent) String() string { + return fmt.Sprintf("time=%s type=%02X attempt=%d text=%q", + text.Time, + text.Type, + text.Attempt, + text.Text) +} + +func (text *GroupText) String() string { + if text.Content == nil { + return text.EncryptedGroupData.String() + } + return text.Content.String() +} + +func (text *GroupText) Packet() *Packet { + return text.Raw +} + +func (text *GroupText) Decrypt(group *Group) error { + b, err := group.Secret.MACThenDecrypt(text.CipherText, text.CipherMAC) + if err != nil { + return err + } + if len(b) < 5 { + return io.ErrUnexpectedEOF + } + + text.Content = &GroupTextContent{ + Group: group, + Time: decodeTime(b), + Type: TextType(b[4] >> 2), + Attempt: b[4] & 0x03, + Text: decodeCString(b[5:]), + } + return nil +} + +type GroupData struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedGroupData + + // Content contains the group datagram. + Content []byte `json:"content,omitempty"` +} + +func (data *GroupData) String() string { + if data.Content == nil { + return data.EncryptedGroupData.String() + } + return fmt.Sprintf("data=%q", data.Content) +} + +func (data *GroupData) Packet() *Packet { + return data.Raw +} + +func (data *GroupData) Decrypt(group *Group) error { + b, err := group.Secret.MACThenDecrypt(data.CipherText, data.CipherMAC) + if err != nil { + return err + } + data.Content = b + return nil +} + +type EncryptedGroupData struct { + // ChannelHash is the first byte of the channel public key. + ChannelHash byte `json:"channel_hash"` + + // CipherMAC is the message authenticator. + CipherMAC uint16 `json:"cipher_mac"` + + // CipherText is the encrypted message. + CipherText []byte `json:"cipher_text"` +} + +func (enc *EncryptedGroupData) String() string { + return fmt.Sprintf("channel=%02X mac=%04X text=", + enc.ChannelHash, + enc.CipherMAC, + len(enc.CipherText)) +} + +func (enc *EncryptedGroupData) Marshal() []byte { + b := make([]byte, 3) + b[0] = enc.ChannelHash + binary.BigEndian.PutUint16(b[1:], enc.CipherMAC) + return append(b, enc.CipherText...) +} + +func (enc *EncryptedGroupData) Unmarshal(b []byte) error { + if len(b) < 3 { + return io.ErrUnexpectedEOF + } + enc.ChannelHash = b[0] + enc.CipherMAC = binary.BigEndian.Uint16(b[1:]) + enc.CipherText = make([]byte, len(b)-3) + copy(enc.CipherText, b[3:]) + return nil +} + +type NodeType byte + +const ( + Chat NodeType = iota + 1 + Repeater + Room + Sensor +) + +func (nt NodeType) String() string { + switch nt { + case Chat: + return "chat" + case Repeater: + return "repeater" + case Room: + return "room" + case Sensor: + return "sensor" + default: + return fmt.Sprintf("", byte(nt)) + } +} + +const ( + advertHasPosition = 0x10 + advertHasFeature1 = 0x20 + advertHasFeature2 = 0x40 + advertHasName = 0x80 +) + +type Advert struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + PublicKey *crypto.PublicKey `json:"public_key"` + Time time.Time `json:"time"` + Signature crypto.Signature `json:"signature"` + Type NodeType `json:"node_type"` + Position *Position `json:"position,omitempty"` + Feature1 *uint16 `json:"feature1,omitempty"` + Feature2 *uint16 `json:"feature2,omitempty"` + Name string `json:"name,omitempty"` +} + +func (adv *Advert) String() string { + return fmt.Sprintf("type=%s position=%s name=%q key=%s", + adv.Type, + adv.Position, + adv.Name, + formatPublicKey(adv.PublicKey.Bytes())) +} + +func (adv *Advert) Packet() *Packet { + return adv.Raw +} + +func (adv *Advert) Marshal() []byte { + b := make([]byte, crypto.PublicKeySize+4+64+1) + copy(b, adv.PublicKey.Bytes()) + encodeTime(b[crypto.PublicKeySize:], adv.Time) + copy(b[crypto.PublicKeySize+4:], adv.Signature[:]) + const flagsOffset = crypto.PublicKeySize + 4 + 64 + b[flagsOffset] = byte(adv.Type) & 0x7 + if adv.Position != nil { + b[flagsOffset] |= advertHasPosition + b = append(b, adv.Position.Marshal()...) + } + if adv.Feature1 != nil { + b[flagsOffset] |= advertHasFeature1 + b = append(b, byte(*adv.Feature1)) + b = append(b, byte(*adv.Feature1>>8)) + } + if adv.Feature2 != nil { + b[flagsOffset] |= advertHasFeature2 + b = append(b, byte(*adv.Feature2)) + b = append(b, byte(*adv.Feature2>>8)) + } + if len(adv.Name) > 0 { + b[flagsOffset] |= advertHasName + b = append(b, []byte(adv.Name)...) + } + return b +} + +func (adv *Advert) Unmarshal(b []byte) error { + if len(b) < crypto.PublicKeySize+4+64+1 { + return io.ErrUnexpectedEOF + } + var ( + n int + err error + ) + + // parse public key + if adv.PublicKey, err = crypto.NewPublicKey(b[:crypto.PublicKeySize]); err != nil { + return err + } + n += crypto.PublicKeySize + + // parse time + adv.Time = decodeTime(b[n:]) + log.Printf("time: %s", adv.Time) + n += 4 + + // parse signature + n += copy(adv.Signature[:], b[n:]) + + // parse flags + flags, rest := b[n], b[n+1:] + adv.Type = NodeType(flags & 0x07) + + if flags&advertHasPosition != 0 { + if len(rest) < 8 { + return io.ErrUnexpectedEOF + } + // nb: some repeaters have no location and send 0,0; we're going to ignore these + if !bytes.Equal(rest[:8], zeroPositionBytes) { + adv.Position = new(Position) + if err = adv.Position.Unmarshal(rest); err != nil { + return err + } + } + rest = rest[8:] + } + + if flags&advertHasFeature1 != 0 { + if len(rest) < 2 { + return io.ErrUnexpectedEOF + } + adv.Feature1 = new(uint16) + *adv.Feature1, rest = binary.LittleEndian.Uint16(rest), rest[2:] + } + + if flags&advertHasFeature2 != 0 { + if len(rest) < 2 { + return io.ErrUnexpectedEOF + } + adv.Feature2 = new(uint16) + *adv.Feature2, rest = binary.LittleEndian.Uint16(rest), rest[2:] + } + + if flags&advertHasName != 0 { + adv.Name = string(rest) + } + + return nil +} + +type AnonymousRequest struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + // Destination hash is the first byte of the recipient public key. + Destination byte `json:"dst"` + + // PublicKey of the sender. + PublicKey *crypto.PublicKey `json:"public_key"` + + // CipherMAC is the message authenticator. + CipherMAC uint16 `json:"cipher_mac"` + + // CipherText is the encrypted message. + CipherText []byte `json:"cipher_text"` +} + +func (req *AnonymousRequest) String() string { + return fmt.Sprintf("dst=%02X key=%s mac=%02X text=", + req.Destination, + formatPublicKey(req.PublicKey.Bytes()), + req.CipherMAC, + len(req.CipherText)) +} + +func (req *AnonymousRequest) Packet() *Packet { + return req.Raw +} + +func (req *AnonymousRequest) Marshal() []byte { + b := make([]byte, 1+crypto.PublicKeySize+2) + b[0] = req.Destination + n := 1 + copy(b[1:], req.PublicKey.Bytes()) + binary.BigEndian.PutUint16(b[n:], req.CipherMAC) + return append(b, req.CipherText...) +} + +func (req *AnonymousRequest) Unmarshal(b []byte) error { + if len(b) < 1+crypto.PublicKeySize+2 { + return io.ErrUnexpectedEOF + } + var err error + req.Destination = b[0] + if req.PublicKey, err = crypto.NewPublicKey(b[1 : 1+crypto.PublicKeySize]); err != nil { + return err + } + req.CipherMAC = binary.BigEndian.Uint16(b[1+crypto.PublicKeySize:]) + req.CipherText = make([]byte, len(b)-1-crypto.PublicKeySize-2) + copy(req.CipherText, b[1+crypto.PublicKeySize+2:]) + return nil +} + +type Path struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + EncryptedData +} + +func (path *Path) Packet() *Packet { + return path.Raw +} + +type Trace struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + Tag uint32 `json:"tag"` + AuthCode uint32 `json:"authcode"` + Flags byte `json:"flags"` + Path []byte `json:"path"` +} + +func (trace *Trace) String() string { + return fmt.Sprintf("tag=%08X authcode=%08X flags=%02X path=%s", + trace.Tag, + trace.AuthCode, + trace.Flags, + formatPath(trace.Path)) +} + +func (trace *Trace) Packet() *Packet { + return trace.Raw +} + +func (trace *Trace) Marshal() []byte { + b := make([]byte, 4+4+1) + binary.LittleEndian.PutUint32(b[0:], trace.Tag) + binary.LittleEndian.PutUint32(b[4:], trace.AuthCode) + b[8] = trace.Flags + return append(b, trace.Path...) +} + +func (trace *Trace) Unmarshal(b []byte) error { + if len(b) < 9 { + return io.ErrUnexpectedEOF + } + trace.Tag = binary.LittleEndian.Uint32(b[0:]) + trace.AuthCode = binary.LittleEndian.Uint32(b[4:]) + trace.Flags = b[8] + trace.Path = make([]byte, len(b)-9) + copy(trace.Path, b[9:]) + return nil +} + +type Multipart struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + Remaining uint8 `json:"remaining"` + Type PayloadType `json:"type"` + Data []byte `json:"data"` +} + +func (multi *Multipart) String() string { + return fmt.Sprintf("remaining=%d type=%s data=%q", + multi.Remaining, + multi.Type.String(), + multi.Data) +} + +func (multi *Multipart) Packet() *Packet { + return multi.Raw +} + +func (multi *Multipart) Marshal() []byte { + return append([]byte{ + multi.Remaining<<4 | byte(multi.Type&0x0F), + }, multi.Data...) +} + +func (multi *Multipart) Unmarshal(b []byte) error { + if len(b) < 1 { + return io.ErrUnexpectedEOF + } + multi.Remaining = b[0] >> 4 + multi.Type = PayloadType(b[0] & 0x0F) + multi.Data = make([]byte, len(b)-1) + copy(multi.Data, b[1:]) + return nil +} + +type ControlType byte + +const ( + DiscoverRequest ControlType = 0x80 + DiscoverResponse ControlType = 0x90 +) + +type Control struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + // Type of control packet. + Type ControlType `json:"type"` + + // Request for discovery. + Request *ControlDiscoverRequest `json:"request,omitempty"` + + // Response for discovery. + Response *ControlDiscoverResponse `json:"response,omitempty"` + + // Data contains the data bytes for unknown/unparsed control types. + Data []byte `json:"data"` +} + +type ControlDiscoverRequest struct { + Flags byte `json:"flags"` // upper 4 bits + PrefixOnly bool `json:"prefix_only"` // lower 1 bit + TypeFilter byte `json:"type_filter"` + Tag uint32 `json:"tag"` + Since *time.Time `json:"since,omitempty"` +} + +func (req ControlDiscoverRequest) String() string { + var since string + if req.Since != nil { + since = " " + req.Since.String() + } + return fmt.Sprintf("flags=%X prefixonly=%t filter=%08b tag=%08X"+since, + req.Flags, + req.PrefixOnly, + req.TypeFilter, + req.Tag) +} + +type ControlDiscoverResponse struct { + Flags byte `json:"flags"` // upper 4 bits + NodeType NodeType `json:"node_type"` // lower 4 bits + SNR float64 `json:"snr"` + Tag uint32 `json:"tag"` + PublicKey []byte `json:"public_key"` // 8 or 32 bytes +} + +func (res ControlDiscoverResponse) String() string { + return fmt.Sprintf("flags=%X node=%s snr=%g tag=%08X key=%s", + res.Flags, + res.NodeType.String(), + res.SNR, + res.Tag, + formatPublicKey(res.PublicKey)) +} + +func (control *Control) String() string { + switch { + case control.Request != nil: + return "request " + control.Request.String() + case control.Response != nil: + return "response " + control.Response.String() + default: + return fmt.Sprintf("type=%02X data=%q", control.Type, control.Data) + } +} + +func (control *Control) Packet() *Packet { + return control.Raw +} + +func (control *Control) Marshal() []byte { + b := []byte{byte(control.Type)} + switch control.Type { + case DiscoverRequest: + if control.Request != nil { + b = append(b, + control.Request.Flags<<4, // 1 + control.Request.TypeFilter, // 2 + 0x00, 0x00, 0x00, 0x00, // 3-6: tag + 0x00, 0x00, 0x00, 0x00, // 7-10: since + ) + if control.Request.PrefixOnly { + b[1] |= 0x01 + } + binary.LittleEndian.PutUint32(b[3:], control.Request.Tag) + if control.Request.Since != nil { + encodeTime(b[7:], *control.Request.Since) + } + } + + case DiscoverResponse: + if control.Response != nil { + b = append(b, + control.Response.Flags<<4|byte(control.Response.NodeType&0x0F), // 1 + byte(control.Response.SNR*4), // 2 + 0x00, 0x00, 0x00, 0x00, // 3-6: tag + ) + binary.LittleEndian.PutUint32(b[3:], control.Response.Tag) + } + b = append(b, control.Response.PublicKey...) + + default: + b = append(b, control.Data...) + } + return b +} + +func (control *Control) Unmarshal(b []byte) error { + if len(b) < 1 { + return io.ErrUnexpectedEOF + } + + control.Type = ControlType(b[0] >> 4) + switch control.Type { + case DiscoverRequest: + if len(b) < 7 { + return io.ErrUnexpectedEOF + } + control.Request = &ControlDiscoverRequest{ + Flags: b[1] >> 4, + PrefixOnly: b[1]&0x01 != 0, + TypeFilter: b[2], + Tag: binary.LittleEndian.Uint32(b[3:]), + } + if len(b) >= 11 { + since := decodeTime(b[7:]) + control.Request.Since = &since + } + + case DiscoverResponse: + if len(b) < 15 { + return io.ErrUnexpectedEOF + } + control.Response = &ControlDiscoverResponse{ + Flags: b[1] >> 4, + NodeType: NodeType(b[1] & 0x0F), + SNR: float64(int8(b[2])) / 4, + Tag: binary.LittleEndian.Uint32(b[3:]), + } + control.Response.PublicKey = make([]byte, len(b)-6) + copy(control.Response.PublicKey, b[6:]) + + default: + control.Data = make([]byte, len(b)-1) + copy(control.Data, b[1:]) + } + + return nil +} + +type RawCustom struct { + // Raw packet (optional). + Raw *Packet `json:"-"` + + // Data in the payload. + Data []byte `json:"data"` +} + +func (raw *RawCustom) String() string { + return fmt.Sprintf("data=%q", raw.Data) +} + +func (raw *RawCustom) Packet() *Packet { + return raw.Raw +} + +func (raw *RawCustom) Marshal() []byte { + return raw.Data +} + +func (raw *RawCustom) Unmarshal(b []byte) error { + raw.Data = make([]byte, len(b)) + copy(raw.Data, b) + return nil +} + +var ( + _ Payload = (*Acknowledgement)(nil) + _ Payload = (*Request)(nil) + _ Payload = (*Response)(nil) + _ Payload = (*Text)(nil) + _ Payload = (*GroupText)(nil) + _ Payload = (*GroupData)(nil) +) diff --git a/protocol/meshcore/util.go b/protocol/meshcore/util.go new file mode 100644 index 0000000..cce8bed --- /dev/null +++ b/protocol/meshcore/util.go @@ -0,0 +1,84 @@ +package meshcore + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "strings" + "time" + + "git.maze.io/go/ham/protocol/meshcore/crypto" +) + +type Position struct { + Latitude float64 + Longitude float64 +} + +func (pos *Position) String() string { + if pos == nil { + return "" + } + return fmt.Sprintf("%f,%f", pos.Latitude, pos.Longitude) +} + +func (pos *Position) Marshal() []byte { + var buf [8]byte + binary.LittleEndian.PutUint32(buf[0:], uint32(pos.Latitude*1e6)) + binary.LittleEndian.PutUint32(buf[4:], uint32(pos.Longitude*1e6)) + return buf[:] +} + +func (pos *Position) Unmarshal(b []byte) error { + if len(b) < 8 { + return io.ErrUnexpectedEOF + } + pos.Latitude = float64(binary.LittleEndian.Uint32(b[0:])) / 1e6 + pos.Longitude = float64(binary.LittleEndian.Uint32(b[4:])) / 1e6 + return nil +} + +func encodeTime(b []byte, t time.Time) { + binary.LittleEndian.PutUint32(b, uint32(t.Unix())) +} + +func decodeTime(b []byte) time.Time { + return time.Unix(int64(binary.LittleEndian.Uint32(b)), 0).UTC() +} + +func encodeFrequency(b []byte, f float64) { + binary.LittleEndian.PutUint32(b, uint32(f*1e3)) +} + +func decodeFrequency(b []byte) float64 { + return float64(int64(binary.LittleEndian.Uint32(b))) / 1e3 +} + +func decodeCString(b []byte) string { + if i := bytes.IndexByte(b, 0x00); i > -1 { + return string(b[:i]) + } + return string(b) +} + +func formatPath(path []byte) string { + p := make([]string, len(path)) + for i, node := range path { + p[i] = fmt.Sprintf("%02X", node) + } + return strings.Join(p, ">") +} + +func formatPublicKey(b []byte) string { + switch len(b) { + case 8: + return fmt.Sprintf("<%016x>", b) + case crypto.PublicKeySize: + return fmt.Sprintf("<%06x…%06x>", b[:3], b[29:]) + case crypto.PrivateKeySize: + return formatPublicKey(b[32:]) + default: + return "" + } +} diff --git a/protocol/packet.go b/protocol/packet.go new file mode 100644 index 0000000..094fd9b --- /dev/null +++ b/protocol/packet.go @@ -0,0 +1,30 @@ +package protocol + +// Packet represents a raw packet. +type Packet struct { + Protocol string `json:"protocol"` // Protocol name + SNR float64 `json:"snr"` // Signal-to-noise Ratio + RSSI int8 `json:"rssi"` // Received Signal Strength Indicator + Raw []byte `json:"raw"` // Raw packet +} + +type Device interface { + // Close the device. + Close() error +} + +// Receiver of packets. +type Receiver interface { + Device + + // RawPackets starts receiving raw packets. + RawPackets() <-chan *Packet +} + +// Transmitter of packets. +type Transmitter interface { + Device + + // SendRawPacket sends a raw (encoded) packet. + SendRawPacket(*Packet) error +}