Added: support for encrypted messages

This commit is contained in:
Aleksandr Soloshenko 2024-01-08 15:55:14 +07:00
parent b8fd478afa
commit 86d59aabfc
10 changed files with 191 additions and 24 deletions

View File

@ -18,5 +18,21 @@ Authorization: Basic {{localCredentials}}
}
###
GET {{localUrl}}/message/hr8HV_KE0kehtBCApEaIn HTTP/1.1
POST {{localUrl}}/message HTTP/1.1
Content-Type: application/json
Authorization: Basic {{localCredentials}}
{
"message": "17wc9/ZRf1l84LHkEK3hgA==.aH1XrMHAeMyF4PeiavV3dk8o2fP0nSo92IqseLQfg14=",
"ttl": 600,
"phoneNumbers": [
"xkQeXzSDFj2xP6JBUMK0pA==.PfUHEa9QZv8h7JnUoBlmWw=="
],
"simNumber": 1,
"withDeliveryReport": true,
"isEncrypted": true
}
###
GET {{localUrl}}/message/2a1hOxM1zuZVygvE3uX0j HTTP/1.1
Authorization: Basic {{localCredentials}}

View File

@ -27,6 +27,22 @@ Authorization: Basic {{credentials}}
"withDeliveryReport": true
}
###
POST {{baseUrl}}/api/3rdparty/v1/message HTTP/1.1
Content-Type: application/json
Authorization: Basic {{credentials}}
{
"message": "17wc9/ZRf1l84LHkEK3hgA==.aH1XrMHAeMyF4PeiavV3dk8o2fP0nSo92IqseLQfg14=",
"ttl": 600,
"phoneNumbers": [
"xkQeXzSDFj2xP6JBUMK0pA==.PfUHEa9QZv8h7JnUoBlmWw=="
],
"simNumber": 1,
"withDeliveryReport": true,
"isEncrypted": true
}
###
GET {{baseUrl}}/api/3rdparty/v1/message/-rnEaUz7KObDdokPrzKpM HTTP/1.1
Authorization: Basic {{credentials}}

View File

@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE `messages`
ADD `is_encrypted` tinyint(1) unsigned NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE `message_recipients`
MODIFY COLUMN `phone_number` varchar(128) NOT NULL;
-- +goose StatementEnd
---
-- +goose Down
-- +goose StatementBegin
ALTER TABLE `message_recipients`
MODIFY COLUMN `phone_number` varchar(16) NOT NULL;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE `messages` DROP `is_encrypted`;
-- +goose StatementEnd

View File

