Skip to content

Commit e4024b5

Browse files
committed
Add support for encrypted messages
1 parent 65658e6 commit e4024b5

File tree

7 files changed

+181
-0
lines changed

7 files changed

+181
-0
lines changed

cipher_service.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package httpsms
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"crypto/sha256"
8+
"encoding/base64"
9+
"errors"
10+
)
11+
12+
// CipherService is used to encrypt and decrypt SMS messages using the AES-256 algorithm
13+
type CipherService service
14+
15+
// Encrypt the message content using the encryption key
16+
func (service *CipherService) Encrypt(encryptionKey string, content string) (string, error) {
17+
block, err := aes.NewCipher(service.hash(encryptionKey))
18+
if err != nil {
19+
return "", errors.Join(err, errors.New("failed to create new cipher"))
20+
}
21+
22+
text := []byte(content)
23+
24+
iv, err := service.initializationVector()
25+
if err != nil {
26+
return "", errors.Join(err, errors.New("failed to create initialization vector"))
27+
}
28+
29+
stream := cipher.NewCFBEncrypter(block, iv)
30+
cipherText := make([]byte, len(text))
31+
stream.XORKeyStream(cipherText, text)
32+
33+
return base64.StdEncoding.EncodeToString(append(iv, cipherText...)), nil
34+
}
35+
36+
// Decrypt the message content using the encryption key
37+
func (service *CipherService) Decrypt(encryptionKey string, cipherText string) (string, error) {
38+
content, err := base64.StdEncoding.DecodeString(cipherText)
39+
if err != nil {
40+
return "", errors.Join(err, errors.New("failed to decode cipher in base64"))
41+
}
42+
43+
block, err := aes.NewCipher(service.hash(encryptionKey))
44+
if err != nil {
45+
return "", errors.Join(err, errors.New("failed to create new cipher"))
46+
}
47+
48+
// Decrypt the message
49+
cipherTextBytes := content[16:]
50+
stream := cipher.NewCFBDecrypter(block, content[:16])
51+
stream.XORKeyStream(cipherTextBytes, cipherTextBytes)
52+
53+
return string(cipherTextBytes), nil
54+
}
55+
56+
// hash a key using the SHA-256 algorithm
57+
func (service *CipherService) hash(key string) []byte {
58+
sha := sha256.Sum256([]byte(key))
59+
return sha[:]
60+
}
61+
62+
func (service *CipherService) initializationVector() ([]byte, error) {
63+
iv := make([]byte, 16)
64+
_, err := rand.Read(iv)
65+
return iv, err
66+
}

cipher_service_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package httpsms
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"testing"
6+
)
7+
8+
func TestCipherService(t *testing.T) {
9+
// Arrange
10+
key := "Password123"
11+
message := "This is a test text message"
12+
client := New()
13+
14+
// Act
15+
encryptedMessage, encryptErr := client.Cipher.Encrypt(key, message)
16+
decryptedMessage, decryptErr := client.Cipher.Decrypt(key, encryptedMessage)
17+
18+
// Assert
19+
assert.Nil(t, encryptErr)
20+
assert.Nil(t, decryptErr)
21+
assert.Equal(t, message, decryptedMessage)
22+
}

client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Client struct {
2424
MessageThreads *MessageThreadService
2525
Heartbeats *HeartbeatService
2626
Messages *MessageService
27+
Cipher *CipherService
2728
}
2829

2930
// New creates and returns a new campay.Client from a slice of campay.ClientOption.
@@ -44,6 +45,8 @@ func New(options ...Option) *Client {
4445
client.Messages = (*MessageService)(&client.common)
4546
client.Heartbeats = (*HeartbeatService)(&client.common)
4647
client.MessageThreads = (*MessageThreadService)(&client.common)
48+
client.Cipher = (*CipherService)(&client.common)
49+
4750
return client
4851
}
4952

