From 0c0e145cce076470fd370a162801987934dc98c9 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Wed, 10 Jan 2024 23:57:51 +0700 Subject: [PATCH] Added: encryption support --- README.md | 25 +++++++++--- src/Client.php | 25 ++++++++++-- src/Domain/Message.php | 30 +++++++++++++- src/Domain/MessageState.php | 63 +++++++++++++++++++++++++++- src/Domain/RecipientState.php | 7 ++++ src/Encryptor.php | 77 +++++++++++++++++++++++++++++++++++ tests/Domain/MessageTest.php | 1 + 7 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/Encryptor.php diff --git a/README.md b/README.md index b6f91ec..02fa4db 100644 --- a/README.md +++ b/README.md @@ -26,33 +26,46 @@ Here is a simple example of how to send a message using the client: require 'vendor/autoload.php'; use AndroidSmsGateway\Client; +use AndroidSmsGateway\Encryptor; +use AndroidSmsGateway\EncryptedClient; use AndroidSmsGateway\Domain\Message; $login = 'your_login'; $password = 'your_password'; $client = new Client($login, $password); +// or +// $encryptor = new Encryptor('your_passphrase'); +// $client = new EncryptedClient($login, $password, Client::DEFAULT_URL, $httpClient, $encryptor); $message = new Message('Your message text here.', ['+1234567890']); try { $messageState = $client->Send($message); - echo "Message sent with ID: " . $messageState->ID(); + echo "Message sent with ID: " . $messageState->ID() . PHP_EOL; } catch (Exception $e) { - echo "Error sending message: " . $e->getMessage(); + echo "Error sending message: " . $e->getMessage() . PHP_EOL; + die(1); } try { $messageState = $client->GetState($messageState->ID()); - echo "Message state: " . $messageState->State(); + echo "Message state: " . $messageState->State() . PHP_EOL; } catch (Exception $e) { - echo "Error getting message state: " . $e->getMessage(); + echo "Error getting message state: " . $e->getMessage() . PHP_EOL; + die(1); } ``` -## Methods +## Clients -The `Client` class provides the following methods: +There are two clients available: + +- `Client` is used for sending SMS messages in plain text, but can also be used for sending encrypted messages by providing an `Encryptor`. + +### Methods + +Client has the following methods: * `Send(Message $message)`: Send a new SMS message. * `GetState(string $id)`: Retrieve the state of a previously sent message by its ID. diff --git a/src/Client.php b/src/Client.php index 2c511fa..fcb918b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -19,6 +19,7 @@ class Client { protected string $baseUrl; protected HttpClient $client; + protected ?Encryptor $encryptor; protected RequestFactoryInterface $requestFactory; protected StreamFactoryInterface $streamFactory; @@ -27,11 +28,13 @@ public function __construct( string $login, string $password, string $serverUrl = self::DEFAULT_URL, - ?HttpClient $client = null + ?HttpClient $client = null, + ?Encryptor $encryptor = null ) { $this->basicAuth = base64_encode($login . ':' . $password); $this->baseUrl = $serverUrl; $this->client = $client ?? HttpClientDiscovery::find(); + $this->encryptor = $encryptor; $this->requestFactory = Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); @@ -40,6 +43,10 @@ public function __construct( public function Send(Message $message): MessageState { $path = '/message'; + if (isset($this->encryptor)) { + $message = $message->Encrypt($this->encryptor); + } + $response = $this->sendRequest( 'POST', $path, @@ -49,7 +56,13 @@ public function Send(Message $message): MessageState { throw new \RuntimeException('Invalid response'); } - return MessageState::FromObject($response); + $state = MessageState::FromObject($response); + + if (isset($this->encryptor)) { + $state = $state->Decrypt($this->encryptor); + } + + return $state; } public function GetState(string $id): MessageState { @@ -63,7 +76,13 @@ public function GetState(string $id): MessageState { throw new \RuntimeException('Invalid response'); } - return MessageState::FromObject($response); + $state = MessageState::FromObject($response); + + if (isset($this->encryptor)) { + $state = $state->Decrypt($this->encryptor); + } + + return $state; } /** diff --git a/src/Domain/Message.php b/src/Domain/Message.php index c159bf8..2e7be43 100644 --- a/src/Domain/Message.php +++ b/src/Domain/Message.php @@ -2,6 +2,7 @@ namespace AndroidSmsGateway\Domain; +use AndroidSmsGateway\Encryptor; use AndroidSmsGateway\Interfaces\SerializableInterface; /** @@ -30,6 +31,10 @@ class Message implements SerializableInterface { * Request delivery report, `true` by default */ private bool $withDeliveryReport; + /** + * Is message and phones encrypted, `false` by default + */ + private bool $isEncrypted = false; /** * Phone numbers in E164 format * @var array @@ -39,13 +44,35 @@ class Message implements SerializableInterface { /** * @param array $phoneNumbers */ - public function __construct(string $message, array $phoneNumbers, ?string $id = null, ?int $ttl = null, ?int $simNumber = null, bool $withDeliveryReport = true) { + public function __construct( + string $message, + array $phoneNumbers, + ?string $id = null, + ?int $ttl = null, + ?int $simNumber = null, + bool $withDeliveryReport = true + ) { $this->id = $id; $this->message = $message; $this->ttl = $ttl; $this->simNumber = $simNumber; $this->withDeliveryReport = $withDeliveryReport; $this->phoneNumbers = $phoneNumbers; + $this->isEncrypted = false; + } + + public function Encrypt(Encryptor $encryptor): self { + if ($this->isEncrypted) { + return $this; + } + + $this->isEncrypted = true; + $this->message = $encryptor->Encrypt($this->message); + $this->phoneNumbers = array_map( + fn(string $phoneNumber) => $encryptor->Encrypt($phoneNumber), + $this->phoneNumbers + ); + return $this; } public function ToObject(): object { @@ -55,6 +82,7 @@ public function ToObject(): object { 'ttl' => $this->ttl, 'simNumber' => $this->simNumber, 'withDeliveryReport' => $this->withDeliveryReport, + 'isEncrypted' => $this->isEncrypted, 'phoneNumbers' => $this->phoneNumbers ]; } diff --git a/src/Domain/MessageState.php b/src/Domain/MessageState.php index e2696a9..594f44f 100644 --- a/src/Domain/MessageState.php +++ b/src/Domain/MessageState.php @@ -2,6 +2,7 @@ namespace AndroidSmsGateway\Domain; +use AndroidSmsGateway\Encryptor; use AndroidSmsGateway\Enums\ProcessState; /** @@ -24,30 +25,86 @@ class MessageState { */ private array $recipients; + /** + * Is message and phones hashed + * @var bool + */ + private bool $isHashed; + + /** + * Is message and phones encrypted + * @var bool + */ + private bool $isEncrypted; + /** * @param array $recipients */ - public function __construct(string $id, ProcessState $state, array $recipients) { + public function __construct( + string $id, + ProcessState $state, + array $recipients, + bool $isHashed = false, + bool $isEncrypted = false + ) { $this->id = $id; $this->state = $state; $this->recipients = $recipients; + $this->isHashed = $isHashed; + $this->isEncrypted = $isEncrypted; } + /** + * Get message ID + * @return string + */ public function ID(): string { return $this->id; } + /** + * Get message state + * @return ProcessState + */ public function State(): ProcessState { return $this->state; } /** + * Is message and phones hashed + * @return bool + */ + public function IsHashed(): bool { + return $this->isHashed; + } + + /** + * Get recipient states * @return array */ public function Recipients(): array { return $this->recipients; } + public function Decrypt(Encryptor $encryptor): self { + if ($this->isHashed) { + return $this; + } + + if (!$this->isEncrypted) { + return $this; + } + + $this->recipients = array_map( + static fn(RecipientState $recipient) => $recipient->Decrypt($encryptor), + $this->recipients + ); + + $this->isEncrypted = false; + + return $this; + } + public static function FromObject(object $obj): self { return new self( $obj->id, @@ -55,7 +112,9 @@ public static function FromObject(object $obj): self { array_map( static fn($obj) => RecipientState::FromObject($obj), $obj->recipients - ) + ), + $obj->isHashed ?? false, + $obj->isEncrypted ?? false ); } } \ No newline at end of file diff --git a/src/Domain/RecipientState.php b/src/Domain/RecipientState.php index 9e83dfc..c223cd1 100644 --- a/src/Domain/RecipientState.php +++ b/src/Domain/RecipientState.php @@ -2,6 +2,7 @@ namespace AndroidSmsGateway\Domain; +use AndroidSmsGateway\Encryptor; use AndroidSmsGateway\Enums\ProcessState; /** @@ -39,6 +40,12 @@ public function Error(): ?string { return $this->error; } + public function Decrypt(Encryptor $encryptor): self { + $this->phoneNumber = $encryptor->Decrypt($this->phoneNumber); + + return $this; + } + public static function FromObject(object $obj): self { return new self( $obj->phoneNumber, diff --git a/src/Encryptor.php b/src/Encryptor.php new file mode 100644 index 0000000..9230614 --- /dev/null +++ b/src/Encryptor.php @@ -0,0 +1,77 @@ +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 = 75000 + ): 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/tests/Domain/MessageTest.php b/tests/Domain/MessageTest.php index 9fd29f3..6bb7e96 100644 --- a/tests/Domain/MessageTest.php +++ b/tests/Domain/MessageTest.php @@ -34,6 +34,7 @@ public function testCanSerializeToObject(): void { 'ttl' => $ttl, 'simNumber' => $simNumber, 'withDeliveryReport' => $withDeliveryReport, + 'isEncrypted' => false, 'phoneNumbers' => $phoneNumbers ];