From 757c9ca4fb30d9feaa7ff3240e2293a54ac686f9 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:11 +0000 Subject: [PATCH 1/6] Add file support to the core library A file is a regular SecureMessage: the file bytes are the (binary safe) content and the file name, mime type and size travel along in the already encrypted meta data. Factory::makeFile() reads a file from a path, with an optional file name override for files on temporary paths such as uploads. File names and mime types must be valid UTF-8, enforced in the setters: the meta data is JSON encoded inside Crypto::encrypt(), and json_encode() returning false would surface as a TypeError inside the crypto path. This keeps Crypto itself unchanged. Mime detection uses ext-fileinfo when available (suggested in composer.json) and falls back to application/octet-stream. Co-Authored-By: Claude Fable 5 --- composer.json | 3 + src/Exceptions/InvalidFileException.php | 7 ++ src/Factory.php | 69 +++++++++++++++ src/SecureMessage.php | 112 +++++++++++++++++++++++- 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/InvalidFileException.php 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/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/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; + } } From a8240721db25062c5fc9bdffde1fb0994079d8db Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:11 +0000 Subject: [PATCH 2/6] Cover file messages and binary content in the core tests Adds round-trips for binary content (all 256 byte values, >1MB random bytes), file meta accessors including the UTF-8 guards, meta survival through encrypt/decrypt and through the failed-decrypt hit-point flow, and the makeFile happy and error paths. Co-Authored-By: Claude Fable 5 --- tests/CryptoTest.php | 84 +++++++++++++++++++++++++++++++++++++ tests/FactoryTest.php | 49 ++++++++++++++++++++++ tests/SecureMessageTest.php | 44 +++++++++++++++++++ 3 files changed, 177 insertions(+) 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/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(); From 45005dab514de877337f583574a6695dcb17ca83 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:31 +0000 Subject: [PATCH 3/6] Store encrypted file messages on a dedicated Laravel disk File blobs go to a new, lazily resolved files disk (config key files_disk_name) under a 'files/' prefix; the database record is stored with a null content column, which is what marks a record as a file message. A new migration makes the content column nullable. Two deliberate design points: - The files disk is resolved lazily and memoized, never in the constructor: existing installations upgrading to 2.1 have no files disk configured, and eager resolution would break every one of them. This is also why destroy() checks the record before touching the files disk (Housekeeping destroys plain messages too). - The 'files/' prefix prevents a blob from overwriting the storage key file when the files disk and the storage key disk point at the same location. The encrypted content is always loaded onto the SecureMessage before decrypting, also on failure paths: the hit-point reduction and the DecryptException constructor both need it. encryptFile() accepts a path or an SplFileInfo (so Laravel/Symfony uploads work out of the box, using the client name but never the client mime type) and enforces the new max_file_size config setting before reading the file into memory. Co-Authored-By: Claude Fable 5 --- ..._make_secure_messages_content_nullable.php | 31 ++++ src/Laravel/Database/SecureMessage.php | 3 +- src/Laravel/Factory.php | 150 +++++++++++++++++- src/Laravel/config/secure_messages.php | 28 ++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/Laravel/Database/Migrations/2026_08_11_000000_make_secure_messages_content_nullable.php 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 From 5c4ca90985c9911d4430957f3e963188b387296d Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:31 +0000 Subject: [PATCH 4/6] Test the Laravel file message flows Covers encryptFile (blob on the files disk, null content column, max size guard), the file decrypt flow including a missing blob and the hit-point limit path, and destroy for file messages. All pre-existing tests pass unchanged, which proves the files disk is only resolved for file messages. Co-Authored-By: Claude Fable 5 --- tests/Laravel/FactoryTest.php | 245 +++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 3 deletions(-) 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(), ]); } From 2962aa0f741a1db87cdce4643442869a4fd92221 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:31 +0000 Subject: [PATCH 5/6] Document file messages Adds usage documentation for makeFile/encryptFile, a runnable example, the files disk setup with a security note on separating it from the storage key disk, the 'php artisan migrate' upgrade step, and a caveat that the file name is part of the meta data and thus readable server side without the verification code. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 23 +++++++++++++++++ README.md | 10 ++++++++ docs/examples/file_example.php | 47 ++++++++++++++++++++++++++++++++++ docs/laravel.md | 44 +++++++++++++++++++++++++++++++ docs/using.md | 35 +++++++++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 docs/examples/file_example.php 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/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..fb7af19 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -51,3 +51,47 @@ 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. + +### 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..c875308 100644 --- a/docs/using.md +++ b/docs/using.md @@ -42,3 +42,38 @@ $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. From 7dea4606f36eed3e0fc10e79daa292d466621c9e Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:36:37 +0000 Subject: [PATCH 6/6] Document that the source file is left untouched after encrypting encryptFile()/makeFile() only read the file; removing the unencrypted original is the responsibility of the application. Co-Authored-By: Claude Fable 5 --- docs/laravel.md | 5 +++++ docs/using.md | 3 +++ 2 files changed, 8 insertions(+) diff --git a/docs/laravel.md b/docs/laravel.md index fb7af19..ba938d2 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -78,6 +78,11 @@ record only holds the keys and meta data. The maximum file size is limited by th (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 diff --git a/docs/using.md b/docs/using.md index c875308..0f8f688 100644 --- a/docs/using.md +++ b/docs/using.md @@ -77,3 +77,6 @@ Some things to keep in mind: 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.