From c2614529fac4c2c8ce5aa44c99feae659261505d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jan 2024 07:06:03 +0000 Subject: [PATCH 1/2] Bump jinja2 from 3.1.2 to 3.1.3 in /web/mkdocs Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: jinja2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- web/mkdocs/Pipfile.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/mkdocs/Pipfile.lock b/web/mkdocs/Pipfile.lock index 000ce82..9594c49 100644 --- a/web/mkdocs/Pipfile.lock +++ b/web/mkdocs/Pipfile.lock @@ -263,11 +263,12 @@ }, "jinja2": { "hashes": [ - "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", - "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa", + "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90" ], + "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==3.1.2" + "version": "==3.1.3" }, "jsmin": { "hashes": [ From 86d59aabfcd211d6675c346f5d580a8c4f6f3d57 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 8 Jan 2024 15:55:14 +0700 Subject: [PATCH 2/2] Added: support for encrypted messages --- api/local.http | 18 +++- api/requests.http | 16 +++ .../mysql/20240108142043_encryption.sql | 18 ++++ internal/sms-gateway/models/models.go | 5 +- internal/sms-gateway/repositories/messages.go | 2 +- internal/sms-gateway/services/messages.go | 26 +++-- pkg/smsgateway/domain.go | 16 +-- web/mkdocs/docs/privacy/encryption.md | 99 +++++++++++++++++++ .../docs/{privacy.md => privacy/policy.md} | 9 +- web/mkdocs/mkdocs.yml | 6 +- 10 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 internal/sms-gateway/models/migrations/mysql/20240108142043_encryption.sql create mode 100644 web/mkdocs/docs/privacy/encryption.md rename web/mkdocs/docs/{privacy.md => privacy/policy.md} (52%) diff --git a/api/local.http b/api/local.http index ebf4932..85d3c6a 100644 --- a/api/local.http +++ b/api/local.http @@ -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}} diff --git a/api/requests.http b/api/requests.http index 0c6b7da..ade3f74 100644 --- a/api/requests.http +++ b/api/requests.http @@ -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}} diff --git a/internal/sms-gateway/models/migrations/mysql/20240108142043_encryption.sql b/internal/sms-gateway/models/migrations/mysql/20240108142043_encryption.sql new file mode 100644 index 0000000..adaacd9 --- /dev/null +++ b/internal/sms-gateway/models/migrations/mysql/20240108142043_encryption.sql @@ -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 \ No newline at end of file diff --git a/internal/sms-gateway/models/models.go b/internal/sms-gateway/models/models.go index 180394c..d895165 100644 --- a/internal/sms-gateway/models/models.go +++ b/internal/sms-gateway/models/models.go @@ -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)"` } diff --git a/internal/sms-gateway/repositories/messages.go b/internal/sms-gateway/repositories/messages.go index 7163988..f4c55a5 100644 --- a/internal/sms-gateway/repositories/messages.go +++ b/internal/sms-gateway/repositories/messages.go @@ -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 { diff --git a/internal/sms-gateway/services/messages.go b/internal/sms-gateway/services/messages.go index 362fae3..08a8fe9 100644 --- a/internal/sms-gateway/services/messages.go +++ b/internal/sms-gateway/services/messages.go @@ -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), } } diff --git a/pkg/smsgateway/domain.go b/pkg/smsgateway/domain.go index 96226db..46727de 100644 --- a/pkg/smsgateway/domain.go +++ b/pkg/smsgateway/domain.go @@ -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"` // Ошибка } diff --git a/web/mkdocs/docs/privacy/encryption.md b/web/mkdocs/docs/privacy/encryption.md new file mode 100644 index 0000000..1019bcd --- /dev/null +++ b/web/mkdocs/docs/privacy/encryption.md @@ -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=$$`. 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 + */ + 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; + } +} +``` \ No newline at end of file diff --git a/web/mkdocs/docs/privacy.md b/web/mkdocs/docs/privacy/policy.md similarity index 52% rename from web/mkdocs/docs/privacy.md rename to web/mkdocs/docs/privacy/policy.md index 3e4fc6c..28aff2a 100644 --- a/web/mkdocs/docs/privacy.md +++ b/web/mkdocs/docs/privacy/policy.md @@ -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. \ No newline at end of file diff --git a/web/mkdocs/mkdocs.yml b/web/mkdocs/mkdocs.yml index 6b1d868..5d9217d 100644 --- a/web/mkdocs/mkdocs.yml +++ b/web/mkdocs/mkdocs.yml @@ -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