diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3fa1896..103c9a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,33 +11,60 @@ on: jobs: run-tests: - name: Run tests + name: Tests (PHP ${{ matrix.php }}, testbench ${{ matrix.testbench }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - versions: [ - { php: "7.3", phpunit: "9" }, - { php: "7.4", phpunit: "9" }, - { php: "8.0", phpunit: "9" }, - { php: "8.1", phpunit: "10" }, - ] + php: ["8.2", "8.3", "8.4"] + # orchestra/testbench 10 = Laravel 12, 11 = Laravel 13. + testbench: ["^10.0", "^11.0"] + exclude: + # orchestra/testbench 11 (Laravel 13) requires PHP >= 8.3. + - php: "8.2" + testbench: "^11.0" steps: - name: Checkout repository uses: actions/checkout@v5 - - name: Composer install - uses: php-actions/composer@v6 + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - php_version: ${{ matrix.versions.php }} + php-version: ${{ matrix.php }} + extensions: sodium + coverage: none + + - name: Select testbench version + run: composer require --dev --no-update "orchestra/testbench:${{ matrix.testbench }}" + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress - name: Run unit tests - uses: php-actions/phpunit@v3.0.3 + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - configuration: phpunit.xml - version: ${{ matrix.versions.phpunit }} - php_version: ${{ matrix.versions.php }} + php-version: "8.3" + extensions: sodium + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress check-code-style: name: Check code style @@ -53,7 +80,7 @@ jobs: - name: Run php-cs-fixer uses: docker://oskarstark/php-cs-fixer-ga - + - name: Apply php-cs-fixer changes uses: stefanzweifel/git-auto-commit-action@v6 with: diff --git a/.gitignore b/.gitignore index c1d5303..f46dce8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /vendor/ composer.lock .php-cs-fixer.cache -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +.phpunit.cache \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index b9c9edd..330f1a6 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -8,7 +8,9 @@ $config = new PhpCsFixer\Config(); return $config + ->setRiskyAllowed(true) ->setRules([ + 'declare_strict_types' => true, '@PSR2' => true, '@Symfony' => true, '@PhpCsFixer' => true, diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cfa2824 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +## What this is + +`exonet/securemessage` is a framework-agnostic PHP library (with an optional +Laravel integration) for encrypting messages using libsodium secretbox. The +32-byte encryption key is deliberately split into three parts stored in +different places, so a single compromised store never yields a complete key: + +- **database key** — 11 random bytes, stored in a database. +- **storage key** — 11 random bytes, stored on a disk/filesystem. +- **verification code** — 10 characters, never stored; sent to the recipient. + +The message meta data (expiry timestamp, remaining "hit points" = allowed +failed decrypt attempts) is encrypted separately with a **meta key**: an +application-wide 10-character key concatenated with the database and storage +keys (10 + 11 + 11 = 32 bytes). + +## Layout + +- `src/Crypto.php` — sodium encrypt/decrypt, hit-point reduction, key validation. +- `src/Factory.php` — creates messages, generates the three key parts. +- `src/SecureMessage.php` — value object holding content, keys and meta; has + `wipe*FromMemory()` methods built on `sodium_memzero()`. +- `src/Exceptions/` — all extend `SecureMessageException`; `ExpiredException` + and `HitPointLimitReachedException` extend `DecryptException`. +- `src/Laravel/` — service provider, facade, Eloquent model + migration, + config, events and the `secure_message:housekeeping` command. Persists the + storage key via a Laravel filesystem disk and the rest in the database, each + wrapped in Laravel's own `Encrypter` as a second layer. +- `tests/` — PHPUnit tests for the core library; `tests/Laravel/` — tests for + the Laravel integration, running on orchestra/testbench (in-memory sqlite). +- `docs/` — usage documentation and a runnable example. + +## Commands + +- `composer test` — runs the whole suite (testsuites `Core` and `Laravel`, + PHPUnit 11). Requires PHP with the `sodium` extension (available locally). +- `composer analyse` — PHPStan level 6 (with larastan and phpstan-mockery), + configured in `phpstan.neon.dist`. Keep it clean. +- Code style is enforced by php-cs-fixer using `.php-cs-fixer.php` + (`@PSR2` + `@Symfony` + `@PhpCsFixer` plus overrides, including + `declare_strict_types`). CI runs it on every PR **and auto-commits the + fixes to the PR branch**, so don't be surprised by extra commits; running + the fixer locally before pushing avoids them. + +## Constraints and gotchas + +- **PHP compatibility: `^8.2`** (v2). CI tests 8.2, 8.3 and 8.4, against both + Laravel 12 (testbench `^10.0`) and Laravel 13 (testbench `^11.0`). Typed + properties, promotion, readonly and match are in use; typed class constants + are NOT (8.3+ feature). +- **Everything is `declare(strict_types=1)`.** When adding code paths, mind + implicit coercions that no longer happen (e.g. `SecureMessage::setMeta()` + deliberately casts `hit_points`/`expires_at` to int for this reason). +- **No production dependencies** other than `php` and `ext-sodium`. The + Laravel classes reference `illuminate/*` and `nesbot/carbon`, which resolve + via orchestra/testbench in dev and via the host app in production. Don't + add them to `require`. +- **`sodium_memzero()` nulls its by-reference argument.** Every property that + gets wiped in `SecureMessage::wipe*FromMemory()` must stay nullable + (`?string = null`) and must never be `readonly`, or wiping throws a + `TypeError` in the security-critical path. +- **Don't add native types to inherited Laravel properties** (`$table`, + `$incrementing`, `$keyType`, `$signature`, `$description`) — the parents + declare them untyped, so typing them is a fatal error. +- **The migration filename must never change.** Laravel records migrations by + filename; renaming re-runs it and crashes existing installs. +- **Key lengths are load-bearing.** `Factory::setMetaKey()` requires exactly + 10 characters; `Crypto` requires the *combined* keys to be exactly 32 bytes + (11-byte database key + 11-byte storage key + 10-char verification code). + +## File messages (since v2.1) + +- A file is a regular `SecureMessage`: the content holds the file bytes, the + encrypted meta carries `file_name`, `mime_type` and `file_size`. There is no + separate file class; `isFile()` means "meta has a file_name". +- File names (and mime types) must be valid UTF-8 — the meta is JSON encoded + and `json_encode()` returning false would blow up inside the crypto path. + The setters validate this; keep it that way. +- In the Laravel integration, `content === null` on the database record ⇔ + file message: the encrypted blob lives on the files disk under + `files/{id}`. The `files/` prefix is load-bearing — without it a blob would + overwrite the storage-key file when both disks point at the same location. +- The files disk is resolved **lazily** (`Laravel\Factory::filesDisk()`), so + installations that never use file messages don't need to configure it. + Never resolve it in the constructor or in code paths that plain text + messages hit (this includes `destroy()`, which checks the record first). +- The encrypted content must always be loaded onto the `SecureMessage` + *before* `decrypt()` is called, also on failure paths — null content causes + `TypeError`s inside `Crypto` and inside the `DecryptException` constructor. +- The `$meta` array type is `array`; PHPStan level 6 + accepts this, levels 7+ would need the narrowing the file-meta getters + already do. Don't loosen those getters. +- **Security invariants — preserve them when touching `Crypto`/`SecureMessage`:** + nonces are randomly generated per encryption and never reused; failed or + invalid decrypt attempts must keep reducing hit points (this is the + brute-force protection); plaintext, keys and decrypted meta are wiped with + `sodium_memzero()` after use. Don't weaken or reorder these paths. +- Changing the encrypted wire format (`Crypto::toString()`/`fromString()`: + base64 of a JSON array of base64 nonce + ciphertext) breaks decryption of + all previously stored messages — treat it as a breaking change. + +## Conventions + +- Every method has a full PHPDoc block (`@param`/`@throws`/`@return` with + descriptions) — match this style; php-cs-fixer enforces the ordering. +- Setters return `$this` (fluent); properties are `private` with getters/setters. +- Follow SemVer. PRs need tests, documentation updates for behaviour changes, + and **exactly the labels CI expects** (`bugfix`, `new-feature`, + `breaking-change`, `enhancement`, `documentation`, `dependencies`, + `maintenance`, `ci`, …) — the `verify-pr-labels` workflow blocks unlabeled + PRs, and release-drafter builds the changelog and version bump from labels. +- Security issues go to development@exonet.nl, never the public issue tracker. diff --git a/README.md b/README.md index 718aee8..d1523f1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ The verification code can be sent (securely) to the receiver of the secure messa message and read it. ## Requirements -This package requires at least PHP 7.3 with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled. +This package requires PHP 8.2 or newer with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled. +The optional Laravel integration supports Laravel 12 and 13. For PHP 7.3 up to 8.1, use version 1.x of this package. ## Install @@ -33,8 +34,8 @@ $ composer require exonet/securemessage ```php // Create the factory. $secureMessageFactory = new Exonet\SecureMessage\Factory(); -// Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('A_10_random_characters_long_key.'); +// Set the (application wide) meta key. This key must be exactly 10 characters long. +$secureMessageFactory->setMetaKey('djuyteb765'); // Create a new SecureMessage. Note: it is not encrypted yet! $secureMessage = $secureMessageFactory->make('Hello, world!'); @@ -42,8 +43,38 @@ $secureMessage = $secureMessageFactory->make('Hello, world!'); $encryptedMessage = $secureMessage->encrypt(); ``` +Files (documents, images) can also be stored as a secure message. The file contents are encrypted in memory and the +file name, mime type and size travel along in the encrypted meta data: + +```php +$secureMessage = $secureMessageFactory->makeFile('/path/to/report.pdf'); +$encryptedMessage = $secureMessage->encrypt(); +``` + +> Mime type detection uses the `fileinfo` extension when it is available. + Please see the `/docs` folder for complete documentation and additional examples. +## Upgrading from v1 + +Messages encrypted with v1 can still be decrypted with v2: the encrypted format and the key structure are unchanged. +Notable changes: + +- PHP 8.2 or newer is required and the Laravel integration requires Laravel 12 or 13. +- The `sodium` extension is now a hard composer requirement (`ext-sodium`). On servers without the extension, + `composer install` fails immediately instead of the package failing at the first encrypt. +- The whole code base is strictly typed (`declare(strict_types=1)`). Make sure you pass the documented types. + In particular, check your published `config/secure_messages.php`: `hit_points` and `expires_in` must be real + integers. A numeric string (for example from an `env()` call) was silently coerced by v1, but throws a + `TypeError` in v2. +- The Laravel events (`DecryptionFailed`, `HitPointLimitReached`, `SecureMessageExpired`) now expose the secure + message through a `public readonly` property `$secureMessage` (previously this property was private and inaccessible + to listeners). +- A malformed encrypted message now throws a `DecryptException` when decrypting or validating a key, instead of + failing with a PHP error. Code catching `TypeError` for this case should catch `DecryptException` instead. +- The migration class `CreateSecureMessagesTable` is now an anonymous class. The migration filename is unchanged, + so existing installations are unaffected, but code referencing the class by name no longer works. + ## Change log Please see [releases][link-releases] for more information on what has changed recently. diff --git a/composer.json b/composer.json index 04db22e..8fa5650 100644 --- a/composer.json +++ b/composer.json @@ -3,8 +3,12 @@ "description": "Encrypt and decrypt messages in a secure way.", "type": "library", "require-dev": { - "mockery/mockery": "^1.4", - "phpunit/phpunit": "^9" + "larastan/larastan": "^3.0", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10.0|^11.0", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-mockery": "^2.0", + "phpunit/phpunit": "^11.5" }, "license": "MIT", "authors": [ @@ -14,7 +18,11 @@ } ], "require": { - "php": "^7.3|^8.0" + "php": "^8.2", + "ext-sodium": "*" + }, + "suggest": { + "ext-fileinfo": "Required to detect the mime type of file messages (falls back to application/octet-stream)." }, "autoload": { "psr-4": { @@ -23,11 +31,12 @@ }, "autoload-dev": { "psr-4": { - "Exonet\\SecureMessage\\": "tests" + "Exonet\\SecureMessage\\Tests\\": "tests/" } }, "scripts": { - "test": "phpunit --testdox tests/" + "test": "phpunit --testdox", + "analyse": "phpstan analyse --no-progress" }, "config": { "sort-packages": true diff --git a/docs/examples/basic_example.php b/docs/examples/basic_example.php index 5843fee..da190f2 100644 --- a/docs/examples/basic_example.php +++ b/docs/examples/basic_example.php @@ -1,5 +1,7 @@ setMetaKey('0123456789'); + +// Create a new SecureMessage from the file and encrypt it. The file name, mime type and size are +// stored in the encrypted meta data. +$secureMessage = $secureMessageFactory->makeFile($examplePath, fileName: 'example.bin'); +$encryptedMessage = $secureMessage->encrypt(); + +echo '---[ ENCRYPTED FILE MESSAGE ]---'."\n"; +echo sprintf("ID: %s\n", $encryptedMessage->getId()); +echo sprintf("Verification code: %s\n", $encryptedMessage->getVerificationCode()); +echo sprintf("Encrypted size: %d bytes\n", strlen((string) $encryptedMessage->getEncryptedContent())); + +echo "\n"; + +/* + * To keep things simple for this example, the encrypted data and keys are reused directly. In a real + * world application you'll have to store the keys at their three separate locations, and read them + * back when the receiver enters the verification code. + */ +$decryptedMessage = $secureMessageFactory->decrypt($encryptedMessage); + +echo '---[ DECRYPTED FILE MESSAGE ]---'."\n"; +echo sprintf("Is file: %s\n", $decryptedMessage->isFile() ? 'yes' : 'no'); +echo sprintf("File name: %s\n", $decryptedMessage->getFileName()); +echo sprintf("Mime type: %s\n", $decryptedMessage->getMimeType()); +echo sprintf("File size: %d bytes\n", $decryptedMessage->getFileSize()); +echo sprintf( + "Contents intact: %s\n", + $decryptedMessage->getContent() === file_get_contents($examplePath) ? 'yes' : 'no' +); + +unlink($examplePath); diff --git a/docs/laravel.md b/docs/laravel.md index aefdf6c..ba938d2 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -2,7 +2,7 @@ ### Installation - Run `composer require exonet/securemessage`. -- If you use Laravel 5.5 or newer, the required ServiceProvider is automatically registered. For older Laravel versions you need to register the service provider `\Exonet\SecureMessage\Laravel\Providers\SecureMessageServiceProvider::class` in your `config/app.php`. +- The required ServiceProvider is automatically registered via package discovery. - In your `.env` file add the following key `SECURE_MESSAGE_META_KEY`. Give it an alphanumeric 10 characters long [random](https://www.random.org/strings/?num=1&len=10&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new) value. - In your `config/filesystems.php` file, add a new storage disk with the name `secure_messages`. For example: `'secure_messages' => ['driver' => 'local', 'root' => storage_path('/secure_messages')],`. - (optional) If you'd like to change the storage disk name, default hit points or default expire date, run `php artisan vendor:publish --provider="Exonet\\SecureMessage\\Laravel\\Providers\\SecureMessageServiceProvider" --tag=config` to get the config file to edit those settings. @@ -51,3 +51,52 @@ following command to clean up the database and file storage: ```bash php artisan secure_message:housekeeping ``` + +## Files as secure messages + +### Setup +- In your `config/filesystems.php`, add a storage disk with the name `secure_messages_files`. Use a disk that is + separate from the `secure_messages` (storage key) disk — and ideally separate from the database host — so that no + single compromised store holds multiple parts of the encryption key material. With the default local driver: + `'secure_messages_files' => ['driver' => 'local', 'root' => storage_path('/secure_messages_files')],` +- Upgrading from a version before 2.1? Run `php artisan migrate` — the package ships a migration that makes the + `content` column nullable. Installations that only use text messages don't need to configure the files disk: it is + resolved lazily, only when file messages are used. + +### Encrypting a file + +```php +// From a path: +$encryptedMessage = \SecureMessage::encryptFile('/path/to/report.pdf'); + +// Or directly from an upload; the original client file name is stored automatically: +$encryptedMessage = \SecureMessage::encryptFile($request->file('attachment')); +``` + +The encrypted file contents are stored (double encrypted, like everything else) on the files disk; the database +record only holds the keys and meta data. The maximum file size is limited by the `max_file_size` config setting +(default 10 MB) because files are encrypted in memory and the stored blob is roughly three times the original file +size. + +> **Note:** the source file itself is left untouched — `encryptFile()` only *reads* it. For uploads this is fine +> (PHP removes the temporary upload file at the end of the request), but if your application first writes a file to +> disk and then stores it as a secure message, deleting the unencrypted original afterwards is the responsibility of +> your application. + +### Decrypting and downloading a file + +`decryptMessage` works for file messages exactly as it does for text messages, including hit points, expiry and the +events. To offer the file as a download: + +```php +$message = \SecureMessage::decryptMessage('SECUREMESSAGEID', 'verificationCode'); + +return response($message->getContent(), 200, [ + 'Content-Type' => $message->getMimeType() ?? 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="'.addslashes($message->getFileName()).'"', +]); +``` + +> **Note:** the file meta data (including the file name!) is part of the meta and can be read server side via +> `SecureMessage::getMeta()` *without* the verification code. Don't show the file name to visitors before they have +> entered a valid verification code, unless that is intended. diff --git a/docs/using.md b/docs/using.md index ff5965b..86187f5 100644 --- a/docs/using.md +++ b/docs/using.md @@ -6,7 +6,7 @@ By using the provided factory it is pretty easy to create a new secure message: // Create the factory. $secureMessageFactory = new \Exonet\SecureMessage\Factory(); // Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('djuyteb765d'); +$secureMessageFactory->setMetaKey('djuyteb765'); // Create a new SecureMessage. Note: it is not encrypted yet! $secureMessage = $secureMessageFactory->make('Hello, world!'); @@ -18,7 +18,8 @@ $encryptedMessage = $secureMessage->encrypt(); after storing them, you call `$encryptedMessage->wipeKeysFromMemory()` to securely erase the keys. The `meta key` is a string of 10 characters that is used when encrypting the meta data in combination with the database -and storage key. This can be the same key for each secure message (because of the use of the database and storage +and storage key: together they form the 32 byte key that the encryption requires (11 + 11 + 10), so the code that +validates a 32 character meta key operates on that composed key, not on the configured one. This can be the same key for each secure message (because of the use of the database and storage keys the complete key used for encryption is never the same) or per secure message. However, if you're using a meta key per secure message, please note that you must store it somewhere or that you can recreate it, because it is necessary for every decrypt/validation action. @@ -31,7 +32,7 @@ Assuming you've the correct keys: // Create the factory. $secureMessageFactory = new Exonet\SecureMessage\Factory(); // Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('djuyteb765d'); +$secureMessageFactory->setMetaKey('djuyteb765'); $secureMessage = new \Exonet\SecureMessage\SecureMessage(); $secureMessage->setEncryptedContent('[the encrypted content]'); @@ -42,3 +43,42 @@ $secureMessage->setVerificationCode('a1bc2ef4xy'); $decryptedMessage = $secureMessageFactory->decrypt($secureMessage); ``` + +## Files as Secure Messages + +A file can be stored as a secure message: the file contents become the (binary safe) message content and the file +name, mime type and file size travel along in the encrypted meta data. + +```php +$secureMessageFactory = new \Exonet\SecureMessage\Factory(); +$secureMessageFactory->setMetaKey('djuyteb765'); + +// Create a SecureMessage from a file. Note: it is not encrypted yet! +$secureMessage = $secureMessageFactory->makeFile('/path/to/report.pdf'); +$encryptedMessage = $secureMessage->encrypt(); +``` + +Decrypting works exactly the same as for text messages. After decrypting, the file meta data is available: + +```php +$decrypted = $secureMessageFactory->decrypt($secureMessage); + +$decrypted->isFile(); // true +$decrypted->getFileName(); // 'report.pdf' +$decrypted->getMimeType(); // 'application/pdf' +$decrypted->getFileSize(); // The size in bytes. +$decrypted->getContent(); // The raw file contents. +``` + +Some things to keep in mind: + +- Files are encrypted **in memory**, so this is meant for small files (documents, images). The encoded, encrypted + message is roughly 1.8 times the original file size. An integration may add its own encryption on top of this: + the Laravel integration double encrypts, bringing the stored blob to roughly 3 times the original file size. +- The file name must be valid UTF-8 (the meta data is JSON encoded). For files with a non-UTF-8 name, pass an + explicit name: `$factory->makeFile($path, fileName: 'sanitized-name.bin')`. The same applies to files on + temporary paths (such as uploads), where the base name of the path is meaningless. +- Mime type detection requires the `fileinfo` extension; without it, `application/octet-stream` is stored. +- **The source file itself is left untouched.** `makeFile()` only *reads* the file: the original, unencrypted file + stays at its path. If the goal is that the contents only exist as a secure message, deleting (or shredding) the + source file after encrypting is the responsibility of your application. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..1d4efb5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + level: 6 + paths: + - src + - tests + excludePaths: + # A Laravel config file, not code. Larastan only allows env() calls in an + # application config/ directory, which this package directory is not. + - src/Laravel/config/secure_messages.php diff --git a/phpunit.xml b/phpunit.xml index 8c86f30..a67a7c3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,17 @@ - - - - ./src - - + - - ./tests + + tests + tests/Laravel + + + tests/Laravel + + + src + + diff --git a/src/Crypto.php b/src/Crypto.php index 06fbc93..6f3c092 100644 --- a/src/Crypto.php +++ b/src/Crypto.php @@ -1,5 +1,7 @@ isMetaEncrypted() === false) { - if (strlen($secureMessage->getMetaKey()) !== 32) { + if (strlen((string) $secureMessage->getMetaKey()) !== 32) { throw new InvalidKeyLengthException('The key must be 32 bytes/characters.'); } @@ -99,7 +101,7 @@ public function decrypt(SecureMessage $secureMessage): SecureMessage if ($content === false) { $secureMessage = $this->reduceHitPoints($secureMessage); - throw new DecryptException('Unable to or failed decrypt the contents of the message.', $secureMessage); + throw new DecryptException('Unable to or failed to decrypt the contents of the message.', $secureMessage); } $secureMessage->setContent($content); @@ -112,13 +114,15 @@ public function decrypt(SecureMessage $secureMessage): SecureMessage /** * Check if the encryption key can be used to decrypt the message. * - * @param SecureMessage $secureMessage + * @param SecureMessage $secureMessage The secure message to validate the encryption key of. + * + * @throws DecryptException When the encrypted content is malformed. * * @return bool Whether or not the encryption key is valid. */ public function validateEncryptionKey(SecureMessage $secureMessage): bool { - if (strlen($secureMessage->getEncryptionKey()) !== 32 || strlen($secureMessage->getMetaKey()) !== 32) { + if (strlen($secureMessage->getEncryptionKey()) !== 32 || strlen((string) $secureMessage->getMetaKey()) !== 32) { return false; } @@ -151,7 +155,7 @@ public function validateEncryptionKey(SecureMessage $secureMessage): bool */ public function decryptMeta(SecureMessage $secureMessage): SecureMessage { - if (!$secureMessage->isMetaEncrypted() || strlen($secureMessage->getMetaKey()) !== 32) { + if (!$secureMessage->isMetaEncrypted() || strlen((string) $secureMessage->getMetaKey()) !== 32) { throw new DecryptException('Unable to or failed to decrypt the meta data.', $secureMessage); } @@ -190,13 +194,27 @@ private function toString(string $nonce, string $encryptedContent): string * * @param string $content The base64 string as generated by $this->toString(). * + * @throws DecryptException When the given string is not valid base64 or does not contain a nonce/message pair. + * * @return string[] The nonce and the encrypted message. */ private function fromString(string $content): array { - [$nonce, $message] = json_decode(base64_decode($content, true)); + $decoded = base64_decode($content, true); + $parts = $decoded === false ? null : json_decode($decoded, true); + + if (!is_array($parts) || count($parts) !== 2 || !is_string($parts[0]) || !is_string($parts[1])) { + throw new DecryptException('The encrypted message is malformed.'); + } + + $nonce = base64_decode($parts[0], true); + $message = base64_decode($parts[1], true); + + if ($nonce === false || $message === false) { + throw new DecryptException('The encrypted message is malformed.'); + } - return ['nonce' => base64_decode($nonce, true), 'data' => base64_decode($message, true)]; + return ['nonce' => $nonce, 'data' => $message]; } /** @@ -207,7 +225,7 @@ private function fromString(string $content): array * * @throws HitPointLimitReachedException If there are no hit points remaining. * - * @return SecureMessage The secure message with the hit points reduces by 1. + * @return SecureMessage The secure message with the hit points reduced by 1. */ private function reduceHitPoints(SecureMessage $secureMessage): SecureMessage { diff --git a/src/Exceptions/DecryptException.php b/src/Exceptions/DecryptException.php index 7acac40..cdba670 100644 --- a/src/Exceptions/DecryptException.php +++ b/src/Exceptions/DecryptException.php @@ -1,5 +1,7 @@ make($content, $hitPoints, $expiresAt); + $factory->secureMessage + ->setFileName($fileName ?? $this->getBaseName($path)) + ->setMimeType($this->detectMimeType($path)) + ->setFileSize(strlen($content)); + + return $factory; + } + /** * Perform the actual encryption on the given secure message or the secure message of the current factory. A * SecureMessage instance with the encrypted content and keys are returned. Please note that when a SecureMessage @@ -120,7 +163,7 @@ public function decryptMeta(SecureMessage $secureMessage): SecureMessage } /** - * Check if the set encryption key can be used to decrypt te message. + * Check if the set encryption key can be used to decrypt the message. * * @param SecureMessage $secureMessage The secure message to decrypt. * @@ -172,7 +215,7 @@ public function setCryptoInstance(Crypto $crypto): self * * @return static The new factory instance. */ - protected function createFactoryInstance() + protected function createFactoryInstance(): static { return new static($this->metaKey); } @@ -191,7 +234,7 @@ protected function generateId(): string * Create three keys (with a length of 32 bytes in total) that will be used to encrypt the message. The keys are * divided in three parts, so they can be stored at three different locations. * - * @return array The keys to use as encryption key. + * @return string[] The keys to use as encryption key. */ protected function generateKeys(): array { @@ -205,4 +248,36 @@ protected function generateKeys(): array 'verification_code' => $verificationCode, ]; } + + /** + * Get the base name of a path. basename() is locale sensitive and can truncate multibyte + * characters, so the path separators are stripped manually. + * + * @param string $path The path to get the base name of. + * + * @return string The base name. + */ + private function getBaseName(string $path): string + { + return preg_replace('#^.*[/\\\]#', '', $path) ?? $path; + } + + /** + * Detect the mime type of the given file. Uses ext-fileinfo when available and falls back to + * application/octet-stream. + * + * @param string $path The path of the file. + * + * @return string The detected mime type. + */ + private function detectMimeType(string $path): string + { + if (!class_exists(\finfo::class)) { + return 'application/octet-stream'; + } + + $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path); + + return $mimeType === false ? 'application/octet-stream' : $mimeType; + } } diff --git a/src/Laravel/Console/Housekeeping.php b/src/Laravel/Console/Housekeeping.php index fb6ba14..fb57954 100644 --- a/src/Laravel/Console/Housekeeping.php +++ b/src/Laravel/Console/Housekeeping.php @@ -1,5 +1,7 @@ text('content')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. Note: reversing fails when file messages (rows with a null content + * column) exist in the table. + */ + public function down(): void + { + Schema::table('secure_messages', function (Blueprint $table) { + $table->text('content')->nullable(false)->change(); + }); + } +}; diff --git a/src/Laravel/Database/SecureMessage.php b/src/Laravel/Database/SecureMessage.php index a2129f2..a6c7745 100644 --- a/src/Laravel/Database/SecureMessage.php +++ b/src/Laravel/Database/SecureMessage.php @@ -1,9 +1,21 @@ secureMessage = $secureMessage; - } + public function __construct(public readonly SecureMessage $secureMessage) {} } diff --git a/src/Laravel/Events/SecureMessageExpired.php b/src/Laravel/Events/SecureMessageExpired.php index 9295734..1258deb 100644 --- a/src/Laravel/Events/SecureMessageExpired.php +++ b/src/Laravel/Events/SecureMessageExpired.php @@ -1,5 +1,7 @@ secureMessageFactory = $secureMessageFactory->setMetaKey($config->get('secure_messages.meta_key')); $this->storage = $storage->disk($config->get('secure_messages.storage_disk_name')); - $this->laravelEncryption = $laravelEncryption; - $this->config = $config; - $this->event = $event; + $this->storageFactory = $storage; } /** @@ -77,6 +84,7 @@ public function __construct( * * @param string $content The content to store secure. * @param Carbon|null $expireDate The expire date of the secure message. (Optional) + * @param int|null $hitPoints The number of hit points. (Optional) * * @return SecureMessage The secure message. */ @@ -113,15 +121,91 @@ public function encrypt(string $content, ?Carbon $expireDate = null, ?int $hitPo return $encryptedData; } + /** + * Encrypt the given file and get a SecureMessage with the verification code available (all other + * keys are removed from the class). The encrypted file contents are stored on the configured + * files disk; the database record is stored with a null content column. + * + * @param \SplFileInfo|string $file The file to store secure: a path, or an SplFileInfo + * instance (uploaded files work out of the box). + * @param Carbon|null $expireDate The expire date of the secure message. (Optional) + * @param int|null $hitPoints The number of hit points. (Optional) + * @param string|null $fileName The file name to store in the (encrypted) meta data. + * Defaults to the original client name for uploaded files, + * or the base name of the path. + * + * @throws InvalidFileException If the file is not readable or exceeds the configured maximum size. + * + * @return SecureMessage The secure message. + */ + public function encryptFile(\SplFileInfo|string $file, ?Carbon $expireDate = null, ?int $hitPoints = null, ?string $fileName = null): SecureMessage + { + $path = $file instanceof \SplFileInfo ? $file->getPathname() : $file; + + // For uploaded files, default to the name of the file on the client machine. The mime type is + // always detected server side from the file contents, because the client mime type is not + // trustworthy. + if ($fileName === null && $file instanceof UploadedFile) { + $fileName = $file->getClientOriginalName(); + } + + if (!is_file($path) || !is_readable($path)) { + throw new InvalidFileException(sprintf('The file [%s] does not exist or is not readable.', $path)); + } + + // Check the file size before reading the contents into memory. + $maxFileSize = $this->config->get('secure_messages.max_file_size'); + if ($maxFileSize !== null && filesize($path) > $maxFileSize) { + throw new InvalidFileException(sprintf('The file exceeds the maximum size of %d bytes.', $maxFileSize)); + } + + // Get a Carbon instance with the expire date, based on the argument or on the config setting. + $carbonExpire = $expireDate ?? Carbon::now()->addDays($this->config->get('secure_messages.expires_in')); + $hitPoints = $hitPoints ?? $this->config->get('secure_messages.hit_points'); + + // Create the secure message. + $encryptedData = $this->secureMessageFactory + ->makeFile($path, $hitPoints, $carbonExpire->timestamp, $fileName) + ->encrypt(); + + // Encrypt the 'storage key' part and save it to the defined storage disk. + $this->storage->put( + $encryptedData->getId(), + $this->laravelEncryption->encrypt($encryptedData->getStorageKey()) + ); + + // Encrypt the file contents a second time and store the blob on the files disk. + $this->filesDisk()->put( + self::FILES_PATH_PREFIX.$encryptedData->getId(), + $this->laravelEncryption->encrypt($encryptedData->getEncryptedContent()) + ); + + // Save the secure message (encrypted) to the database. The content column is null: it marks + // the record as a file message, whose encrypted contents live on the files disk. + $record = new SecureMessageModel(); + $record->id = $encryptedData->getId(); + $record->meta = $this->laravelEncryption->encrypt($encryptedData->getEncryptedMeta()); + $record->content = null; + $record->key = $this->laravelEncryption->encrypt($encryptedData->getDatabaseKey()); + $record->created_at = Carbon::now(); + $record->updated_at = Carbon::now(); + $record->save(); + + // Wipe the keys from memory, but keep the verification code. + $encryptedData->wipeKeysFromMemory(false); + + return $encryptedData; + } + /** * Return the decrypted content of the secure message for the given message ID. * * @param string $secureMessageId The secure message ID. * @param string $verificationCode The verification code for the secure message. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the secure message can not be decrypted. * - * @return string The contents of the secure message. + * @return string|null The contents of the secure message. */ public function decrypt(string $secureMessageId, string $verificationCode): ?string { @@ -137,7 +221,7 @@ public function decrypt(string $secureMessageId, string $verificationCode): ?str * @param string $secureMessageId The secure message ID. * @param string $verificationCode The verification code for the secure message. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the secure message can not be decrypted. * * @return SecureMessage The decrypted secure message, with the keys removed. */ @@ -152,9 +236,12 @@ public function decryptMessage(string $secureMessageId, string $verificationCode $secureMessage->setVerificationCode($verificationCode); $secureMessage->setDatabaseKey($this->laravelEncryption->decrypt($record->key)); $secureMessage->setEncryptedMeta($this->laravelEncryption->decrypt($record->meta)); - $secureMessage->setEncryptedContent($this->laravelEncryption->decrypt($record->content)); try { + // Load the encrypted content, from the files disk or the database record. This must + // happen before decrypting, also for the failure paths. + $this->loadEncryptedContent($secureMessage, $record); + // Check if the storage key file exists. if (!$this->storage->exists($record->id)) { throw new DecryptException('Can not find key file.'); @@ -172,23 +259,19 @@ public function decryptMessage(string $secureMessageId, string $verificationCode $record->save(); } - // Dispatch events. - switch (get_class($exception)) { - case HitPointLimitReachedException::class: - $this->event->dispatch(new HitPointLimitReached($secureMessage)); + // Wipe the keys before the secure message is handed to event listeners. Most failure paths + // already wipe the keys (the DecryptException constructor does so when it is given the secure + // message), but the paths that throw without it - a missing key file, a missing file blob or + // malformed stored ciphertext - would otherwise expose the decrypted keys on this instance to + // listeners (and to anything they serialize the event to, such as a queue). + $secureMessage->wipeKeysFromMemory(); - break; - - case ExpiredException::class: - $this->event->dispatch(new SecureMessageExpired($secureMessage)); - - break; - - default: - $this->event->dispatch(new DecryptionFailed($secureMessage)); - - break; - } + // Dispatch events. + match ($exception::class) { + HitPointLimitReachedException::class => $this->event->dispatch(new HitPointLimitReached($secureMessage)), + ExpiredException::class => $this->event->dispatch(new SecureMessageExpired($secureMessage)), + default => $this->event->dispatch(new DecryptionFailed($secureMessage)), + }; // And throw the exception again, so the user can catch it. throw $exception; @@ -214,7 +297,7 @@ public function checkVerificationCode(string $secureMessageId, string $verificat $secureMessage->setVerificationCode($verificationCode); $secureMessage->setDatabaseKey($this->laravelEncryption->decrypt($record->key)); $secureMessage->setEncryptedMeta($this->laravelEncryption->decrypt($record->meta)); - $secureMessage->setEncryptedContent($this->laravelEncryption->decrypt($record->content)); + $this->loadEncryptedContent($secureMessage, $record); // Check if the storage key file exists. if (!$this->storage->exists($record->id)) { @@ -232,7 +315,7 @@ public function checkVerificationCode(string $secureMessageId, string $verificat * * @param string $secureMessageId The secure message ID. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the meta data can not be decrypted. * * @return SecureMessage The secure message with only the (decrypted) meta. */ @@ -271,9 +354,54 @@ public function getMeta(string $secureMessageId): SecureMessage * * @param string $secureMessageId The secure message ID. */ - public function destroy(string $secureMessageId) + public function destroy(string $secureMessageId): void { + // For file messages the encrypted contents live on the files disk; remove that blob as well. + // The record is fetched first so the files disk is only resolved for file messages. + $record = SecureMessageModel::find($secureMessageId); + if ($record !== null && $record->content === null) { + $this->filesDisk()->delete(self::FILES_PATH_PREFIX.$secureMessageId); + } + SecureMessageModel::destroy($secureMessageId); $this->storage->delete($secureMessageId); } + + /** + * Set the encrypted content on the secure message: from the database record, or for file + * messages (identified by a null content column) from the blob on the files disk. + * + * @param SecureMessage $secureMessage The secure message to set the encrypted content on. + * @param SecureMessageModel $record The database record. + * + * @throws DecryptException If the file blob can not be found. + */ + private function loadEncryptedContent(SecureMessage $secureMessage, SecureMessageModel $record): void + { + if ($record->content !== null) { + $secureMessage->setEncryptedContent($this->laravelEncryption->decrypt($record->content)); + + return; + } + + // File message: the encrypted contents are stored on the files disk. + if (!$this->filesDisk()->exists(self::FILES_PATH_PREFIX.$record->id)) { + throw new DecryptException('Can not find file blob.'); + } + + $secureMessage->setEncryptedContent( + $this->laravelEncryption->decrypt($this->filesDisk()->get(self::FILES_PATH_PREFIX.$record->id)) + ); + } + + /** + * Get the disk holding the encrypted file contents. Resolved lazily (and memoized), so that + * installations that never use file messages do not need to configure the disk. + * + * @return Filesystem The files disk. + */ + private function filesDisk(): Filesystem + { + return $this->filesDisk ??= $this->storageFactory->disk($this->config->get('secure_messages.files_disk_name')); + } } diff --git a/src/Laravel/Providers/SecureMessageServiceProvider.php b/src/Laravel/Providers/SecureMessageServiceProvider.php index 958ae39..b3a01ea 100644 --- a/src/Laravel/Providers/SecureMessageServiceProvider.php +++ b/src/Laravel/Providers/SecureMessageServiceProvider.php @@ -1,5 +1,7 @@ publishes([ __DIR__.'/../config/secure_messages.php' => config_path('secure_messages.php'), @@ -31,8 +33,6 @@ public function register(): void $this->commands([Housekeeping::class]); // Create a container binding (used by the facade). - $this->app->bind('secureMessage', function () { - return $this->app->make(LaravelSecureMessageFactory::class); - }); + $this->app->bind('secureMessage', fn () => $this->app->make(LaravelSecureMessageFactory::class)); } } diff --git a/src/Laravel/SecureMessageFacade.php b/src/Laravel/SecureMessageFacade.php index 87fc00a..9b58693 100644 --- a/src/Laravel/SecureMessageFacade.php +++ b/src/Laravel/SecureMessageFacade.php @@ -1,5 +1,7 @@ env('SECURE_MESSAGE_META_KEY', 'ChangeThis'), + /* + |-------------------------------------------------------------------------- + | File Storage Disk + |-------------------------------------------------------------------------- + | + | Here you can specify which disk entry the package must use to store the + | encrypted contents of file messages. You can define this disk in + | 'config/filesystems.php'. Use a disk that is separate from the + | 'storage_disk_name' disk (and ideally separate from the database host), + | so that no single compromised store holds multiple parts of the + | encryption key material. Only required when using file messages. + | + */ + 'files_disk_name' => 'secure_messages_files', + + /* + |-------------------------------------------------------------------------- + | Maximum File Size + |-------------------------------------------------------------------------- + | + | The maximum size (in bytes) of files that can be stored as a secure + | message. Files are encrypted in memory and the stored blob is roughly + | three times the original file size, so this limit keeps memory and + | storage usage bounded. + | + */ + 'max_file_size' => 10485760, + /* |-------------------------------------------------------------------------- | Hit Points diff --git a/src/Laravel/tests/FactoryTest.php b/src/Laravel/tests/FactoryTest.php deleted file mode 100644 index 9a6bfc0..0000000 --- a/src/Laravel/tests/FactoryTest.php +++ /dev/null @@ -1,372 +0,0 @@ -app->make('db')->beginTransaction(); - } - - protected function tearDown() - { - $this->app->make('db')->rollBack(); - parent::tearDown(); - } - - public function createApplication() - { - $app = require __DIR__.'/../../../../bootstrap/app.php'; - $app->make(Kernel::class)->bootstrap(); - - return $app; - } - - public function testEncrypt() - { - Carbon::setTestNow(Carbon::create(2018, 4, 24, 9, 32, 33)); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.expires_in'])->once()->andReturn(1); - $configMock->shouldReceive('get')->withArgs(['secure_messages.hit_points'])->once()->andReturn(100); - - $createdSecureMessage = new SecureMessage(); - $createdSecureMessage->setId('secureMessageKey'); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('make')->withArgs(['Unit Test', 100, 1524648753])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('encrypt')->withNoArgs()->once()->andReturn($createdSecureMessage); - - $encrypterMock - ->shouldReceive('encrypt') - ->withArgs([\Mockery::any()]) - ->times(4) - ->andReturn('encryptedKey', 'encryptedMeta', 'encryptedContent', 'encryptedDbKey'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('put')->withArgs(['secureMessageKey', 'encryptedKey'])->once(); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - $encryptedMessage = $factory->encrypt('Unit Test'); - - $this->assertDatabaseHas('secure_messages', ['id' => $encryptedMessage->getId()]); - } - - public function testDecryptMessage() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->twice()->andReturn('databaseKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->twice()->andReturn('storageKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->twice()->andReturn('meta'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->twice()->andReturn('content'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->twice()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->twice()->andReturn('encryptedStorageKey'); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('decrypt')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { - $this->assertSame('unitTest', $secureMessage->getId()); - $this->assertSame('databaseKey', $secureMessage->getDatabaseKey()); - $this->assertSame('storageKey', $secureMessage->getStorageKey()); - $this->assertSame('1337', $secureMessage->getVerificationCode()); - $this->assertSame('meta', $secureMessage->getEncryptedMeta()); - $this->assertSame('content', $secureMessage->getEncryptedContent()); - - return true; - })])->twice()->andReturn($decryptedSecureMessage); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - - $this->assertSame($decryptedSecureMessage, $factory->decryptMessage('unitTest', '1337')); - $this->assertSame('Decrypted content', $factory->decrypt('unitTest', '1337')); - } - - public function testCheckVerificationCode() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('validateEncryptionKey')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { - $this->assertSame('unitTest', $secureMessage->getId()); - $this->assertSame('databaseKey', $secureMessage->getDatabaseKey()); - $this->assertSame('storageKey', $secureMessage->getStorageKey()); - $this->assertSame('1337', $secureMessage->getVerificationCode()); - $this->assertSame('meta', $secureMessage->getEncryptedMeta()); - $this->assertSame('content', $secureMessage->getEncryptedContent()); - - return true; - })])->once()->andReturnTrue(); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - - $this->assertTrue($factory->checkVerificationCode('unitTest', '1337')); - } - - public function testDecryptMessageStorageKeyNotFound() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->never(); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnFalse(); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - - $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === DecryptionFailed::class; - })])->once(); - - $this->expectException(DecryptException::class); - $this->expectExceptionMessage('Can not find key file.'); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - $factory->decryptMessage('unitTest', '1337'); - } - - public function testDecryptMessageHitpointLimitReached() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new HitPointLimitReachedException('The maximum number of hit points is reached.')); - - $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === HitPointLimitReached::class; - })])->once(); - - $this->expectException(DecryptException::class); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - $factory->decryptMessage('unitTest', '1337'); - } - - public function testDecryptMessageMessageExpired() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new ExpiredException('This secure message is expired.')); - - $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === SecureMessageExpired::class; - })])->once(); - - $this->expectException(DecryptException::class); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - $factory->decryptMessage('unitTest', '1337'); - } - - public function testDecryptMeta() - { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - $secureMessageFactoryMock->shouldReceive('decryptMeta')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { - $this->assertSame('unitTest', $secureMessage->getId()); - $this->assertSame('meta', $secureMessage->getEncryptedMeta()); - - return true; - })])->once()->andReturn($decryptedSecureMessage); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - - $this->assertSame($decryptedSecureMessage, $factory->getMeta('unitTest')); - } - - public function testDestroy() - { - SecureMessageModel::insert(['id' => 'unitTest']); - - $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); - $storageMock = \Mockery::mock(Storage::class); - $encrypterMock = \Mockery::mock(Encrypter::class); - $configMock = \Mockery::mock(Config::class); - $eventMock = \Mockery::mock(Event::class); - - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('delete')->withArgs(['unitTest'])->once()->andReturnSelf(); - - $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); - - $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); - $factory->destroy('unitTest'); - - $this->assertDatabaseMissing('secure_messages', ['id' => 'unitTest']); - } -} diff --git a/src/SecureMessage.php b/src/SecureMessage.php index 099a028..620fd9d 100644 --- a/src/SecureMessage.php +++ b/src/SecureMessage.php @@ -1,38 +1,50 @@ Array holding the different keys used for this secure message. + * + * Note: these keys are wiped with sodium_memzero(), which nulls its by-reference argument. The + * array values must therefore always allow null. */ - private $keys = ['database' => null, 'storage' => null, 'verification' => null, 'meta' => null]; + private array $keys = ['database' => null, 'storage' => null, 'verification' => null, 'meta' => null]; /** - * @var string The message content. Can be plain text or encrypted. + * @var string|null The message content. Can be plain text or encrypted. Nullable because it is + * wiped with sodium_memzero(), which nulls its by-reference argument. */ - private $content; + private ?string $content = null; /** - * @var string The encrypted version of the content. + * @var string|null The encrypted version of the content. Nullable because it is wiped with + * sodium_memzero(), which nulls its by-reference argument. */ - private $contentEncrypted; + private ?string $contentEncrypted = null; /** - * @var int[] The meta data for this secure message. + * @var array The meta data for this secure message. Holds the hit points and expire + * timestamp, and for file messages also the file name, mime type and file + * size. */ - private $meta = ['hit_points' => null, 'expires_at' => null]; + private array $meta = ['hit_points' => null, 'expires_at' => null]; /** - * @var string[] The encrypted version of the meta. + * @var string|null The encrypted version of the meta. Nullable because it is wiped with + * sodium_memzero(), which nulls its by-reference argument. */ - private $metaEncrypted; + private ?string $metaEncrypted = null; /** * Wipe the sensitive keys from memory. @@ -99,9 +111,11 @@ public function getEncryptionKey(): string } /** - * Get the meta key. + * Get the composed meta encryption key: database key (11) + storage key (11) + the configured + * 10 character meta key = the 32 bytes sodium requires. The configured key alone is never + * enough to decrypt the meta data. * - * @return string|null The meta key. + * @return string|null The composed 32 byte meta key. */ public function getMetaKey(): ?string { @@ -161,7 +175,7 @@ public function isContentEncrypted(): bool } /** - * Set the boolean indicating the content is encrypted. + * Set the encrypted content. * * @param string $encrypted The encrypted content. * @@ -195,7 +209,7 @@ public function isMetaEncrypted(): bool } /** - * Set the boolean indicating the meta is encrypted. + * Set the encrypted meta data. * * @param string $encrypted The encrypted meta data. * @@ -229,8 +243,7 @@ public function getContent(): ?string } /** - * Set the content. Can be encrypted or unencrypted. Don't forget to also set the 'encrypted' boolean when updating - * this value. + * Set the content. Can be encrypted or unencrypted. * * @param string $content The content. * @@ -374,16 +387,133 @@ public function getMeta(): array } /** - * Set all meta data for this message. + * Set all meta data for this message. The hit points and expire timestamp are cast to integers + * to keep the (strictly typed) meta getters working for callers that provide numeric strings. * - * @param int[] The meta data. + * @param mixed[] $metaData The meta data. * * @return $this The current secure message instance. */ public function setMeta(array $metaData): self { + if (isset($metaData['hit_points'])) { + $metaData['hit_points'] = (int) $metaData['hit_points']; + } + + if (isset($metaData['expires_at'])) { + $metaData['expires_at'] = (int) $metaData['expires_at']; + } + + if (isset($metaData['file_size'])) { + $metaData['file_size'] = (int) $metaData['file_size']; + } + $this->meta = $metaData; return $this; } + + /** + * Check if this secure message is a file. + * + * @return bool True when this secure message holds a file. + */ + public function isFile(): bool + { + return isset($this->meta['file_name']); + } + + /** + * Set the file name of this message. Setting a file name marks the message as a file. + * + * @param string $fileName The file name. + * + * @throws InvalidFileException If the file name is not valid UTF-8 (required because the meta + * data is JSON encoded before it is encrypted). + * + * @return $this The current secure message instance. + */ + public function setFileName(string $fileName): self + { + // UTF-8 validation via PCRE instead of mb_check_encoding: mbstring is not a package dependency. + if (preg_match('//u', $fileName) !== 1) { + throw new InvalidFileException('The file name must be valid UTF-8.'); + } + + $this->meta['file_name'] = $fileName; + + return $this; + } + + /** + * Get the file name of this message. + * + * @return string|null The file name, or null when this message is not a file. + */ + public function getFileName(): ?string + { + $fileName = $this->meta['file_name'] ?? null; + + return is_string($fileName) ? $fileName : null; + } + + /** + * Set the mime type of the file. + * + * @param string $mimeType The mime type. + * + * @throws InvalidFileException If the mime type is not valid UTF-8 (required because the meta + * data is JSON encoded before it is encrypted). + * + * @return $this The current secure message instance. + */ + public function setMimeType(string $mimeType): self + { + // UTF-8 validation via PCRE instead of mb_check_encoding: mbstring is not a package dependency. + if (preg_match('//u', $mimeType) !== 1) { + throw new InvalidFileException('The mime type must be valid UTF-8.'); + } + + $this->meta['mime_type'] = $mimeType; + + return $this; + } + + /** + * Get the mime type of the file. + * + * @return string|null The mime type, or null when this message is not a file. + */ + public function getMimeType(): ?string + { + $mimeType = $this->meta['mime_type'] ?? null; + + return is_string($mimeType) ? $mimeType : null; + } + + /** + * Set the file size in bytes. + * + * @param int $fileSize The file size in bytes. + * + * @return $this The current secure message instance. + */ + public function setFileSize(int $fileSize): self + { + $this->meta['file_size'] = $fileSize; + + return $this; + } + + /** + * Get the file size in bytes. + * + * @return int|null The file size in bytes, or null when this message is not a file. + */ + public function getFileSize(): ?int + { + $fileSize = $this->meta['file_size'] ?? null; + + return is_int($fileSize) ? $fileSize : null; + } } diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php index a200caf..dad02b0 100644 --- a/tests/CryptoTest.php +++ b/tests/CryptoTest.php @@ -1,6 +1,8 @@ assertSame(4823435472, $metaArray['expires_at']); } - public function testEncryptInvalidKeyLength() + public function testEncryptInvalidKeyLength(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -62,7 +64,7 @@ public function testEncryptInvalidKeyLength() $secureMessage->setVerificationCode('b'); $secureMessage->setDatabaseKey('c'); $secureMessage->setContent('Unit Test'); - $secureMessage->setHitPoints('1337'); + $secureMessage->setHitPoints(1337); $secureMessage->setExpiresAt(10); $this->expectException(InvalidKeyLengthException::class); @@ -70,7 +72,7 @@ public function testEncryptInvalidKeyLength() $crypto->encrypt($secureMessage); } - public function testEncryptInvalidMetaKeyLength() + public function testEncryptInvalidMetaKeyLength(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -79,7 +81,7 @@ public function testEncryptInvalidMetaKeyLength() $secureMessage->setVerificationCode('1234567890'); $secureMessage->setDatabaseKey('databaseKey'); $secureMessage->setContent('Unit Test'); - $secureMessage->setHitPoints('1337'); + $secureMessage->setHitPoints(1337); $secureMessage->setExpiresAt(10); $this->expectException(InvalidKeyLengthException::class); @@ -87,7 +89,7 @@ public function testEncryptInvalidMetaKeyLength() $crypto->encrypt($secureMessage); } - public function testDecrypt() + public function testDecrypt(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -114,7 +116,7 @@ public function testDecrypt() $this->assertNull($decrypted->getDatabaseKey()); } - public function testDecryptSecureMessageIsExpired() + public function testDecryptSecureMessageIsExpired(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -130,7 +132,7 @@ public function testDecryptSecureMessageIsExpired() $crypto->decrypt($secureMessage); } - public function testDecryptHitpointsReached() + public function testDecryptHitpointsReached(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -167,7 +169,7 @@ public function testDecryptHitpointsReached() $this->assertTrue($exceptionThrown); } - public function testDecryptInvalidVerificationCode() + public function testDecryptInvalidVerificationCode(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -204,7 +206,91 @@ public function testDecryptInvalidVerificationCode() $this->assertTrue($exceptionThrown); } - public function testValidateEncryptionKeyCorrectKey() + public function testEncryptDecryptBinaryContent(): void + { + $crypto = new Crypto(); + $binary = implode('', array_map('chr', range(0, 255))).random_bytes(1024 * 1024 + 1); + + $secureMessage = new SecureMessage(); + $secureMessage->setMetaKey('metaKey___'); + $secureMessage->setStorageKey('storageKey_'); + $secureMessage->setVerificationCode('1234567890'); + $secureMessage->setDatabaseKey('databaseKey'); + $secureMessage->setContent($binary); + $secureMessage->setHitPoints(3); + $secureMessage->setExpiresAt(time() + 3600); + + $decrypted = $crypto->decrypt($crypto->encrypt($secureMessage)); + + $this->assertSame($binary, $decrypted->getContent()); + } + + public function testEncryptDecryptKeepsFileMeta(): void + { + $crypto = new Crypto(); + + $secureMessage = new SecureMessage(); + $secureMessage->setMetaKey('metaKey___'); + $secureMessage->setStorageKey('storageKey_'); + $secureMessage->setVerificationCode('1234567890'); + $secureMessage->setDatabaseKey('databaseKey'); + $secureMessage->setContent("file\x00contents"); + $secureMessage->setHitPoints(3); + $secureMessage->setExpiresAt(time() + 3600); + $secureMessage->setFileName('report.pdf'); + $secureMessage->setMimeType('application/pdf'); + $secureMessage->setFileSize(13); + + $decrypted = $crypto->decrypt($crypto->encrypt($secureMessage)); + + $this->assertSame("file\x00contents", $decrypted->getContent()); + $this->assertTrue($decrypted->isFile()); + $this->assertSame('report.pdf', $decrypted->getFileName()); + $this->assertSame('application/pdf', $decrypted->getMimeType()); + $this->assertSame(13, $decrypted->getFileSize()); + } + + public function testFileMetaSurvivesFailedDecrypt(): void + { + $crypto = new Crypto(); + + $secureMessage = new SecureMessage(); + $secureMessage->setMetaKey('metaKey___'); + $secureMessage->setStorageKey('storageKey_'); + $secureMessage->setVerificationCode('1234567890'); + $secureMessage->setDatabaseKey('databaseKey'); + $secureMessage->setContent('file contents'); + $secureMessage->setHitPoints(3); + $secureMessage->setExpiresAt(time() + 3600); + $secureMessage->setFileName('report.pdf'); + + $encrypted = $crypto->encrypt($secureMessage); + $encrypted->setVerificationCode('WrongKey__'); + + $exceptionThrown = false; + + try { + $crypto->decrypt($encrypted); + } catch (DecryptException $exception) { + $exceptionThrown = true; + + // Re-add the keys to decrypt the updated meta from the exception. + $exception->secureMessage->setStorageKey('storageKey_'); + $exception->secureMessage->setDatabaseKey('databaseKey'); + $exception->secureMessage->setMetaKey('metaKey___'); + + $decryptedMeta = $crypto->decryptMeta($exception->secureMessage); + + // The hit points are reduced, but the file meta is untouched. + $this->assertSame(2, $decryptedMeta->getHitPoints()); + $this->assertTrue($decryptedMeta->isFile()); + $this->assertSame('report.pdf', $decryptedMeta->getFileName()); + } + + $this->assertTrue($exceptionThrown); + } + + public function testValidateEncryptionKeyCorrectKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -219,7 +305,7 @@ public function testValidateEncryptionKeyCorrectKey() $this->assertTrue($crypto->validateEncryptionKey($secureMessage)); } - public function testValidateEncryptionKeyIncorrectKey() + public function testValidateEncryptionKeyIncorrectKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -235,7 +321,7 @@ public function testValidateEncryptionKeyIncorrectKey() $this->assertFalse($crypto->validateEncryptionKey($secureMessage)); } - public function testValidateEncryptionKeyKeyTooShort() + public function testValidateEncryptionKeyKeyTooShort(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -251,7 +337,7 @@ public function testValidateEncryptionKeyKeyTooShort() $this->assertFalse($crypto->validateEncryptionKey($secureMessage)); } - public function testDecryptMeta() + public function testDecryptMeta(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -273,7 +359,7 @@ public function testDecryptMeta() $this->assertNotNull($decrypted->getEncryptedContent()); } - public function testDecryptMetaInvalidMetaKey() + public function testDecryptMetaInvalidMetaKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index fb6c5e9..9d01cb7 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -1,11 +1,15 @@ assertSame(10, $resultAll->secureMessage->getExpiresAt()); } - public function testEncrypt() + public function testEncrypt(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -60,7 +66,7 @@ public function testEncrypt() $this->assertSame($secureMessageResult, $factory->encrypt()); } - public function testDecrypt() + public function testDecrypt(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -80,7 +86,7 @@ public function testDecrypt() $this->assertSame($secureMessageResult, $factory->decrypt($secureMessage)); } - public function testDecryptMeta() + public function testDecryptMeta(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -100,7 +106,7 @@ public function testDecryptMeta() $this->assertSame($secureMessageResult, $factory->decryptMeta($secureMessage)); } - public function testValidateEncryptionKey() + public function testValidateEncryptionKey(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessage = new SecureMessage(); @@ -119,7 +125,55 @@ public function testValidateEncryptionKey() $this->assertTrue($factory->validateEncryptionKey($secureMessage)); } - public function testSetMetaKey() + public function testMakeFile(): void + { + $path = tempnam(sys_get_temp_dir(), 'securemessage'); + file_put_contents($path, 'Unit Test file contents'); + + try { + $factory = new Factory(); + $result = $factory->makeFile($path, 1, 10); + + $this->assertNotSame($factory, $result); + $this->assertSame('Unit Test file contents', $result->secureMessage->getContent()); + $this->assertTrue($result->secureMessage->isFile()); + $this->assertSame(basename($path), $result->secureMessage->getFileName()); + $this->assertSame('text/plain', $result->secureMessage->getMimeType()); + $this->assertSame(23, $result->secureMessage->getFileSize()); + $this->assertSame(1, $result->secureMessage->getHitPoints()); + $this->assertSame(10, $result->secureMessage->getExpiresAt()); + } finally { + unlink($path); + } + } + + public function testMakeFileWithFileNameOverride(): void + { + $path = tempnam(sys_get_temp_dir(), 'securemessage'); + file_put_contents($path, 'Unit Test file contents'); + + try { + $result = (new Factory())->makeFile($path, fileName: 'report.txt'); + + $this->assertSame('report.txt', $result->secureMessage->getFileName()); + } finally { + unlink($path); + } + } + + public function testMakeFileMissingPath(): void + { + $this->expectException(InvalidFileException::class); + (new Factory())->makeFile(sys_get_temp_dir().'/does-not-exist.bin'); + } + + public function testMakeFileDirectoryPath(): void + { + $this->expectException(InvalidFileException::class); + (new Factory())->makeFile(sys_get_temp_dir()); + } + + public function testSetMetaKey(): void { $factory = new Factory(); diff --git a/tests/Laravel/FactoryTest.php b/tests/Laravel/FactoryTest.php new file mode 100644 index 0000000..3b95fcb --- /dev/null +++ b/tests/Laravel/FactoryTest.php @@ -0,0 +1,605 @@ +shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.expires_in'])->once()->andReturn(1); + $configMock->shouldReceive('get')->withArgs(['secure_messages.hit_points'])->once()->andReturn(100); + + $createdSecureMessage = new SecureMessage(); + $createdSecureMessage->setId('secureMessageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('make')->withArgs(['Unit Test', 100, 1524648753])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('encrypt')->withNoArgs()->once()->andReturn($createdSecureMessage); + + $encrypterMock + ->shouldReceive('encrypt') + ->withArgs([\Mockery::any()]) + ->times(4) + ->andReturn('encryptedKey', 'encryptedMeta', 'encryptedContent', 'encryptedDbKey'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('put')->withArgs(['secureMessageKey', 'encryptedKey'])->once(); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $encryptedMessage = $factory->encrypt('Unit Test'); + + $this->assertDatabaseHas('secure_messages', ['id' => $encryptedMessage->getId()]); + } + + public function testDecryptMessage(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $decryptedSecureMessage = new SecureMessage(); + $decryptedSecureMessage->setContent('Decrypted content'); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->twice()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->twice()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->twice()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->twice()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->twice()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->twice()->andReturn('encryptedStorageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decrypt')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { + $this->assertSame('unitTest', $secureMessage->getId()); + $this->assertSame('databaseKey', $secureMessage->getDatabaseKey()); + $this->assertSame('storageKey', $secureMessage->getStorageKey()); + $this->assertSame('1337', $secureMessage->getVerificationCode()); + $this->assertSame('meta', $secureMessage->getEncryptedMeta()); + $this->assertSame('content', $secureMessage->getEncryptedContent()); + + return true; + })])->twice()->andReturn($decryptedSecureMessage); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + + $this->assertSame($decryptedSecureMessage, $factory->decryptMessage('unitTest', '1337')); + $this->assertSame('Decrypted content', $factory->decrypt('unitTest', '1337')); + } + + public function testCheckVerificationCode(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('validateEncryptionKey')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { + $this->assertSame('unitTest', $secureMessage->getId()); + $this->assertSame('databaseKey', $secureMessage->getDatabaseKey()); + $this->assertSame('storageKey', $secureMessage->getStorageKey()); + $this->assertSame('1337', $secureMessage->getVerificationCode()); + $this->assertSame('meta', $secureMessage->getEncryptedMeta()); + $this->assertSame('content', $secureMessage->getEncryptedContent()); + + return true; + })])->once()->andReturnTrue(); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + + $this->assertTrue($factory->checkVerificationCode('unitTest', '1337')); + } + + public function testDecryptMessageStorageKeyNotFound(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->never(); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnFalse(); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + + $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + // The event must not expose any key material to listeners, even though this failure path + // throws without a secure message (so the DecryptException constructor never wiped it). + $this->assertNull($event->secureMessage->getDatabaseKey()); + $this->assertNull($event->secureMessage->getStorageKey()); + $this->assertNull($event->secureMessage->getMetaKey()); + $this->assertNull($event->secureMessage->getVerificationCode()); + + return $event::class === DecryptionFailed::class; + })])->once(); + + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('Can not find key file.'); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->decryptMessage('unitTest', '1337'); + } + + public function testDecryptMessageHitpointLimitReached(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new HitPointLimitReachedException('The maximum number of hit points is reached.')); + + $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + return $event::class === HitPointLimitReached::class; + })])->once(); + + $this->expectException(DecryptException::class); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->decryptMessage('unitTest', '1337'); + } + + public function testDecryptMessageMessageExpired(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new ExpiredException('This secure message is expired.')); + + $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + return $event::class === SecureMessageExpired::class; + })])->once(); + + $this->expectException(DecryptException::class); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->decryptMessage('unitTest', '1337'); + } + + public function testDecryptMeta(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $decryptedSecureMessage = new SecureMessage(); + $decryptedSecureMessage->setContent('Decrypted content'); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decryptMeta')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { + $this->assertSame('unitTest', $secureMessage->getId()); + $this->assertSame('meta', $secureMessage->getEncryptedMeta()); + + return true; + })])->once()->andReturn($decryptedSecureMessage); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + + $this->assertSame($decryptedSecureMessage, $factory->getMeta('unitTest')); + } + + public function testDestroy(): void + { + $this->insertSecureMessageRecord(); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('delete')->withArgs(['unitTest'])->once()->andReturnSelf(); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->destroy('unitTest'); + + $this->assertDatabaseMissing('secure_messages', ['id' => 'unitTest']); + } + + public function testEncryptFile(): void + { + Carbon::setTestNow(Carbon::create(2018, 4, 24, 9, 32, 33)); + + $path = tempnam(sys_get_temp_dir(), 'securemessage'); + file_put_contents($path, 'Unit Test file contents'); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $filesDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.files_disk_name'])->once()->andReturn('secure_messages_files'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.max_file_size'])->once()->andReturn(10485760); + $configMock->shouldReceive('get')->withArgs(['secure_messages.expires_in'])->once()->andReturn(1); + $configMock->shouldReceive('get')->withArgs(['secure_messages.hit_points'])->once()->andReturn(100); + + $createdSecureMessage = new SecureMessage(); + $createdSecureMessage->setId('secureMessageKey'); + $createdSecureMessage->setEncryptedMeta('rawMeta'); + $createdSecureMessage->setEncryptedContent('rawContent'); + $createdSecureMessage->setDatabaseKey('rawDbKey'); + $createdSecureMessage->setStorageKey('rawStorageKey'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('makeFile')->withArgs([$path, 100, 1524648753, null])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('encrypt')->withNoArgs()->once()->andReturn($createdSecureMessage); + + $encrypterMock + ->shouldReceive('encrypt') + ->withArgs([\Mockery::any()]) + ->times(4) + ->andReturn('encryptedKey', 'encryptedBlob', 'encryptedMeta', 'encryptedDbKey'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages_files'])->once()->andReturn($filesDiskMock); + $storageDiskMock->shouldReceive('put')->withArgs(['secureMessageKey', 'encryptedKey'])->once(); + $filesDiskMock->shouldReceive('put')->withArgs(['files/secureMessageKey', 'encryptedBlob'])->once(); + + try { + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $encryptedMessage = $factory->encryptFile($path); + + $this->assertDatabaseHas('secure_messages', ['id' => $encryptedMessage->getId(), 'content' => null]); + } finally { + unlink($path); + } + } + + public function testEncryptFileTooLarge(): void + { + $path = tempnam(sys_get_temp_dir(), 'securemessage'); + file_put_contents($path, 'Unit Test file contents'); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.max_file_size'])->once()->andReturn(10); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + + $this->expectException(InvalidFileException::class); + + try { + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->encryptFile($path); + } finally { + unlink($path); + } + } + + public function testDecryptFileMessage(): void + { + $this->insertSecureMessageRecord(null); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $filesDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $decryptedSecureMessage = new SecureMessage(); + $decryptedSecureMessage->setContent('Decrypted file contents'); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.files_disk_name'])->once()->andReturn('secure_messages_files'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedBlob'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages_files'])->once()->andReturn($filesDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $filesDiskMock->shouldReceive('exists')->withArgs(['files/unitTest'])->once()->andReturnTrue(); + $filesDiskMock->shouldReceive('get')->withArgs(['files/unitTest'])->once()->andReturn('encryptedBlob'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decrypt')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { + $this->assertSame('unitTest', $secureMessage->getId()); + $this->assertSame('databaseKey', $secureMessage->getDatabaseKey()); + $this->assertSame('storageKey', $secureMessage->getStorageKey()); + $this->assertSame('1337', $secureMessage->getVerificationCode()); + $this->assertSame('meta', $secureMessage->getEncryptedMeta()); + $this->assertSame('content', $secureMessage->getEncryptedContent()); + + return true; + })])->once()->andReturn($decryptedSecureMessage); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + + $this->assertSame($decryptedSecureMessage, $factory->decryptMessage('unitTest', '1337')); + } + + public function testDecryptFileMessageBlobMissing(): void + { + $this->insertSecureMessageRecord(null); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $filesDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.files_disk_name'])->once()->andReturn('secure_messages_files'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages_files'])->once()->andReturn($filesDiskMock); + $filesDiskMock->shouldReceive('exists')->withArgs(['files/unitTest'])->once()->andReturnFalse(); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + + $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + // The event must not expose any key material to listeners, even though this failure path + // throws without a secure message (so the DecryptException constructor never wiped it). + $this->assertNull($event->secureMessage->getDatabaseKey()); + $this->assertNull($event->secureMessage->getStorageKey()); + $this->assertNull($event->secureMessage->getMetaKey()); + $this->assertNull($event->secureMessage->getVerificationCode()); + + return $event::class === DecryptionFailed::class; + })])->once(); + + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('Can not find file blob.'); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->decryptMessage('unitTest', '1337'); + } + + public function testDecryptFileMessageHitpointLimitReached(): void + { + $this->insertSecureMessageRecord(null); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $filesDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.files_disk_name'])->once()->andReturn('secure_messages_files'); + + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedBlob'])->once()->andReturn('content'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages_files'])->once()->andReturn($filesDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $filesDiskMock->shouldReceive('exists')->withArgs(['files/unitTest'])->once()->andReturnTrue(); + $filesDiskMock->shouldReceive('get')->withArgs(['files/unitTest'])->once()->andReturn('encryptedBlob'); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new HitPointLimitReachedException('The maximum number of hit points is reached.')); + + $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + return $event::class === HitPointLimitReached::class; + })])->once(); + + $this->expectException(DecryptException::class); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->decryptMessage('unitTest', '1337'); + } + + public function testDestroyFileMessage(): void + { + $this->insertSecureMessageRecord(null); + + $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); + $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); + $filesDiskMock = \Mockery::mock(Filesystem::class); + $encrypterMock = \Mockery::mock(Encrypter::class); + $configMock = \Mockery::mock(Config::class); + $eventMock = \Mockery::mock(Event::class); + + $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $configMock->shouldReceive('get')->withArgs(['secure_messages.files_disk_name'])->once()->andReturn('secure_messages_files'); + + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages_files'])->once()->andReturn($filesDiskMock); + $storageDiskMock->shouldReceive('delete')->withArgs(['unitTest'])->once()->andReturnSelf(); + $filesDiskMock->shouldReceive('delete')->withArgs(['files/unitTest'])->once()->andReturnSelf(); + + $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); + + $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); + $factory->destroy('unitTest'); + + $this->assertDatabaseMissing('secure_messages', ['id' => 'unitTest']); + } + + protected function getPackageProviders($app): array + { + return [SecureMessageServiceProvider::class]; + } + + /** + * Insert a secure message record with all non-nullable columns filled. A null content marks the + * record as a file message. + */ + private function insertSecureMessageRecord(?string $content = 'encryptedContent'): void + { + SecureMessageModel::insert([ + 'id' => 'unitTest', + 'key' => 'encryptedDatabaseKey', + 'meta' => 'encryptedMeta', + 'content' => $content, + 'created_at' => Carbon::now(), + ]); + } +} diff --git a/src/Laravel/tests/HousekeepingTest.php b/tests/Laravel/HousekeepingTest.php similarity index 83% rename from src/Laravel/tests/HousekeepingTest.php rename to tests/Laravel/HousekeepingTest.php index 85e2a4a..7aa0883 100644 --- a/src/Laravel/tests/HousekeepingTest.php +++ b/tests/Laravel/HousekeepingTest.php @@ -1,28 +1,25 @@ make(Kernel::class)->bootstrap(); - - return $app; - } + use MockeryPHPUnitIntegration; - public function testHandle() + public function testHandle(): void { $modelMock = \Mockery::mock(SecureMessageModel::class); $factoryMock = \Mockery::mock(SecureMessageFactory::class); @@ -49,4 +46,9 @@ public function testHandle() $command->handle($factoryMock, $modelMock); } + + protected function getPackageProviders($app): array + { + return [SecureMessageServiceProvider::class]; + } } diff --git a/tests/SecureMessageTest.php b/tests/SecureMessageTest.php index aae9791..5ae7c46 100644 --- a/tests/SecureMessageTest.php +++ b/tests/SecureMessageTest.php @@ -1,7 +1,10 @@ setDatabaseKey('abc'); @@ -33,7 +36,7 @@ public function testWipeKeysFromMemory() $this->assertNull($secureMessage->getVerificationCode()); } - public function testWipeContentFromMemory() + public function testWipeContentFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setContent('abc'); @@ -42,7 +45,7 @@ public function testWipeContentFromMemory() $this->assertNull($secureMessage->getContent()); } - public function testWipeEncryptedContentFromMemory() + public function testWipeEncryptedContentFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setEncryptedContent('abc'); @@ -51,7 +54,7 @@ public function testWipeEncryptedContentFromMemory() $this->assertNull($secureMessage->getEncryptedContent()); } - public function testWipeEncryptedMetaFromMemory() + public function testWipeEncryptedMetaFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setEncryptedMeta('abc'); @@ -60,7 +63,7 @@ public function testWipeEncryptedMetaFromMemory() $this->assertEmpty($secureMessage->getEncryptionKey()); } - public function testGetEncryptionKey() + public function testGetEncryptionKey(): void { $secureMessage = new SecureMessage(); $secureMessage->setDatabaseKey('abc'); @@ -70,7 +73,7 @@ public function testGetEncryptionKey() $this->assertSame('abcdefghi', $secureMessage->getEncryptionKey()); } - public function testSettersGetters() + public function testSettersGetters(): void { $secureMessage = new SecureMessage(); $this->assertSame('storageKey', $secureMessage->setStorageKey('storageKey')->getStorageKey()); @@ -86,7 +89,50 @@ public function testSettersGetters() $this->assertSame(1, $secureMessage->setExpiresAt(1)->getExpiresAt()); } - public function testIsEncrypted() + public function testFileMetaAccessors(): void + { + $secureMessage = new SecureMessage(); + + $this->assertFalse($secureMessage->isFile()); + $this->assertNull($secureMessage->getFileName()); + $this->assertNull($secureMessage->getMimeType()); + $this->assertNull($secureMessage->getFileSize()); + + $secureMessage->setFileName('report.pdf')->setMimeType('application/pdf')->setFileSize(1337); + + $this->assertTrue($secureMessage->isFile()); + $this->assertSame('report.pdf', $secureMessage->getFileName()); + $this->assertSame('application/pdf', $secureMessage->getMimeType()); + $this->assertSame(1337, $secureMessage->getFileSize()); + } + + public function testSetFileNameRejectsInvalidUtf8(): void + { + $secureMessage = new SecureMessage(); + + $this->expectException(InvalidFileException::class); + $secureMessage->setFileName("\xC3\x28invalid.bin"); + } + + public function testSetMimeTypeRejectsInvalidUtf8(): void + { + $secureMessage = new SecureMessage(); + + $this->expectException(InvalidFileException::class); + $secureMessage->setMimeType("application/\xC3\x28"); + } + + public function testSetMetaCastsFileSize(): void + { + $secureMessage = new SecureMessage(); + $secureMessage->setMeta(['hit_points' => '3', 'expires_at' => '10', 'file_size' => '2048', 'file_name' => 'a.txt']); + + $this->assertSame(2048, $secureMessage->getFileSize()); + $this->assertSame('a.txt', $secureMessage->getFileName()); + $this->assertSame(3, $secureMessage->getHitPoints()); + } + + public function testIsEncrypted(): void { $secureMessage = new SecureMessage();