diff --git a/.travis.yml b/.travis.yml index dd349b9..d6af35f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ --- language: go go: - - 1.3 - - 1.4 - 1.5 - 1.6 - 1.7 @@ -12,6 +10,7 @@ install: - go get github.com/stretchr/testify/assert - go get github.com/axw/gocov/gocov - go get github.com/mattn/goveralls + - go get golang.org/x/crypto/nacl/secretbox - if ! go get code.google.com/p/go.tools/cmd/cover; then go get golang.org/x/tools/cmd/cover; fi - $HOME/gopath/bin/goveralls -service=travis-ci -repotoken=$COVERALLS_TOKEN script: diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c8f54..7abd3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +1.3.0 / 2018-08-13 +================== + +* This release adds support for end to end encrypted channels, a new feature for Channels. Read more [in our docs](https://pusher.com/docs/client_api_guide/client_encrypted_channels). + 1.2.0 / 2016-05-24 ================== diff --git a/README.md b/README.md index 647f110..4fa2dff 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ This package lets you trigger events to your client and query the state of your In order to use this library, you need to have a free account on . After registering, you will need the application credentials for your app. +This library requires you to be using at least Go 1.5 or greater. + ## Table of Contents - [Installation](#installation) @@ -133,6 +135,28 @@ Setting the `pusher.Client`'s `Cluster` property will make sure requests are sen ```go client.Cluster = "eu" // in this case requests will be made to api-eu.pusher.com. ``` +#### End to End Encryption + +This library supports end to end encryption of your private channels. This means that only you and your connected clients will be able to read your messages. Pusher cannot decrypt them. You can enable this feature by following these steps: + +1. You should first set up Private channels. This involves [creating an authentication endpoint on your server](https://pusher.com/docs/authenticating_users). + +2. Next, Specify your 32 character `EncryptionMasterKey`. This is secret and you should never share this with anyone. Not even Pusher. + +```go +client := pusher.Client{ + AppId: "your_app_id", + Key: "your_app_key", + Secret: "your_app_secret", + Cluster: "your_app_cluster", + EncryptionMasterKey "abcdefghijklmnopqrstuvwxyzabcdef" +} +``` +3. Channels where you wish to use end to end encryption should be prefixed with `private-encrypted-`. + +4. Subscribe to these channels in your client, and you're done! You can verify it is working by checking out the debug console on the https://dashboard.pusher.com/ and seeing the scrambled ciphertext. + +**Important note: This will not encrypt messages on channels that are not prefixed by private-encrypted-.** ### Google App Engine diff --git a/client.go b/client.go index 2370e8e..86ec78e 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package pusher import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -49,6 +50,7 @@ type Client struct { Secure bool // true for HTTPS Cluster string HttpClient *http.Client + EncryptionMasterKey string //for E2E } /* @@ -169,15 +171,22 @@ func (c *Client) trigger(channels []string, eventName string, data interface{}, return nil, errors.New("You cannot trigger on more than 10 channels at once") } + if len(channels) > 1 && encryptedChannelPresent(channels) { + return nil, errors.New("You cannot trigger batch events when using encrypted channels") + } + if !channelsAreValid(channels) { return nil, errors.New("At least one of your channels' names are invalid") } + if !validEncryptionKey(c.EncryptionMasterKey) && encryptedChannelPresent(channels) { + return nil, errors.New("Your Encryption key is not of the correct format") + } if err := validateSocketID(socketID); err != nil { return nil, err } - payload, err := createTriggerPayload(channels, eventName, data, socketID) + payload, err := createTriggerPayload(channels, eventName, data, socketID, c.EncryptionMasterKey) if err != nil { return nil, err } @@ -372,8 +381,8 @@ func (c *Client) AuthenticatePresenceChannel(params []byte, member MemberData) ( } func (c *Client) authenticateChannel(params []byte, member *MemberData) (response []byte, err error) { - channelName, socketID, err := parseAuthRequestParams(params) + channelName, socketID, err := parseAuthRequestParams(params) if err != nil { return } @@ -397,7 +406,15 @@ func (c *Client) authenticateChannel(params []byte, member *MemberData) (respons stringToSign = strings.Join([]string{stringToSign, jsonUserData}, ":") } - _response := createAuthMap(c.Key, c.Secret, stringToSign) + var _response map[string]string + + if isEncryptedChannel(channelName) { + sharedSecret := generateSharedSecret(channelName, c.EncryptionMasterKey) + sharedSecretB64 := base64.StdEncoding.EncodeToString(sharedSecret[:]) + _response = createAuthMap(c.Key, c.Secret, stringToSign, sharedSecretB64) + } else { + _response = createAuthMap(c.Key, c.Secret, stringToSign, "") + } if member != nil { _response["channel_data"] = jsonUserData @@ -433,10 +450,14 @@ If it is invalid, the first return value will be nil, and an error will be passe } */ func (c *Client) Webhook(header http.Header, body []byte) (*Webhook, error) { - for _, token := range header["X-Pusher-Key"] { if token == c.Key && checkSignature(header.Get("X-Pusher-Signature"), c.Secret, body) { - return unmarshalledWebhook(body) + unmarshalledWebhooks, err := unmarshalledWebhook(body) + if err != nil { + return unmarshalledWebhooks, err + } + decryptedWebhooks, err := decryptEvents(*unmarshalledWebhooks, c.EncryptionMasterKey) + return decryptedWebhooks, err } } return nil, errors.New("Invalid webhook") diff --git a/crypto.go b/crypto.go index 91b0f6d..4bcaf57 100644 --- a/crypto.go +++ b/crypto.go @@ -3,12 +3,23 @@ package pusher import ( "crypto/hmac" "crypto/md5" + "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" - // "fmt" + "encoding/json" + "errors" + "io" "strings" + + "golang.org/x/crypto/nacl/secretbox" ) +type EncryptedMessage struct { + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + func hmacSignature(toSign, secret string) string { return hex.EncodeToString(hmacBytes([]byte(toSign), []byte(secret))) } @@ -29,9 +40,12 @@ func checkSignature(result, secret string, body []byte) bool { return hmac.Equal(expected, resultBytes) } -func createAuthMap(key, secret, stringToSign string) map[string]string { +func createAuthMap(key, secret, stringToSign string, sharedSecret string) map[string]string { authSignature := hmacSignature(stringToSign, secret) authString := strings.Join([]string{key, authSignature}, ":") + if sharedSecret != "" { + return map[string]string{"auth": authString, "shared_secret": sharedSecret} + } return map[string]string{"auth": authString} } @@ -40,3 +54,73 @@ func md5Signature(body []byte) string { _bodyMD5.Write([]byte(body)) return hex.EncodeToString(_bodyMD5.Sum(nil)) } + +func encrypt(channel string, data []byte, encryptionKey string) string { + sharedSecret := generateSharedSecret(channel, encryptionKey) + nonce := generateNonce() + nonceB64 := base64.StdEncoding.EncodeToString(nonce[:]) + cipherText := secretbox.Seal([]byte{}, data, &nonce, &sharedSecret) + cipherTextB64 := base64.StdEncoding.EncodeToString(cipherText) + return formatMessage(nonceB64, cipherTextB64) +} + +func formatMessage(nonce string, cipherText string) string { + encryptedMessage := &EncryptedMessage{ + Nonce: nonce, + Ciphertext: cipherText, + } + json, err := json.Marshal(encryptedMessage) + if err != nil { + panic(err) + } + + return string(json) +} + +func generateNonce() [24]byte { + var nonce [24]byte + //Trick ReadFull into thinking nonce is a slice + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + panic(err) + } + return nonce +} + +func generateSharedSecret(channel string, encryptionKey string) [32]byte { + return sha256.Sum256([]byte(channel + encryptionKey)) +} + +func decryptEvents(webhookData Webhook, encryptionKey string) (*Webhook, error) { + decryptedWebhooks := &Webhook{} + decryptedWebhooks.TimeMs = webhookData.TimeMs + for _, event := range webhookData.Events { + + if isEncryptedChannel(event.Channel) { + var encryptedMessage EncryptedMessage + json.Unmarshal([]byte(event.Data), &encryptedMessage) + cipherTextBytes, decodePayloadErr := base64.StdEncoding.DecodeString(encryptedMessage.Ciphertext) + if decodePayloadErr != nil { + return decryptedWebhooks, decodePayloadErr + } + + nonceBytes, decodeNonceErr := base64.StdEncoding.DecodeString(encryptedMessage.Nonce) + if decodeNonceErr != nil { + return decryptedWebhooks, decodeNonceErr + } + + // Convert slice to fixed length array for secretbox + var nonce [24]byte + copy(nonce[:], []byte(nonceBytes[:])) + + sharedSecret := generateSharedSecret(event.Channel, encryptionKey) + box := []byte(cipherTextBytes) + decryptedBox, ok := secretbox.Open([]byte{}, box, &nonce, &sharedSecret) + if !ok { + return decryptedWebhooks, errors.New("Failed to decrypt event, possibly wrong key?") + } + event.Data = string(decryptedBox) + } + decryptedWebhooks.Events = append(decryptedWebhooks.Events, event) + } + return decryptedWebhooks, nil +} diff --git a/crypto_test.go b/crypto_test.go new file mode 100644 index 0000000..b184901 --- /dev/null +++ b/crypto_test.go @@ -0,0 +1,154 @@ +package pusher + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHmacSignature(t *testing.T) { + expected := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" + toSign := "Hello!" + secret := "supersecret" + hmac := hmacSignature(toSign, secret) + assert.Equal(t, hmac, expected) +} + +func TestHmacBytes(t *testing.T) { + expectedHex := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" + expectedBytes, _ := hex.DecodeString(expectedHex) + toSign := "Hello!" + secret := "supersecret" + hmacBytes := hmacBytes([]byte(toSign), []byte(secret)) + assert.Equal(t, hmacBytes, expectedBytes) +} + +func TestCheckValidSignature(t *testing.T) { + signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" + secret := "supersecret" + body := "Hello!" + validSignature := checkSignature(signature, secret, []byte(body)) + assert.Equal(t, validSignature, true) +} + +func TestCheckInvalidSignature(t *testing.T) { + signature := "no" + secret := "supersecret" + body := "Hello!" + validSignature := checkSignature(signature, secret, []byte(body)) + assert.Equal(t, validSignature, false) +} + +func TestCreateAuthMapNoE2E(t *testing.T) { + signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" + key := "key" + secret := "supersecret" + stringToSign := "Hello!" + sharedSecret := "" + authMap := createAuthMap(key, secret, stringToSign, sharedSecret) + // The [4:] here removes the prefix of key: from the string. + assert.Equal(t, authMap["auth"][4:], signature) + assert.Equal(t, authMap["shared_secret"], "") +} + +func TestCreateAuthMapE2E(t *testing.T) { + signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" + key := "key" + secret := "supersecret" + stringToSign := "Hello!" + sharedSecret := "This is a string that is 32 chars" + authMap := createAuthMap(key, secret, stringToSign, sharedSecret) + // The [4:] here removes the prefix of key: from the string. + assert.Equal(t, authMap["auth"][4:], signature) + assert.Equal(t, authMap["shared_secret"], sharedSecret) +} + +func TestMD5Signature(t *testing.T) { + expected := "952d2c56d0485958336747bcdd98590d" + actual := md5Signature([]byte("Hello!")) + assert.Equal(t, expected, actual) +} + +func TestEncrypt(t *testing.T) { + channel := "private-encrypted-bla" + body := []byte("Hello!") + encryptionKey := "This is a string that is 32 chars" + cipherText := encrypt(channel, body, encryptionKey) + assert.NotNil(t, cipherText) + assert.NotEqual(t, cipherText, body) +} + +func TestFormatMessage(t *testing.T) { + nonce := "a" + cipherText := "b" + formatted := formatMessage(nonce, cipherText) + assert.Equal(t, formatted, "{\"nonce\":\"a\",\"ciphertext\":\"b\"}") +} + +func TestGenerateSharedSecret(t *testing.T) { + channel := "private-encrypted-bla" + encryptionKey := "This is a string that is 32 chars" + sharedSecret := generateSharedSecret(channel, encryptionKey) + t.Log(hex.EncodeToString(sharedSecret[:])) + expected := "004831f99d2a4e86723e893caded3a2897deeddbed9514fe9497dcddc52bd50b" + assert.Equal(t, hex.EncodeToString(sharedSecret[:]), expected) +} + +func TestDecryptValidKey(t *testing.T) { + channel := "private-encrypted-bla" + plaintext := "Hello!" + cipherText := "{\"nonce\":\"sjklahvpWWQgAjTx5FfYHCCxd2AmaL9T\",\"ciphertext\":\"zoDEe8dA3nDXKsybAWce/hXGW4szJw==\"}" + encryptionKey := "This is a string that is 32 chars" + + encryptedWebhookData := &Webhook{ + TimeMs: 1, + Events: []WebhookEvent{ + WebhookEvent{ + Name: "event", + Channel: channel, + Event: "event", + Data: cipherText, + SocketId: "44610.7511910", + }, + }, + } + + expectedWebhookData := &Webhook{ + TimeMs: 1, + Events: []WebhookEvent{ + WebhookEvent{ + Name: "event", + Channel: channel, + Event: "event", + Data: plaintext, + SocketId: "44610.7511910", + }, + }, + } + decryptedWebhooks, _ := decryptEvents(*encryptedWebhookData, encryptionKey) + assert.Equal(t, expectedWebhookData, decryptedWebhooks) +} + +func TestDecryptInvalidKey(t *testing.T) { + channel := "private-encrypted-bla" + cipherText := "{\"nonce\":\"sjklahvpWWQgAjTx5FfYHCCxd2AmaL9T\",\"ciphertext\":\"zoDEe8dA3nDXKsybAWce/hXGW4szJw==\"}" + encryptionKey := "This is an invalid key 32 chars!!" + + encryptedWebhookData := &Webhook{ + TimeMs: 1, + Events: []WebhookEvent{ + WebhookEvent{ + Name: "event", + Channel: channel, + Event: "event", + Data: cipherText, + SocketId: "44610.7511910", + }, + }, + } + decryptedWebhooks, err := decryptEvents(*encryptedWebhookData, encryptionKey) + assert.Equal(t, decryptedWebhooks.Events, []WebhookEvent(nil)) + assert.EqualError(t, err, "Failed to decrypt event, possibly wrong key?") + +} diff --git a/event.go b/event.go index 954c49e..1bcb916 100644 --- a/event.go +++ b/event.go @@ -23,9 +23,10 @@ type BufferedEvents struct { EventIds map[string]string `json:"event_ids,omitempty"` } -func createTriggerPayload(channels []string, event string, data interface{}, socketID *string) ([]byte, error) { +func createTriggerPayload(channels []string, event string, data interface{}, socketID *string, encryptionKey string) ([]byte, error) { var dataBytes []byte var err error + var payloadData string switch d := data.(type) { case []byte: @@ -38,15 +39,19 @@ func createTriggerPayload(channels []string, event string, data interface{}, soc return nil, err } } + if isEncryptedChannel(channels[0]) { + payloadData = encrypt(channels[0], dataBytes, encryptionKey) + } else { + payloadData = string(dataBytes) + } - if len(dataBytes) > 10240 { + if len(payloadData) > 10240 { return nil, errors.New("Data must be smaller than 10kb") } - return json.Marshal(&eventPayload{ Name: event, Channels: channels, - Data: string(dataBytes), + Data: payloadData, SocketId: socketID, }) } diff --git a/util.go b/util.go index d89fc8f..d80b02b 100644 --- a/util.go +++ b/util.go @@ -5,6 +5,7 @@ import ( "net/url" "regexp" "strconv" + "strings" "time" ) @@ -42,6 +43,25 @@ func channelsAreValid(channels []string) bool { } return true } +func encryptedChannelPresent(channels []string) bool { + for _, channel := range channels { + if isEncryptedChannel(channel) { + return true + } + } + return false +} + +func isEncryptedChannel(channel string) bool { + if strings.HasPrefix(channel, "private-encrypted-") { + return true + } + return false +} + +func validEncryptionKey(encryptionKey string) bool { + return len(encryptionKey) == 32 +} func validateSocketID(socketID *string) (err error) { if (socketID == nil) || socketIDValidationRegex.MatchString(*socketID) {