├── src ├── Exceptions │ ├── ExpiredException.php │ ├── SecureMessageException.php │ ├── InvalidKeyException.php │ ├── HitPointLimitReachedException.php │ ├── InvalidKeyLengthException.php │ └── DecryptException.php ├── Laravel │ ├── Events │ │ ├── DecryptionFailed.php │ │ ├── HitPointLimitReached.php │ │ ├── SecureMessageExpired.php │ │ └── SecureMessageEvent.php │ ├── SecureMessageFacade.php │ ├── Database │ │ ├── SecureMessage.php │ │ └── Migrations │ │ │ └── 2018_04_16_142926_create_secure_messages_table.php │ ├── Providers │ │ └── SecureMessageServiceProvider.php │ ├── config │ │ └── secure_messages.php │ ├── Console │ │ └── Housekeeping.php │ └── Factory.php ├── Factory.php ├── Crypto.php └── SecureMessage.php ├── LICENSE.md ├── composer.json └── README.md /src/Exceptions/ExpiredException.php: -------------------------------------------------------------------------------- 1 | secureMessage = $secureMessage; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Laravel/Database/Migrations/2018_04_16_142926_create_secure_messages_table.php: -------------------------------------------------------------------------------- 1 | char('id', 32)->unique()->index(); 16 | $table->text('meta'); 17 | $table->text('content'); 18 | $table->text('key'); 19 | $table->timestamp('created_at'); 20 | $table->timestamp('updated_at')->nullable(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('secure_messages'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Exonet 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. -------------------------------------------------------------------------------- /src/Exceptions/DecryptException.php: -------------------------------------------------------------------------------- 1 | secureMessage = $crypto->encrypt($secureMessage); 28 | $this->secureMessage->wipeKeysFromMemory(); 29 | } 30 | 31 | parent::__construct($exceptionMessage); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exonet/securemessage", 3 | "description": "Encrypt and decrypt messages in a secure way.", 4 | "type": "library", 5 | "require-dev": { 6 | "mockery/mockery": "^1.4", 7 | "phpunit/phpunit": "^9" 8 | }, 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Exonet B.V.", 13 | "email": "development@exonet.nl" 14 | } 15 | ], 16 | "require": { 17 | "php": "^7.3|^8.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Exonet\\SecureMessage\\": "src" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "Exonet\\SecureMessage\\": "tests" 27 | } 28 | }, 29 | "scripts": { 30 | "test": "phpunit --testdox tests/" 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Exonet\\SecureMessage\\Laravel\\Providers\\SecureMessageServiceProvider" 39 | ], 40 | "aliases": { 41 | "SecureMessage": "Exonet\\SecureMessage\\Laravel\\SecureMessageFacade" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Laravel/Providers/SecureMessageServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 17 | __DIR__.'/../config/secure_messages.php' => config_path('secure_messages.php'), 18 | ], 'config'); 19 | } 20 | 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function register(): void 25 | { 26 | // Load the migration and the config. 27 | $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); 28 | $this->mergeConfigFrom(__DIR__.'/../config/secure_messages.php', 'secure_messages'); 29 | 30 | // Register the housekeeping command. 31 | $this->commands([Housekeeping::class]); 32 | 33 | // Create a container binding (used by the facade). 34 | $this->app->bind('secureMessage', function () { 35 | return $this->app->make(LaravelSecureMessageFactory::class); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Laravel/config/secure_messages.php: -------------------------------------------------------------------------------- 1 | 'secure_messages', 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Meta data encryption key. 18 | |-------------------------------------------------------------------------- 19 | | 20 | | This key is being used to encrypt the meta data of a secure message (the 21 | | expire date and hit points). This string MUST be 32 characters long. 22 | | 23 | | PLEASE NOTE: if you change this key while there are non-expired secure 24 | | messages, those messages CAN NOT be decrypted! 25 | | 26 | */ 27 | 'meta_key' => env('SECURE_MESSAGE_META_KEY', 'ChangeThis'), 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Hit Points 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Here you can specify how many times a wrong verification code can be 35 | | entered. 36 | | 37 | */ 38 | 'hit_points' => 3, 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Default expire date 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Here you can specify after how many days a message will expire, if no 46 | | expire date is specified. 47 | | 48 | */ 49 | 'expires_in' => 10, 50 | ]; 51 | -------------------------------------------------------------------------------- /src/Laravel/Console/Housekeeping.php: -------------------------------------------------------------------------------- 1 | pluck('id') 31 | ->each(function (string $secureMessageId) use ($secureMessageFactory) { 32 | $meta = $secureMessageFactory->getMeta($secureMessageId); 33 | $meta->wipeKeysFromMemory(); 34 | 35 | // If there are no more hit points left, or the message is expired, destroy it. 36 | if ($meta->getHitPoints() <= 0 || $meta->getExpiresAt() < time()) { 37 | $secureMessageFactory->destroy($secureMessageId); 38 | 39 | // Output info if requested. 40 | if ($this->getOutput()->isVerbose()) { 41 | $this->info(sprintf('Destroyed secure message [%s]', $secureMessageId)); 42 | } 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecureMessage 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Total Downloads][ico-downloads]][link-downloads] 6 | 7 | This package makes it possible to create (very) secure messages and store them in, for example, your database. A secure 8 | message is encrypted with a combination of three key 'parts': 9 | - A "database key" - to be saved in a database. 10 | - A "storage key" - to be stored on a disk/filesystem. 11 | - A "verification code" - this code should _not_ be stored anywhere. 12 | 13 | This way, if an attacker has access to the database, it still has only access to a small part of the complete key. The 14 | same goes if an attacker has access to the file storage. Even if an attacker has access to the database _and_ the file 15 | storage, a part of the complete key is still missing. 16 | 17 | The verification code can be sent (securely) to the receiver of the secure message and with this code, it can decrypt the 18 | message and read it. 19 | 20 | ## Requirements 21 | This package requires at least PHP 7.3 with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled. 22 | 23 | ## Install 24 | 25 | Via Composer 26 | 27 | ``` bash 28 | $ composer require exonet/securemessage 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```php 34 | // Create the factory. 35 | $secureMessageFactory = new Exonet\SecureMessage\Factory(); 36 | // Set the (application wide) meta key. 37 | $secureMessageFactory->setMetaKey('A_10_random_characters_long_key.'); 38 | 39 | // Create a new SecureMessage. Note: it is not encrypted yet! 40 | $secureMessage = $secureMessageFactory->make('Hello, world!'); 41 | // Encrypt the Secure Message. 42 | $encryptedMessage = $secureMessage->encrypt(); 43 | ``` 44 | 45 | Please see the `/docs` folder for complete documentation and additional examples. 46 | 47 | ## Change log 48 | 49 | Please see [releases][link-releases] for more information on what has changed recently. 50 | 51 | ## Testing 52 | 53 | ``` bash 54 | $ composer test 55 | ``` 56 | 57 | ## Contributing 58 | 59 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) and [CODE_OF_CONDUCT](.github/CODE_OF_CONDUCT.md) for details. 60 | 61 | ## Security 62 | 63 | If you discover any security related issues please email [development@exonet.nl](mailto:development@exonet.nl) instead of using 64 | the issue tracker. 65 | 66 | ## Credits 67 | 68 | - [Exonet][link-author] 69 | - [All Contributors][link-contributors] 70 | 71 | ## License 72 | 73 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 74 | 75 | [ico-version]: https://img.shields.io/packagist/v/exonet/securemessage.svg?style=flat-square 76 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 77 | [ico-downloads]: https://img.shields.io/packagist/dt/exonet/securemessage.svg?style=flat-square 78 | 79 | [link-packagist]: https://packagist.org/packages/exonet/securemessage 80 | [link-downloads]: https://packagist.org/packages/exonet/securemessage 81 | [link-author]: https://github.com/exonet 82 | [link-releases]: https://github.com/exonet/securemessage/releases 83 | [link-contributors]: ../../contributors 84 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | setMetaKey($metaKey); 39 | } 40 | 41 | $this->setCryptoInstance($crypto ?? new Crypto()); 42 | } 43 | 44 | /** 45 | * Make a new secure message factory. 46 | * 47 | * @param string $content The content to encrypt. 48 | * @param int $hitPoints The number of hit points. 49 | * @param int $expiresAt The expire timestamp. 50 | * 51 | * @return Factory The new (yet unencrypted) secure message factory. 52 | */ 53 | public function make(string $content, int $hitPoints = 3, ?int $expiresAt = null): self 54 | { 55 | $factory = $this->createFactoryInstance(); 56 | 57 | $message = new SecureMessage(); 58 | $message->setId($this->generateId()); 59 | $message->setContent($content); 60 | $message->setHitPoints($hitPoints); 61 | $message->setExpiresAt($expiresAt ?? time() + self::DEFAULT_EXPIRE); 62 | 63 | $factory->secureMessage = $message; 64 | 65 | return $factory; 66 | } 67 | 68 | /** 69 | * Perform the actual encryption on the given secure message or the secure message of the current factory. A 70 | * SecureMessage instance with the encrypted content and keys are returned. Please note that when a SecureMessage 71 | * is passed the keys that are already set will be overwritten with new ones. 72 | * 73 | * @param SecureMessage|null $secureMessage If set encrypt the given SecureMessage instance. 74 | * 75 | * @return SecureMessage The encrypted secure message, including the encryption keys. 76 | */ 77 | public function encrypt(?SecureMessage $secureMessage = null): SecureMessage 78 | { 79 | $message = $secureMessage ?? $this->secureMessage; 80 | $keys = $this->generateKeys(); 81 | 82 | $message->setStorageKey($keys['storage_key']); 83 | $message->setDatabaseKey($keys['database_key']); 84 | $message->setVerificationCode($keys['verification_code']); 85 | $message->setMetaKey($this->metaKey); 86 | 87 | return $this->crypto->encrypt($message); 88 | } 89 | 90 | /** 91 | * Decrypt the given secure message. Make sure all keys are set. 92 | * 93 | * @param SecureMessage $secureMessage The secure message to decrypt. 94 | * 95 | * @throws Exceptions\DecryptException If the message can not be decrypted. 96 | * 97 | * @return SecureMessage The decrypted secure message. 98 | */ 99 | public function decrypt(SecureMessage $secureMessage): SecureMessage 100 | { 101 | $secureMessage->setMetaKey($this->metaKey); 102 | 103 | return $this->crypto->decrypt($secureMessage); 104 | } 105 | 106 | /** 107 | * Decrypt the meta data of given secure message. Make sure the meta key is set. 108 | * 109 | * @param SecureMessage $secureMessage The secure message to decrypt. 110 | * 111 | * @throws Exceptions\DecryptException If the message can not be decrypted. 112 | * 113 | * @return SecureMessage The decrypted secure message. 114 | */ 115 | public function decryptMeta(SecureMessage $secureMessage): SecureMessage 116 | { 117 | $secureMessage->setMetaKey($this->metaKey); 118 | 119 | return $this->crypto->decryptMeta($secureMessage); 120 | } 121 | 122 | /** 123 | * Check if the set encryption key can be used to decrypt te message. 124 | * 125 | * @param SecureMessage $secureMessage The secure message to decrypt. 126 | * 127 | * @return bool Whether or not the encryption key is valid. 128 | */ 129 | public function validateEncryptionKey(SecureMessage $secureMessage): bool 130 | { 131 | $secureMessage->setMetaKey($this->metaKey); 132 | 133 | return $this->crypto->validateEncryptionKey($secureMessage); 134 | } 135 | 136 | /** 137 | * Set the key to encrypt and decrypt the meta data. Must be 10 characters. 138 | * 139 | * @param string $key The key. 140 | * 141 | * @throws InvalidKeyLengthException If the key isn't 10 characters long. 142 | * 143 | * @return $this The current factory instance. 144 | */ 145 | public function setMetaKey(string $key): self 146 | { 147 | if (strlen($key) !== 10) { 148 | throw new InvalidKeyLengthException('The meta key must be 10 characters.'); 149 | } 150 | 151 | $this->metaKey = $key; 152 | 153 | return $this; 154 | } 155 | 156 | /** 157 | * Set the crypto instance to use. 158 | * 159 | * @param Crypto $crypto The crypto instance to use. 160 | * 161 | * @return $this The current factory instance. 162 | */ 163 | public function setCryptoInstance(Crypto $crypto): self 164 | { 165 | $this->crypto = $crypto; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Create a new factory instance. 172 | * 173 | * @return static The new factory instance. 174 | */ 175 | protected function createFactoryInstance() 176 | { 177 | return new static($this->metaKey); 178 | } 179 | 180 | /** 181 | * Generate an ID for the secure message. 182 | * 183 | * @return string The ID to use. 184 | */ 185 | protected function generateId(): string 186 | { 187 | return strtoupper(substr(sha1(random_bytes(24)), 0, 32)); 188 | } 189 | 190 | /** 191 | * Create three keys (with a length of 32 bytes in total) that will be used to encrypt the message. The keys are 192 | * divided in three parts, so they can be stored at three different locations. 193 | * 194 | * @return array The keys to use as encryption key. 195 | */ 196 | protected function generateKeys(): array 197 | { 198 | $verificationCode = substr(sha1(random_bytes(10)), 0, 10); 199 | $storageKey = random_bytes(11); 200 | $databaseKey = random_bytes(11); 201 | 202 | return [ 203 | 'storage_key' => $storageKey, 204 | 'database_key' => $databaseKey, 205 | 'verification_code' => $verificationCode, 206 | ]; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Crypto.php: -------------------------------------------------------------------------------- 1 | isMetaEncrypted() === false) { 26 | if (strlen($secureMessage->getMetaKey()) !== 32) { 27 | throw new InvalidKeyLengthException('The key must be 32 bytes/characters.'); 28 | } 29 | 30 | $metaNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); 31 | $encryptedMeta = sodium_crypto_secretbox( 32 | json_encode($secureMessage->getMeta()), 33 | $metaNonce, 34 | $secureMessage->getMetaKey() 35 | ); 36 | 37 | $secureMessage->setEncryptedMeta($this->toString($metaNonce, $encryptedMeta)); 38 | } 39 | 40 | if ($secureMessage->isContentEncrypted() === false) { 41 | if (strlen($secureMessage->getEncryptionKey()) !== 32) { 42 | throw new InvalidKeyLengthException('The key must be 32 bytes/characters.'); 43 | } 44 | 45 | $contentNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); 46 | $encryptedContent = sodium_crypto_secretbox( 47 | $secureMessage->getContent(), 48 | $contentNonce, 49 | $secureMessage->getEncryptionKey() 50 | ); 51 | $secureMessage->setEncryptedContent($this->toString($contentNonce, $encryptedContent)); 52 | $secureMessage->wipeContentFromMemory(); 53 | } 54 | 55 | return $secureMessage; 56 | } 57 | 58 | /** 59 | * Decrypt the content of a secure message. 60 | * 61 | * @param SecureMessage $secureMessage The secure message to decrypt. 62 | * 63 | * @throws DecryptException When the content can not be decrypted. 64 | * @throws ExpiredException When the secure message is expired. 65 | * @throws HitPointLimitReachedException When the secure message hit point limit is reached. 66 | * 67 | * @return SecureMessage The decrypted SecureMessage. 68 | */ 69 | public function decrypt(SecureMessage $secureMessage): SecureMessage 70 | { 71 | // If the meta isn't decrypted yet, decrypt it now. 72 | if ($secureMessage->isMetaEncrypted()) { 73 | $secureMessage = $this->decryptMeta($secureMessage); 74 | } 75 | 76 | if (strlen($secureMessage->getEncryptionKey()) !== 32) { 77 | $secureMessage = $this->reduceHitPoints($secureMessage); 78 | 79 | throw new DecryptException('Invalid key length.', $secureMessage); 80 | } 81 | 82 | // Check if the message is expired. 83 | if (time() > $secureMessage->getExpiresAt()) { 84 | throw new ExpiredException('This secure message is expired.', $secureMessage); 85 | } 86 | 87 | // Check if the message is out of hit points. 88 | if ($secureMessage->getHitPoints() <= 0) { 89 | $this->reduceHitPoints($secureMessage); 90 | } 91 | 92 | $contentParts = $this->fromString($secureMessage->getEncryptedContent()); 93 | $content = sodium_crypto_secretbox_open( 94 | $contentParts['data'], 95 | $contentParts['nonce'], 96 | $secureMessage->getEncryptionKey() 97 | ); 98 | 99 | if ($content === false) { 100 | $secureMessage = $this->reduceHitPoints($secureMessage); 101 | 102 | throw new DecryptException('Unable to or failed decrypt the contents of the message.', $secureMessage); 103 | } 104 | 105 | $secureMessage->setContent($content); 106 | $secureMessage->wipeEncryptedContentFromMemory(); 107 | $secureMessage->wipeKeysFromMemory(); 108 | 109 | return $secureMessage; 110 | } 111 | 112 | /** 113 | * Check if the encryption key can be used to decrypt the message. 114 | * 115 | * @param SecureMessage $secureMessage 116 | * 117 | * @return bool Whether or not the encryption key is valid. 118 | */ 119 | public function validateEncryptionKey(SecureMessage $secureMessage): bool 120 | { 121 | if (strlen($secureMessage->getEncryptionKey()) !== 32 || strlen($secureMessage->getMetaKey()) !== 32) { 122 | return false; 123 | } 124 | 125 | $contentParts = $this->fromString($secureMessage->getEncryptedContent()); 126 | // There's no other way to check if a key is valid than decrypting the content. 127 | $content = sodium_crypto_secretbox_open( 128 | $contentParts['data'], 129 | $contentParts['nonce'], 130 | $secureMessage->getEncryptionKey() 131 | ); 132 | 133 | $keyIsValid = $content !== false; 134 | 135 | // Remove the decrypted data from memory. 136 | if (is_string($content)) { 137 | sodium_memzero($content); 138 | } 139 | 140 | return $keyIsValid; 141 | } 142 | 143 | /** 144 | * Decrypt the meta data of secure message. Make sure the encrypted meta field and the meta key are set. 145 | * 146 | * @param SecureMessage $secureMessage The secure message to decrypt. 147 | * 148 | * @throws DecryptException When the content can not be decrypted. 149 | * 150 | * @return SecureMessage The decrypted SecureMessage. 151 | */ 152 | public function decryptMeta(SecureMessage $secureMessage): SecureMessage 153 | { 154 | if (!$secureMessage->isMetaEncrypted() || strlen($secureMessage->getMetaKey()) !== 32) { 155 | throw new DecryptException('Unable to or failed to decrypt the meta data.', $secureMessage); 156 | } 157 | 158 | $metaParts = $this->fromString($secureMessage->getEncryptedMeta()); 159 | $metaData = sodium_crypto_secretbox_open( 160 | $metaParts['data'], 161 | $metaParts['nonce'], 162 | $secureMessage->getMetaKey() 163 | ); 164 | 165 | if ($metaData === false) { 166 | throw new DecryptException('Unable to or failed to decrypt the meta data.', $secureMessage); 167 | } 168 | 169 | $secureMessage->setMeta(json_decode($metaData, true)); 170 | $secureMessage->wipeEncryptedMetaFromMemory(); 171 | 172 | return $secureMessage; 173 | } 174 | 175 | /** 176 | * Merge the used nonce and the encrypted content into a single base64 string. 177 | * 178 | * @param string $nonce The nonce that was used. 179 | * @param string $encryptedContent The encrypted content. 180 | * 181 | * @return string The merged string. 182 | */ 183 | private function toString(string $nonce, string $encryptedContent): string 184 | { 185 | return base64_encode(json_encode([base64_encode($nonce), base64_encode($encryptedContent)])); 186 | } 187 | 188 | /** 189 | * Transform the base64 encoded string back to a nonce and encrypted message. 190 | * 191 | * @param string $content The base64 string as generated by $this->toString(). 192 | * 193 | * @return string[] The nonce and the encrypted message. 194 | */ 195 | private function fromString(string $content): array 196 | { 197 | [$nonce, $message] = json_decode(base64_decode($content, true)); 198 | 199 | return ['nonce' => base64_decode($nonce, true), 'data' => base64_decode($message, true)]; 200 | } 201 | 202 | /** 203 | * Reduce the number of hit points with 1 in the given secure message. If the new value is 0, a 204 | * HitPointLimitReachedException is thrown. 205 | * 206 | * @param SecureMessage $secureMessage The secure message. 207 | * 208 | * @throws HitPointLimitReachedException If there are no hit points remaining. 209 | * 210 | * @return SecureMessage The secure message with the hit points reduces by 1. 211 | */ 212 | private function reduceHitPoints(SecureMessage $secureMessage): SecureMessage 213 | { 214 | $secureMessage->setHitPoints($secureMessage->getHitPoints() - 1); 215 | if ($secureMessage->getHitPoints() <= 0) { 216 | throw new HitPointLimitReachedException( 217 | 'The maximum number of hit points has been reached.', 218 | $secureMessage 219 | ); 220 | } 221 | 222 | return $secureMessage; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/SecureMessage.php: -------------------------------------------------------------------------------- 1 | null, 'storage' => null, 'verification' => null, 'meta' => null]; 16 | 17 | /** 18 | * @var string The message content. Can be plain text or encrypted. 19 | */ 20 | private $content; 21 | 22 | /** 23 | * @var string The encrypted version of the content. 24 | */ 25 | private $contentEncrypted; 26 | 27 | /** 28 | * @var int[] The meta data for this secure message. 29 | */ 30 | private $meta = ['hit_points' => null, 'expires_at' => null]; 31 | 32 | /** 33 | * @var string[] The encrypted version of the meta. 34 | */ 35 | private $metaEncrypted; 36 | 37 | /** 38 | * Wipe the sensitive keys from memory. 39 | * 40 | * @param bool $wipeVerificationCode When true, wipe also the verification code. 41 | */ 42 | public function wipeKeysFromMemory(bool $wipeVerificationCode = true): void 43 | { 44 | if ($this->keys['database'] !== null) { 45 | sodium_memzero($this->keys['database']); 46 | } 47 | 48 | if ($this->keys['storage'] !== null) { 49 | sodium_memzero($this->keys['storage']); 50 | } 51 | 52 | if ($this->keys['meta'] !== null) { 53 | sodium_memzero($this->keys['meta']); 54 | } 55 | 56 | if ($wipeVerificationCode && $this->keys['verification'] !== null) { 57 | sodium_memzero($this->keys['verification']); 58 | } 59 | } 60 | 61 | /** 62 | * Wipe the plain text content from memory. 63 | */ 64 | public function wipeContentFromMemory(): void 65 | { 66 | if ($this->content !== null) { 67 | sodium_memzero($this->content); 68 | } 69 | } 70 | 71 | /** 72 | * Wipe the encrypted content from memory. 73 | */ 74 | public function wipeEncryptedContentFromMemory(): void 75 | { 76 | if ($this->contentEncrypted !== null) { 77 | sodium_memzero($this->contentEncrypted); 78 | } 79 | } 80 | 81 | /** 82 | * Wipe the encrypted meta from memory. 83 | */ 84 | public function wipeEncryptedMetaFromMemory(): void 85 | { 86 | if ($this->metaEncrypted !== null) { 87 | sodium_memzero($this->metaEncrypted); 88 | } 89 | } 90 | 91 | /** 92 | * Get the encryption key. 93 | * 94 | * @return string The encryption key. 95 | */ 96 | public function getEncryptionKey(): string 97 | { 98 | return $this->getDatabaseKey().$this->getStorageKey().$this->getVerificationCode(); 99 | } 100 | 101 | /** 102 | * Get the meta key. 103 | * 104 | * @return string|null The meta key. 105 | */ 106 | public function getMetaKey(): ?string 107 | { 108 | if ($this->getDatabaseKey() !== null && $this->getStorageKey() !== null && $this->keys['meta'] !== null) { 109 | return $this->getDatabaseKey().$this->getStorageKey().$this->keys['meta']; 110 | } 111 | 112 | return null; 113 | } 114 | 115 | /** 116 | * Set the meta key. 117 | * 118 | * @param string $key The meta key. 119 | * 120 | * @return $this The current secure message instance. 121 | */ 122 | public function setMetaKey(string $key): self 123 | { 124 | $this->keys['meta'] = $key; 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * Get the secure message ID. 131 | * 132 | * @return string|null The secure message ID. 133 | */ 134 | public function getId(): ?string 135 | { 136 | return $this->id; 137 | } 138 | 139 | /** 140 | * Set the secure message ID. 141 | * 142 | * @param string $id The secure message ID. 143 | * 144 | * @return $this The current secure message instance. 145 | */ 146 | public function setId(string $id): self 147 | { 148 | $this->id = $id; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Check if the content is already encrypted. 155 | * 156 | * @return bool True when the content is encrypted. 157 | */ 158 | public function isContentEncrypted(): bool 159 | { 160 | return $this->contentEncrypted !== null; 161 | } 162 | 163 | /** 164 | * Set the boolean indicating the content is encrypted. 165 | * 166 | * @param string $encrypted The encrypted content. 167 | * 168 | * @return $this The current secure message instance. 169 | */ 170 | public function setEncryptedContent(string $encrypted): self 171 | { 172 | $this->contentEncrypted = $encrypted; 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * Get the encrypted content data of this message. 179 | * 180 | * @return string|null The encrypted content data. 181 | */ 182 | public function getEncryptedContent(): ?string 183 | { 184 | return $this->contentEncrypted; 185 | } 186 | 187 | /** 188 | * Check if the meta is already encrypted. 189 | * 190 | * @return bool True when the meta is encrypted. 191 | */ 192 | public function isMetaEncrypted(): bool 193 | { 194 | return $this->metaEncrypted !== null; 195 | } 196 | 197 | /** 198 | * Set the boolean indicating the meta is encrypted. 199 | * 200 | * @param string $encrypted The encrypted meta data. 201 | * 202 | * @return $this The current secure message instance. 203 | */ 204 | public function setEncryptedMeta(string $encrypted): self 205 | { 206 | $this->metaEncrypted = $encrypted; 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * Get the encrypted meta data of this message. 213 | * 214 | * @return string|null The encrypted meta data. 215 | */ 216 | public function getEncryptedMeta(): ?string 217 | { 218 | return $this->metaEncrypted; 219 | } 220 | 221 | /** 222 | * Get the content. Can be encrypted or unencrypted. 223 | * 224 | * @return string|null The content. 225 | */ 226 | public function getContent(): ?string 227 | { 228 | return $this->content; 229 | } 230 | 231 | /** 232 | * Set the content. Can be encrypted or unencrypted. Don't forget to also set the 'encrypted' boolean when updating 233 | * this value. 234 | * 235 | * @param string $content The content. 236 | * 237 | * @return $this The current secure message instance. 238 | */ 239 | public function setContent(string $content): self 240 | { 241 | $this->content = $content; 242 | 243 | return $this; 244 | } 245 | 246 | /** 247 | * Get the verification code. 248 | * 249 | * @return string|null The verification code. 250 | */ 251 | public function getVerificationCode(): ?string 252 | { 253 | return $this->keys['verification']; 254 | } 255 | 256 | /** 257 | * Set the verification code. 258 | * 259 | * @param string $verificationCode The verification code. 260 | * 261 | * @return $this The current secure message instance. 262 | */ 263 | public function setVerificationCode(string $verificationCode): self 264 | { 265 | $this->keys['verification'] = $verificationCode; 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * Get the storage key. 272 | * 273 | * @return string|null The storage key. 274 | */ 275 | public function getStorageKey(): ?string 276 | { 277 | return $this->keys['storage']; 278 | } 279 | 280 | /** 281 | * Set the storage key. 282 | * 283 | * @param string $storageKey The storage key. 284 | * 285 | * @return $this The current secure message instance. 286 | */ 287 | public function setStorageKey(string $storageKey): self 288 | { 289 | $this->keys['storage'] = $storageKey; 290 | 291 | return $this; 292 | } 293 | 294 | /** 295 | * Get the database key. 296 | * 297 | * @return string|null The database key. 298 | */ 299 | public function getDatabaseKey(): ?string 300 | { 301 | return $this->keys['database']; 302 | } 303 | 304 | /** 305 | * Set the database key. 306 | * 307 | * @param string $databaseKey The database key. 308 | * 309 | * @return $this The current secure message instance. 310 | */ 311 | public function setDatabaseKey(string $databaseKey): self 312 | { 313 | $this->keys['database'] = $databaseKey; 314 | 315 | return $this; 316 | } 317 | 318 | /** 319 | * Set the maximum number of hit points. 320 | * 321 | * @param int $hitPoints The number of hit points. 322 | * 323 | * @return $this The current secure message instance. 324 | */ 325 | public function setHitPoints(int $hitPoints): self 326 | { 327 | $this->meta['hit_points'] = $hitPoints; 328 | 329 | return $this; 330 | } 331 | 332 | /** 333 | * Get the maximum number of hit points. 334 | * 335 | * @return int The maximum number of hit points. 336 | */ 337 | public function getHitPoints(): int 338 | { 339 | return $this->meta['hit_points']; 340 | } 341 | 342 | /** 343 | * Set the expire date of this message. 344 | * 345 | * @param int $expiresAt The timestamp when this message expires. 346 | * 347 | * @return $this The current secure message instance. 348 | */ 349 | public function setExpiresAt(int $expiresAt): self 350 | { 351 | $this->meta['expires_at'] = $expiresAt; 352 | 353 | return $this; 354 | } 355 | 356 | /** 357 | * Get the expire date of this message. 358 | * 359 | * @return int The timestamp when this message expires. 360 | */ 361 | public function getExpiresAt(): int 362 | { 363 | return $this->meta['expires_at']; 364 | } 365 | 366 | /** 367 | * Get all meta data for this message. 368 | * 369 | * @return mixed[] The meta data. 370 | */ 371 | public function getMeta(): array 372 | { 373 | return $this->meta; 374 | } 375 | 376 | /** 377 | * Set all meta data for this message. 378 | * 379 | * @param int[] The meta data. 380 | * 381 | * @return $this The current secure message instance. 382 | */ 383 | public function setMeta(array $metaData): self 384 | { 385 | $this->meta = $metaData; 386 | 387 | return $this; 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /src/Laravel/Factory.php: -------------------------------------------------------------------------------- 1 | secureMessageFactory = $secureMessageFactory->setMetaKey($config->get('secure_messages.meta_key')); 68 | $this->storage = $storage->disk($config->get('secure_messages.storage_disk_name')); 69 | $this->laravelEncryption = $laravelEncryption; 70 | $this->config = $config; 71 | $this->event = $event; 72 | } 73 | 74 | /** 75 | * Encrypt the given content and get a SecureMessage with the verification code available (all other keys are 76 | * removed from the class). 77 | * 78 | * @param string $content The content to store secure. 79 | * @param Carbon|null $expireDate The expire date of the secure message. (Optional) 80 | * 81 | * @return SecureMessage The secure message. 82 | */ 83 | public function encrypt(string $content, ?Carbon $expireDate = null, ?int $hitPoints = null): SecureMessage 84 | { 85 | // Get a Carbon instance with the expire date, based on the argument or on the config setting. 86 | $carbonExpire = $expireDate ?? Carbon::now()->addDays($this->config->get('secure_messages.expires_in')); 87 | $hitPoints = $hitPoints ?? $this->config->get('secure_messages.hit_points'); 88 | 89 | // Create the secure message. 90 | $encryptedData = $this->secureMessageFactory 91 | ->make($content, $hitPoints, $carbonExpire->timestamp) 92 | ->encrypt(); 93 | 94 | // Encrypt the 'storage key' part and save it to the defined storage disk. 95 | $this->storage->put( 96 | $encryptedData->getId(), 97 | $this->laravelEncryption->encrypt($encryptedData->getStorageKey()) 98 | ); 99 | 100 | // Save the secure message (encrypted) to the database. 101 | $record = new SecureMessageModel(); 102 | $record->id = $encryptedData->getId(); 103 | $record->meta = $this->laravelEncryption->encrypt($encryptedData->getEncryptedMeta()); 104 | $record->content = $this->laravelEncryption->encrypt($encryptedData->getEncryptedContent()); 105 | $record->key = $this->laravelEncryption->encrypt($encryptedData->getDatabaseKey()); 106 | $record->created_at = Carbon::now(); 107 | $record->updated_at = Carbon::now(); 108 | $record->save(); 109 | 110 | // Wipe the keys from memory, but keep the verification code. 111 | $encryptedData->wipeKeysFromMemory(false); 112 | 113 | return $encryptedData; 114 | } 115 | 116 | /** 117 | * Return the decrypted content of the secure message for the given message ID. 118 | * 119 | * @param string $secureMessageId The secure message ID. 120 | * @param string $verificationCode The verification code for the secure message. 121 | * 122 | * @throws DecryptException If the secure message can not be encrypted. 123 | * 124 | * @return string The contents of the secure message. 125 | */ 126 | public function decrypt(string $secureMessageId, string $verificationCode): ?string 127 | { 128 | return $this->decryptMessage($secureMessageId, $verificationCode)->getContent(); 129 | } 130 | 131 | /** 132 | * Return the decrypted SecureMessage class. If the hit point limit is reached, the message is expired, the 133 | * verification code is wrong or the file containing the storage key can not be found, a DecryptException is thrown. 134 | * In case of the hit point limit or expired message, the corresponding events are dispatched. For all other errors, 135 | * the more generic 'DecryptionFailed' event is dispatched. 136 | * 137 | * @param string $secureMessageId The secure message ID. 138 | * @param string $verificationCode The verification code for the secure message. 139 | * 140 | * @throws DecryptException If the secure message can not be encrypted. 141 | * 142 | * @return SecureMessage The decrypted secure message, with the keys removed. 143 | */ 144 | public function decryptMessage(string $secureMessageId, string $verificationCode): SecureMessage 145 | { 146 | // Get the secure message from the database. 147 | $record = SecureMessageModel::where('id', $secureMessageId)->firstOrFail(); 148 | 149 | // Build the SecureMessage as required by the Crypto utility. 150 | $secureMessage = new SecureMessage(); 151 | $secureMessage->setId($record->id); 152 | $secureMessage->setVerificationCode($verificationCode); 153 | $secureMessage->setDatabaseKey($this->laravelEncryption->decrypt($record->key)); 154 | $secureMessage->setEncryptedMeta($this->laravelEncryption->decrypt($record->meta)); 155 | $secureMessage->setEncryptedContent($this->laravelEncryption->decrypt($record->content)); 156 | 157 | try { 158 | // Check if the storage key file exists. 159 | if (!$this->storage->exists($record->id)) { 160 | throw new DecryptException('Can not find key file.'); 161 | } 162 | 163 | // Read and set the storage key. 164 | $secureMessage->setStorageKey($this->laravelEncryption->decrypt($this->storage->get($record->id))); 165 | 166 | // Try decrypting the secure message. 167 | return $this->secureMessageFactory->decrypt($secureMessage); 168 | } catch (DecryptException $exception) { 169 | // Catch the exception and update the secure message, if it is set. 170 | if ($exception->secureMessage !== null) { 171 | $record->meta = $this->laravelEncryption->encrypt($exception->secureMessage->getEncryptedMeta()); 172 | $record->save(); 173 | } 174 | 175 | // Dispatch events. 176 | switch (get_class($exception)) { 177 | case HitPointLimitReachedException::class: 178 | $this->event->dispatch(new HitPointLimitReached($secureMessage)); 179 | 180 | break; 181 | 182 | case ExpiredException::class: 183 | $this->event->dispatch(new SecureMessageExpired($secureMessage)); 184 | 185 | break; 186 | 187 | default: 188 | $this->event->dispatch(new DecryptionFailed($secureMessage)); 189 | 190 | break; 191 | } 192 | 193 | // And throw the exception again, so the user can catch it. 194 | throw $exception; 195 | } 196 | } 197 | 198 | /** 199 | * @param string $secureMessageId The secure message ID. 200 | * @param string $verificationCode The verification code for the secure message. 201 | * 202 | * @throws DecryptException If the storage key file can not be found. 203 | * 204 | * @return bool Whether or not the verification code is valid. 205 | */ 206 | public function checkVerificationCode(string $secureMessageId, string $verificationCode): bool 207 | { 208 | // Get the secure message from the database. 209 | $record = SecureMessageModel::where('id', $secureMessageId)->firstOrFail(); 210 | 211 | // Build the SecureMessage as required by the Crypto utility. 212 | $secureMessage = new SecureMessage(); 213 | $secureMessage->setId($record->id); 214 | $secureMessage->setVerificationCode($verificationCode); 215 | $secureMessage->setDatabaseKey($this->laravelEncryption->decrypt($record->key)); 216 | $secureMessage->setEncryptedMeta($this->laravelEncryption->decrypt($record->meta)); 217 | $secureMessage->setEncryptedContent($this->laravelEncryption->decrypt($record->content)); 218 | 219 | // Check if the storage key file exists. 220 | if (!$this->storage->exists($record->id)) { 221 | throw new DecryptException('Can not find key file.'); 222 | } 223 | 224 | // Read and set the storage key. 225 | $secureMessage->setStorageKey($this->laravelEncryption->decrypt($this->storage->get($record->id))); 226 | 227 | return $this->secureMessageFactory->validateEncryptionKey($secureMessage); 228 | } 229 | 230 | /** 231 | * Get only the meta data of the secure message. Useful to check the hit points or expire date. 232 | * 233 | * @param string $secureMessageId The secure message ID. 234 | * 235 | * @throws DecryptException If the secure message can not be encrypted. 236 | * 237 | * @return SecureMessage The secure message with only the (decrypted) meta. 238 | */ 239 | public function getMeta(string $secureMessageId): SecureMessage 240 | { 241 | // Get the secure message from the database. 242 | $record = SecureMessageModel::where('id', $secureMessageId)->firstOrFail(); 243 | 244 | // Build the SecureMessage as required by the Crypto utility, but only with the data for decrypting the meta. 245 | $secureMessage = new SecureMessage(); 246 | $secureMessage->setId($record->id); 247 | $secureMessage->setDatabaseKey($this->laravelEncryption->decrypt($record->key)); 248 | $secureMessage->setEncryptedMeta($this->laravelEncryption->decrypt($record->meta)); 249 | 250 | // Check if the storage key file exists. 251 | if (!$this->storage->exists($record->id)) { 252 | throw new DecryptException('Can not find key file.'); 253 | } 254 | 255 | // Read and set the storage key. 256 | $secureMessage->setStorageKey($this->laravelEncryption->decrypt($this->storage->get($record->id))); 257 | 258 | // Decrypt the meta data. 259 | $meta = $this->secureMessageFactory->decryptMeta($secureMessage); 260 | 261 | // Remove all keys from memory. 262 | $secureMessage->wipeKeysFromMemory(); 263 | $secureMessage->wipeEncryptedMetaFromMemory(); 264 | 265 | // Return the secure message (with only the meta data set). 266 | return $meta; 267 | } 268 | 269 | /** 270 | * Destroy a secure message. Both the record and the key in the file storage will be removed. 271 | * 272 | * @param string $secureMessageId The secure message ID. 273 | */ 274 | public function destroy(string $secureMessageId) 275 | { 276 | SecureMessageModel::destroy($secureMessageId); 277 | $this->storage->delete($secureMessageId); 278 | } 279 | } 280 | --------------------------------------------------------------------------------