@ -51,7 +51,8 @@ type Message struct {
SimNumber *uint8 `gorm:"type:tinyint(1) unsigned"`
WithDeliveryReport bool `gorm:"not null;type:tinyint(1) unsigned"`
IsHashed bool `gorm:"not null;type:tinyint(1) unsigned;default:0"`
IsHashed bool `gorm:"not null;type:tinyint(1) unsigned;default:0"`
IsEncrypted bool `gorm:"not null;type:tinyint(1) unsigned;default:0"`
Device Device `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"`
Recipients []MessageRecipient `gorm:"foreignKey:MessageID;constraint:OnDelete:CASCADE"`
@ -61,7 +62,7 @@ type Message struct {
type MessageRecipient struct {
MessageID uint64 `gorm:"primaryKey;type:BIGINT UNSIGNED"`
PhoneNumber string `gorm:"primaryKey;type:varchar(16)"`
PhoneNumber string `gorm:"primaryKey;type:varchar(128)"`
State MessageState `gorm:"not null;type:enum('Pending','Sent','Processed','Delivered','Failed');default:Pending"`
Error *string `gorm:"type:varchar(256)"`
}

View File

@ -84,7 +84,7 @@ func (r *MessagesRepository) HashProcessed() error {
defer tx.Exec("SELECT RELEASE_LOCK(?)", HashingLockName)
err = tx.Model(&models.MessageRecipient{}).
Where("message_id IN (?)", tx.Model(&models.Message{}).Select("id").Where("is_hashed = ? AND state <> ?", false, models.MessageStatePending)).
Where("message_id IN (?)", tx.Model(&models.Message{}).Select("id").Where("is_hashed = ? AND is_encrypted = ? AND state <> ?", false, false, models.MessageStatePending)).
Update("phone_number", gorm.Expr("LEFT(SHA2(phone_number, 256), 16)")).
Error
if err != nil {

View File

@ -69,9 +69,10 @@ func (s *MessagesService) SelectPending(deviceID string) ([]smsgateway.Message,
ID: v.ExtID,
Message: v.Message,
TTL: ttl,
PhoneNumbers: s.recipientsToDomain(v.Recipients),
SimNumber: v.SimNumber,
WithDeliveryReport: types.AsPointer[bool](v.WithDeliveryReport),
IsEncrypted: v.IsEncrypted,
PhoneNumbers: s.recipientsToDomain(v.Recipients),
}
}
@ -114,10 +115,15 @@ func (s *MessagesService) Enqeue(device models.Device, message smsgateway.Messag
Recipients: make([]smsgateway.RecipientState, len(message.PhoneNumbers)),
}
var phone string
var err error
for i, v := range message.PhoneNumbers {
phone, err := cleanPhoneNumber(v)
if err != nil {
return state, fmt.Errorf("can't use phone in row %d: %w", i+1, err)
if message.IsEncrypted {
phone = v
} else {
if phone, err = cleanPhoneNumber(v); err != nil {
return state, fmt.Errorf("can't use phone in row %d: %w", i+1, err)
}
}
message.PhoneNumbers[i] = phone
@ -140,7 +146,10 @@ func (s *MessagesService) Enqeue(device models.Device, message smsgateway.Messag
ValidUntil: validUntil,
SimNumber: message.SimNumber,
WithDeliveryReport: types.OrDefault[bool](message.WithDeliveryReport, true),
IsEncrypted: message.IsEncrypted,
Device: device,
Recipients: s.recipientsToModel(message.PhoneNumbers),
TimedModel: models.TimedModel{},
}
if msg.ExtID == "" {
msg.ExtID = s.idgen()
@ -240,10 +249,11 @@ func (s *MessagesService) recipientsStateToModel(input []smsgateway.RecipientSta
func modelToMessageState(input models.Message) smsgateway.MessageState {
return smsgateway.MessageState{
ID: input.ExtID,
State: smsgateway.ProcessState(input.State),
IsHashed: input.IsHashed,
Recipients: slices.Map(input.Recipients, modelToRecipientState),
ID: input.ExtID,
State: smsgateway.ProcessState(input.State),
IsHashed: input.IsHashed,
IsEncrypted: input.IsEncrypted,
Recipients: slices.Map(input.Recipients, modelToRecipientState),
}
}

View File

@ -17,20 +17,22 @@ type Message struct {
TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` // Время жизни сообщения в секундах
SimNumber *uint8 `json:"simNumber,omitempty" validate:"omitempty,max=3" example:"1"` // Номер сим-карты
WithDeliveryReport *bool `json:"withDeliveryReport,omitempty" example:"true"` // Запрашивать отчет о доставке
IsEncrypted bool `json:"isEncrypted,omitempty" example:"true"` // Зашифровано
PhoneNumbers []string `json:"phoneNumbers" validate:"required,min=1,max=100,dive,required,min=10" example:"79990001234"` // Получатели
}
// Состояние сообщения
type MessageState struct {
ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Идентификатор
State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние
IsHashed bool `json:"isHashed" example:"false"` // Хэшировано
Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Детализация состояния по получателям
ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Идентификатор
State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние
IsHashed bool `json:"isHashed" example:"false"` // Хэшировано
IsEncrypted bool `json:"isEncrypted" example:"false"` // Зашифровано
Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Детализация состояния по получателям
}
// Детализация состояния
type RecipientState struct {
PhoneNumber string `json:"phoneNumber" validate:"required,min=10" example:"79990001234"` // Номер телефона или первые 16 символов SHA256
State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние
Error *string `json:"error,omitempty" example:"timeout"` // Ошибка
PhoneNumber string `json:"phoneNumber" validate:"required,min=10,max=64" example:"79990001234"` // Номер телефона или первые 16 символов SHA256
State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние
Error *string `json:"error,omitempty" example:"timeout"` // Ошибка
}

View File

@ -0,0 +1,99 @@
# Encryption
The application supports end-to-end encryption by encrypting message text and recipients' phone numbers before sending them to the API and decrypting them on the device. This ensures that no one including us as the service provider, the hosting provider, or any third parties can access the content and recipients of the messages.
Please note that using encryption will increase device battery usage.
## Requirements
1. Fields `message` and every value in the `phoneNumbers` field must be encrypted.
2. The `isEncrypted` field of the message object must be set to `true`.
3. On the device, the same passphrase must be specified as in step 1.
## Algorithm
1. Select a passphrase that will be used for encryption and specify it on the device.
2. Generate a random salt, with 16 bytes being the recommended size.
3. Create a secret key using the PBKDF2 algorithm with SHA1 hash function, key size of 256 bits, and recommended iteration count of 75,000.
4. Encrypt the message text and recipients' phone numbers using the AES-256-CBC algorithm and encode the result as Base64.
5. Format result as `$aes-256-cbc/pbkdf2-sha1$i=<iteration count>$<base64 encoded salt>$<base 64 encoded encrypted data>`. The format is inspired by [PHC](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md).
Or use one of the following realization:
### [PHP](https://github.com/capcom6/android-sms-gateway-php/blob/master/src/Encryptor.php)
```php
class Encryptor {
protected string $passphrase;
protected int $iterationCount;
/**
* Encryptor constructor.
* @param string $passphrase Passphrase to use for encryption
* @param int $iterationCount Iteration count
*/
public function __construct(
string $passphrase,
int $iterationCount = 75000
) {
$this->passphrase = $passphrase;
$this->iterationCount = $iterationCount;
}
public function Encrypt(string $data): string {
$salt = $this->generateSalt();
$secretKey = $this->generateSecretKeyFromPassphrase($this->passphrase, $salt, 32, $this->iterationCount);
return sprintf(
'$aes-256-cbc/pbkdf2-sha1$i=%d$%s$%s',
$this->iterationCount,
base64_encode($salt),
openssl_encrypt($data, 'aes-256-cbc', $secretKey, 0, $salt)
);
}
public function Decrypt(string $data): string {
list($_, $algo, $paramsStr, $saltBase64, $encryptedBase64) = explode('$', $data);
if ($algo !== 'aes-256-cbc/pbkdf2-sha1') {
throw new \RuntimeException('Unsupported algorithm');
}
$params = $this->parseParams($paramsStr);
if (empty($params['i'])) {
throw new \RuntimeException('Missing iteration count');
}
$salt = base64_decode($saltBase64);
$secretKey = $this->generateSecretKeyFromPassphrase($this->passphrase, $salt, 32, intval($params['i']));
return openssl_decrypt($encryptedBase64, 'aes-256-cbc', $secretKey, 0, $salt);
}
protected function generateSalt(int $size = 16): string {
return random_bytes($size);
}
protected function generateSecretKeyFromPassphrase(
string $passphrase,
string $salt,
int $keyLength = 32,
int $iterationCount = 300000
): string {
return hash_pbkdf2('sha1', $passphrase, $salt, $iterationCount, $keyLength, true);
}
/**
* @return array<string, string>
*/
protected function parseParams(string $params): array {
$keyValuePairs = explode(',', $params);
$result = [];
foreach ($keyValuePairs as $pair) {
list($key, $value) = explode('=', $pair, 2);
$result[$key] = $value;
}
return $result;
}
}
```

View File

@ -9,10 +9,11 @@ We believe in transparency and the importance of privacy. Here's how we handle i
## Cloud Mode
- **Encrypted Communication**: Communication between the app and the cloud server is encrypted.
- **Encrypted Communication**: Communication between the app and the cloud server is encrypted using secure protocols to protect your data in transit.
- **End-to-End Encryption**: We have implemented optional AES-based end-to-end encryption to ensure that all messages and phone numbers can be encrypted before being sent to the API. This means that data is encrypted before transmission and decrypted on the user's device before sending the SMS, ensuring that no one including us as the service provider, the hosting provider, or any other party can access the content and recipients of the messages.
- **Message Handling**: If end-to-end encryption is not used, after your device confirms receipt, message content and recipients are converted into a SHA256 hash within 15 minutes, ensuring it is not stored in clear form.
- **Limited Data Sharing**: Only essential data such as the device manufacturer, model, app version, and Firebase Cloud Messaging (FCM) token is sent to the server to enable cloud functionality.
- **Message Handling**: Message content and recipient phone numbers are stored on the server only until your device confirms receipt. Afterwards, this information is converted into a SHA256 hash within 15 minutes, ensuring it is not stored in clear form.
## No Collection of Usage Statistics
- **No Tracking**: We do not collect any usage statistics, including crash reports. Your usage of the app remains private and untracked.
- **No Tracking**: We do not collect any usage statistics, including crash reports. Your usage of the app remains private and untracked.

View File

@ -3,6 +3,8 @@ site_url: https://sms.capcom.me
repo_url: https://github.com/capcom6/android-sms-gateway
theme:
name: material
features:
- content.code.copy
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
@ -30,7 +32,9 @@ nav:
- Getting Started: getting-started.md
- API: api.md
- Pricing: pricing.md
- Privacy: privacy.md
- Privacy:
- Policy: privacy/policy.md
- Encryption: privacy/encryption.md
- FAQ: faq.md
- Contributing: contributing.md
- License: license.md