From a258da3678d07d3a34f65a23928d21d201a0fc63 Mon Sep 17 00:00:00 2001 From: Jake Walker Date: Wed, 3 Aug 2022 18:01:58 +0100 Subject: [PATCH] Initial commit --- .drone.yml | 17 ++++ .gitignore | 139 +++++++++++++++++++++++++++ crypto/aes.go | 78 ++++++++++++++++ crypto/padding.go | 44 +++++++++ crypto/rsa.go | 24 +++++ go.mod | 3 + go.sum | 0 main.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 17 ++++ types.go | 55 +++++++++++ 10 files changed, 609 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 crypto/aes.go create mode 100644 crypto/padding.go create mode 100644 crypto/rsa.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 types.go diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..3a0b94d --- /dev/null +++ b/.drone.yml @@ -0,0 +1,17 @@ +--- +kind: pipeline +type: kubernetes +name: default + +services: + - name: echo-server + image: ghcr.io/will-scargill/echo:latest + +steps: + - name: test + image: golang:1.18 + commands: + - curl -L https://vh7.uk/9kiw > waitfor.sh && chmod +x waitfor.sh + - ./waitfor.sh 127.0.0.1:16000 -t 60 + - go test -v ./... + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c7890c --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Go Patch ### +/vendor/ +/Godeps/ + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace diff --git a/crypto/aes.go b/crypto/aes.go new file mode 100644 index 0000000..cf920ea --- /dev/null +++ b/crypto/aes.go @@ -0,0 +1,78 @@ +package crypto + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +func packCipherData(cipherText, iv []byte) ([]byte, error) { + return json.Marshal([]string{ + base64.StdEncoding.EncodeToString(cipherText), + base64.StdEncoding.EncodeToString(iv), + }) +} + +func unpackCipherData(data []byte) ([]byte, []byte, error) { + var cipherData []string + err := json.Unmarshal(data, &cipherData) + if err != nil { + return nil, nil, err + } + if len(cipherData) != 2 { + return nil, nil, fmt.Errorf("invalid cipher data") + } + + cipherText, err := base64.StdEncoding.DecodeString(cipherData[0]) + if err != nil { + return nil, nil, err + } + + iv, err := base64.StdEncoding.DecodeString(cipherData[1]) + if err != nil { + return nil, nil, err + } + + return cipherText, iv, nil +} + +func EncryptAesCbc(aes cipher.Block, plainTextBytes []byte) ([]byte, error) { + iv := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + + encrypter := cipher.NewCBCEncrypter(aes, iv) + + plainTextBytes, err := pkcs7Pad(plainTextBytes, encrypter.BlockSize()) + if err != nil { + return nil, err + } + + cipherText := make([]byte, len(plainTextBytes)) + encrypter.CryptBlocks(cipherText, plainTextBytes) + + return packCipherData(cipherText, iv) +} + +func DecryptAesCbc(aes cipher.Block, data []byte) ([]byte, error) { + encrypted, iv, err := unpackCipherData(data) + if err != nil { + return nil, err + } + + decryptor := cipher.NewCBCDecrypter(aes, iv) + + decryptedBytes := make([]byte, len(encrypted)) + decryptor.CryptBlocks(decryptedBytes, encrypted) + + decryptedBytes, err = pkcs7Unpad(decryptedBytes, decryptor.BlockSize()) + if err != nil { + return nil, err + } + + return decryptedBytes, nil +} diff --git a/crypto/padding.go b/crypto/padding.go new file mode 100644 index 0000000..63788cb --- /dev/null +++ b/crypto/padding.go @@ -0,0 +1,44 @@ +package crypto + +import ( + "bytes" + "fmt" +) + +// ref: https://golang-examples.tumblr.com/post/98350728789/pkcs7-padding +// Appends padding. +func pkcs7Pad(data []byte, blocklen int) ([]byte, error) { + if blocklen <= 0 { + return nil, fmt.Errorf("Invalid block length %d", blocklen) + } + padlen := 1 + for ((len(data) + padlen) % blocklen) != 0 { + padlen = padlen + 1 + } + + pad := bytes.Repeat([]byte{byte(padlen)}, padlen) + return append(data, pad...), nil +} + +// Returns slice of the original data without padding. +func pkcs7Unpad(data []byte, blocklen int) ([]byte, error) { + if blocklen <= 0 { + return nil, fmt.Errorf("Invalid block length %d", blocklen) + } + if len(data)%blocklen != 0 || len(data) == 0 { + return nil, fmt.Errorf("Invalid data length %d", len(data)) + } + padlen := int(data[len(data)-1]) + if padlen > blocklen || padlen == 0 { + return nil, fmt.Errorf("Invalid padding") + } + // check padding + pad := data[len(data)-padlen:] + for i := 0; i < padlen; i++ { + if pad[i] != byte(padlen) { + return nil, fmt.Errorf("Invalid padding") + } + } + + return data[:len(data)-padlen], nil +} diff --git a/crypto/rsa.go b/crypto/rsa.go new file mode 100644 index 0000000..327ff70 --- /dev/null +++ b/crypto/rsa.go @@ -0,0 +1,24 @@ +package crypto + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" +) + +func RsaEncrypt(pubKey []byte, text []byte) (string, error) { + block, _ := pem.Decode(pubKey) + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", err + } + k := key.(*rsa.PublicKey) + ciphertext, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, k, text, nil) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a767601 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.vh7.uk/jakew/echo-go + +go 1.18 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..081ab4c --- /dev/null +++ b/main.go @@ -0,0 +1,232 @@ +package echo_go + +import ( + "bytes" + "crypto/aes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net" + "strings" + + "git.vh7.uk/jakew/echo-go/crypto" +) + +const EchoVersion = "3.17" + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (c *Client) SendPlain(msgType MessageType, data *string, subType *string, metadata []string) error { + metadataBytes, err := json.Marshal(&metadata) + if err != nil { + return err + } + msg := RawMessage{ + UserId: c.UserId, + MessageType: msgType, + SubType: subType, + Data: data, + Metadata: string(metadataBytes), + } + + b, err := json.Marshal(&msg) + if err != nil { + return err + } + + //log.Printf("sending message %v", msg) + _, err = c.Con.Write(b) + return err +} + +func (c *Client) Send(msgType MessageType, data *string, subType *string, metadata []string) error { + metadataBytes, err := json.Marshal(&metadata) + if err != nil { + return err + } + msg := RawMessage{ + UserId: c.UserId, + MessageType: msgType, + SubType: subType, + Data: data, + Metadata: string(metadataBytes), + } + + plainBytes, err := json.Marshal(&msg) + if err != nil { + return err + } + + aesCipher, err := aes.NewCipher([]byte(c.SessionKey)) + if err != nil { + return err + } + + encryptedBytes, err := crypto.EncryptAesCbc(aesCipher, plainBytes) + if err != nil { + return err + } + + _, err = c.Con.Write(encryptedBytes) + return err +} + +func (c *Client) ReceivePlain() ([]RawMessage, error) { + data := make([]byte, 20480) + _, err := c.Con.Read(data) + if err != nil { + return nil, err + } + + messages := []RawMessage{} + + for _, message := range strings.Split(string(bytes.Trim(data, "\x00")), "}") { + if strings.TrimSpace(message) == "" { + continue + } + + var msg RawMessage + err = json.Unmarshal([]byte(message+"}"), &msg) + if err != nil { + return nil, err + } + + messages = append(messages, msg) + } + + return messages, nil +} + +func (c *Client) Receive() ([]RawMessage, error) { + data := make([]byte, 20480) + _, err := c.Con.Read(data) + if err != nil { + return nil, err + } + + messages := []RawMessage{} + + for _, message := range strings.Split(string(bytes.Trim(data, "\x00")), "]") { + if strings.TrimSpace(message) == "" { + continue + } + + aesCipher, err := aes.NewCipher([]byte(c.SessionKey)) + if err != nil { + return nil, err + } + + decrypted, err := crypto.DecryptAesCbc(aesCipher, []byte(message+"]")) + if err != nil { + return nil, err + } + + var msg RawMessage + err = json.Unmarshal(decrypted, &msg) + if err != nil { + return nil, err + } + + messages = append(messages, msg) + } + + return messages, nil +} + +func (c *Client) handshakeLoop(password string) error { + log.Println("sending server info request") + err := c.SendPlain(ReqServerInfo, nil, nil, nil) + if err != nil { + return err + } + + encrypted := false + + for { + var msgs []RawMessage + var err error + if encrypted { + msgs, err = c.Receive() + } else { + msgs, err = c.ReceivePlain() + } + if err != nil { + return err + } + + for _, msg := range msgs { + switch msg.MessageType { + case ResServerInfo: + ciphertext, err := crypto.RsaEncrypt([]byte(*msg.Data), []byte(c.SessionKey)) + err = c.SendPlain(ReqClientSecret, &ciphertext, nil, nil) + if err != nil { + return err + } + encrypted = true + case ResClientSecret: + data, err := json.Marshal([]string{ + c.Username, + password, + EchoVersion, + }) + if err != nil { + return err + } + dataStr := string(data) + err = c.Send(ReqConnection, &dataStr, nil, nil) + if err != nil { + return err + } + case ResConnectionAccepted: + log.Println("handshake accepted") + return nil + case ResConnectionDenied: + return fmt.Errorf("handshake failed - %v", *msg.Data) + default: + return fmt.Errorf("unexpected handshake message type %v", msg.MessageType) + } + } + } +} + +func (c *Client) Disconnect() { + log.Println("gracefully disconnecting") + _ = c.Send(ReqDisconnect, nil, nil, nil) + _ = c.Con.Close() +} + +func New(addr string, username string) (*Client, error) { + userId, err := randomHex(32) + if err != nil { + return nil, err + } + + sessionKey, err := randomHex(8) + if err != nil { + return nil, err + } + + log.Printf("connecting to %v", addr) + + con, err := net.Dial("tcp", addr) + if err != nil { + return nil, err + } + + client := Client{ + Con: con, + UserId: userId, + SessionKey: sessionKey, + Username: username, + } + + return &client, nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d090ab0 --- /dev/null +++ b/main_test.go @@ -0,0 +1,17 @@ +package echo_go + +import "testing" + +func TestCanConnect(t *testing.T) { + client, err := New("127.0.0.1:16000", "EchoGoTest") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + err = client.handshakeLoop("mypassword") + if err != nil { + t.Fatalf("failed to run handshake loop: %v", err) + } + + client.Disconnect() +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..b3158b8 --- /dev/null +++ b/types.go @@ -0,0 +1,55 @@ +package echo_go + +import ( + "fmt" + "net" +) + +type MessageType string + +const ( + ReqServerInfo MessageType = "serverInfoRequest" + ReqClientSecret = "clientSecret" + ReqConnection = "connectionRequest" + ReqDisconnect = "disconnect" + ReqInfo = "requestInfo" + ReqUserMessage = "userMessage" + ReqChangeChannel = "changeChannel" + ReqHistory = "historyRequest" + ReqLeaveChannel = "leaveChannel" + + ResServerInfo = "serverInfo" + ResClientSecret = "gotSecret" + ResConnectionAccepted = "CRAccepted" + ResConnectionDenied = "CRDenied" + ResOutboundMessage = "outboundMessage" + ResConnectionTerminated = "connectionTerminated" + ResChannelUpdate = "channelUpdate" + ResCommandData = "commandData" + ResChannelHistory = "channelHistory" + ResErrorOccurred = "errorOccured" + ResAdditionalHistory = "additionalHistory" +) + +type Client struct { + Con net.Conn + UserId string + SessionKey string + Username string +} + +type RawMessage struct { + UserId string `json:"userid"` + MessageType MessageType `json:"messagetype"` + SubType *string `json:"subtype"` + Data *string `json:"data"` + Metadata string `json:"metadata"` +} + +func (m *RawMessage) String() string { + return fmt.Sprintf("\n"+ + " UserId: %v\n"+ + " Type: %v - %v\n"+ + " Data: %v\n"+ + " Metadata: %v", m.UserId, m.MessageType, *m.SubType, *m.Data, m.Metadata) +}