diff --git a/AGENTS.md b/AGENTS.md index f6a364d..cfa2824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,29 @@ keys (10 + 11 + 11 = 32 bytes). - **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 diff --git a/README.md b/README.md index c8920d6..d1523f1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,16 @@ $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 diff --git a/composer.json b/composer.json index 5b04c06..8fa5650 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,9 @@ "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": { "Exonet\\SecureMessage\\": "src" diff --git a/docs/examples/file_example.php b/docs/examples/file_example.php new file mode 100644 index 0000000..5def104 --- /dev/null +++ b/docs/examples/file_example.php @@ -0,0 +1,47 @@ +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 81f3a58..ba938d2 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -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 1c043bd..0f8f688 100644 --- a/docs/using.md +++ b/docs/using.md @@ -42,3 +42,41 @@ $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. +- 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/src/Exceptions/InvalidFileException.php b/src/Exceptions/InvalidFileException.php new file mode 100644 index 0000000..d4fae82 --- /dev/null +++ b/src/Exceptions/InvalidFileException.php @@ -0,0 +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 @@ -211,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/Database/Migrations/2026_08_11_000000_make_secure_messages_content_nullable.php b/src/Laravel/Database/Migrations/2026_08_11_000000_make_secure_messages_content_nullable.php new file mode 100644 index 0000000..b6d0b36 --- /dev/null +++ b/src/Laravel/Database/Migrations/2026_08_11_000000_make_secure_messages_content_nullable.php @@ -0,0 +1,31 @@ +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 c4d0ca2..a6c7745 100644 --- a/src/Laravel/Database/SecureMessage.php +++ b/src/Laravel/Database/SecureMessage.php @@ -10,7 +10,8 @@ /** * @property string $id The secure message ID. * @property string $meta The encrypted meta data. - * @property string $content The encrypted content. + * @property string|null $content The encrypted content. Null for file messages: their encrypted + * contents are stored on the configured files disk. * @property string $key The encrypted database key. * @property Carbon|null $created_at * @property Carbon|null $updated_at diff --git a/src/Laravel/Factory.php b/src/Laravel/Factory.php index a81d5d3..22dd73e 100644 --- a/src/Laravel/Factory.php +++ b/src/Laravel/Factory.php @@ -8,6 +8,7 @@ use Exonet\SecureMessage\Exceptions\DecryptException; use Exonet\SecureMessage\Exceptions\ExpiredException; use Exonet\SecureMessage\Exceptions\HitPointLimitReachedException; +use Exonet\SecureMessage\Exceptions\InvalidFileException; use Exonet\SecureMessage\Exceptions\InvalidKeyLengthException; use Exonet\SecureMessage\Factory as SecureMessageFactory; use Exonet\SecureMessage\Laravel\Database\SecureMessage as SecureMessageModel; @@ -20,9 +21,17 @@ use Illuminate\Contracts\Events\Dispatcher as Event; use Illuminate\Contracts\Filesystem\Factory as Storage; use Illuminate\Contracts\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\File\UploadedFile; class Factory { + /** + * @var string The path prefix for encrypted file contents on the files disk. Must never be empty: + * when the files disk and the storage-key disk point at the same location, blobs + * stored at the bare message ID would overwrite the storage key files. + */ + private const FILES_PATH_PREFIX = 'files/'; + /** * @var SecureMessageFactory The Secure Message factory, configured with the meta key. */ @@ -33,6 +42,18 @@ class Factory */ private Filesystem $storage; + /** + * @var Storage The Laravel storage factory, kept to lazily resolve the files disk. + */ + private Storage $storageFactory; + + /** + * @var Filesystem|null The Laravel storage disk holding encrypted file contents. Resolved lazily + * (see filesDisk()) so installations that never use file messages do not + * need to configure the disk. + */ + private ?Filesystem $filesDisk = null; + /** * Factory constructor. * @@ -54,6 +75,7 @@ public function __construct( ) { $this->secureMessageFactory = $secureMessageFactory->setMetaKey($config->get('secure_messages.meta_key')); $this->storage = $storage->disk($config->get('secure_messages.storage_disk_name')); + $this->storageFactory = $storage; } /** @@ -99,6 +121,82 @@ 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. * @@ -138,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.'); @@ -189,7 +290,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)) { @@ -248,7 +349,52 @@ public function getMeta(string $secureMessageId): SecureMessage */ 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/config/secure_messages.php b/src/Laravel/config/secure_messages.php index 74cdc83..0f822b4 100644 --- a/src/Laravel/config/secure_messages.php +++ b/src/Laravel/config/secure_messages.php @@ -28,6 +28,34 @@ */ 'meta_key' => 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/SecureMessage.php b/src/SecureMessage.php index 1867d60..a04fd59 100644 --- a/src/SecureMessage.php +++ b/src/SecureMessage.php @@ -4,6 +4,8 @@ namespace Exonet\SecureMessage; +use Exonet\SecureMessage\Exceptions\InvalidFileException; + class SecureMessage { /** @@ -32,7 +34,9 @@ class SecureMessage private ?string $contentEncrypted = null; /** - * @var array 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 array $meta = ['hit_points' => null, 'expires_at' => null]; @@ -398,8 +402,114 @@ public function setMeta(array $metaData): self $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 + { + 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 + { + 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 3a0a2b1..dad02b0 100644 --- a/tests/CryptoTest.php +++ b/tests/CryptoTest.php @@ -206,6 +206,90 @@ public function testDecryptInvalidVerificationCode(): void $this->assertTrue($exceptionThrown); } + 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(); diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index 031c6ab..9d01cb7 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -5,6 +5,7 @@ namespace Exonet\SecureMessage\Tests; use Exonet\SecureMessage\Crypto; +use Exonet\SecureMessage\Exceptions\InvalidFileException; use Exonet\SecureMessage\Exceptions\InvalidKeyLengthException; use Exonet\SecureMessage\Factory; use Exonet\SecureMessage\SecureMessage; @@ -124,6 +125,54 @@ public function testValidateEncryptionKey(): void $this->assertTrue($factory->validateEncryptionKey($secureMessage)); } + 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 index e63b5e9..288d081 100644 --- a/tests/Laravel/FactoryTest.php +++ b/tests/Laravel/FactoryTest.php @@ -8,6 +8,7 @@ use Exonet\SecureMessage\Exceptions\DecryptException; use Exonet\SecureMessage\Exceptions\ExpiredException; use Exonet\SecureMessage\Exceptions\HitPointLimitReachedException; +use Exonet\SecureMessage\Exceptions\InvalidFileException; use Exonet\SecureMessage\Factory as SecureMessageFactory; use Exonet\SecureMessage\Laravel\Database\SecureMessage as SecureMessageModel; use Exonet\SecureMessage\Laravel\Events\DecryptionFailed; @@ -331,21 +332,259 @@ public function testDestroy(): void $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) { + 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. + * Insert a secure message record with all non-nullable columns filled. A null content marks the + * record as a file message. */ - private function insertSecureMessageRecord(): void + private function insertSecureMessageRecord(?string $content = 'encryptedContent'): void { SecureMessageModel::insert([ 'id' => 'unitTest', 'key' => 'encryptedDatabaseKey', 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', + 'content' => $content, 'created_at' => Carbon::now(), ]); } diff --git a/tests/SecureMessageTest.php b/tests/SecureMessageTest.php index d2d5fa0..5ae7c46 100644 --- a/tests/SecureMessageTest.php +++ b/tests/SecureMessageTest.php @@ -4,6 +4,7 @@ namespace Exonet\SecureMessage\Tests; +use Exonet\SecureMessage\Exceptions\InvalidFileException; use Exonet\SecureMessage\SecureMessage; use PHPUnit\Framework\TestCase; @@ -88,6 +89,49 @@ public function testSettersGetters(): void $this->assertSame(1, $secureMessage->setExpiresAt(1)->getExpiresAt()); } + 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();