heartbeat_service_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package httpsms
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"github.com/NdoleStudio/httpsms-go/internal/helpers"
7+
"github.com/NdoleStudio/httpsms-go/internal/stubs"
8+
"github.com/stretchr/testify/assert"
9+
"net/http"
10+
"testing"
11+
)
12+
13+
func TestHeartbeatService_Index(t *testing.T) {
14+
// Setup
15+
t.Parallel()
16+
17+
// Arrange
18+
apiKey := "test-api-key"
19+
server := helpers.MakeTestServer(http.StatusOK, stubs.HeartbeatIndexResponse())
20+
client := New(WithBaseURL(server.URL), WithAPIKey(apiKey))
21+
22+
// Act
23+
heartbeats, response, err := client.Heartbeats.Index(context.Background(), &HeartbeatIndexParams{
24+
Skip: 0,
25+
Owner: "+18005550199",
26+
Query: nil,
27+
Limit: 100,
28+
})
29+
30+
// Assert
31+
assert.Nil(t, err)
32+
33+
assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode)
34+
35+
jsonContent, _ := json.Marshal(heartbeats)
36+
assert.JSONEq(t, string(stubs.HeartbeatIndexResponse()), string(jsonContent))
37+
38+
// Teardown
39+
server.Close()
40+
}
41+
42+
func TestHeartbeatService_IndexWithError(t *testing.T) {
43+
// Setup
44+
t.Parallel()
45+
46+
// Arrange
47+
apiKey := "test-api-key"
48+
server := helpers.MakeTestServer(http.StatusInternalServerError, stubs.MessagesSendErrorResponse())
49+
client := New(WithBaseURL(server.URL), WithAPIKey(apiKey))
50+
51+
// Act
52+
_, response, err := client.Heartbeats.Index(context.Background(), &HeartbeatIndexParams{
53+
Skip: 0,
54+
Owner: "+18005550199",
55+
Query: nil,
56+
Limit: 100,
57+
})
58+
59+
// Assert
60+
assert.NotNil(t, err)
61+
assert.Equal(t, http.StatusInternalServerError, response.HTTPResponse.StatusCode)
62+
assert.Equal(t, string(stubs.MessagesSendErrorResponse()), string(*response.Body))
63+
64+
// Teardown
65+
server.Close()
66+
}

internal/stubs/heartbeat.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package stubs
2+
3+
// HeartbeatIndexResponse response from the /v1/heartbeats endpoint
4+
func HeartbeatIndexResponse() []byte {
5+
return []byte(`
6+
{
7+
"data": [
8+
{
9+
"id": "9d484671-cac2-41de-9171-d9d2c1835d7b",
10+
"owner": "+18005550199",
11+
"user_id": "hT5V2CmN5bbG81glMLmosxPV9Np2",
12+
"charging": true,
13+
"timestamp": "2024-01-21T13:07:56.203538Z"
14+
}
15+
],
16+
"message": "fetched 1 heartbeat",
17+
"status": "success"
18+
}
19+
`)
20+
}

internal/stubs/message.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func MessagesSendResponse() []byte {
2727
"sent_at": "2022-06-05T14:26:09.527976+03:00",
2828
"sim": "SIM1",
2929
"status": "pending",
30+
"encrypted": false,
3031
"type": "mobile-terminated",
3132
"updated_at": "2022-06-05T14:26:10.303278+03:00",
3233
"user_id": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"

message.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
type MessageSendParams struct {
1111
Content string `json:"content"`
1212
From string `json:"from"`
13+
Encrypted bool `json:"encrypted"`
1314
RequestID string `json:"request_id,omitempty"`
1415
To string `json:"to"`
1516
}
@@ -39,6 +40,8 @@ type Message struct {
3940
Content string `json:"content" example:"This is a sample text message"`
4041
Type string `json:"type" example:"mobile-terminated"`
4142
Status string `json:"status" example:"pending"`
43+
Encrypted bool `json:"encrypted" example:"false"`
44+
4245
// SIM is the SIM card to use to send the message
4346
// * SMS1: use the SIM card in slot 1
4447
// * SMS2: use the SIM card in slot 2

0 commit comments

Comments
 (0)