From bcd5143fe368c06551629cc81042470da272b87f Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:29 +0000 Subject: [PATCH 01/18] Require PHP 8.2 and ext-sodium, modernise dev dependencies Adds orchestra/testbench (Laravel 12/13), PHPUnit 11, Mockery 1.6, PHPStan with larastan and phpstan-mockery. Fixes the autoload-dev mapping, which pointed the root namespace at tests/ and never actually PSR-4-resolved. Co-Authored-By: Claude Fable 5 --- composer.json | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 04db22e..5b04c06 100644 --- a/composer.json +++ b/composer.json @@ -3,8 +3,12 @@ "description": "Encrypt and decrypt messages in a secure way.", "type": "library", "require-dev": { - "mockery/mockery": "^1.4", - "phpunit/phpunit": "^9" + "larastan/larastan": "^3.0", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10.0|^11.0", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-mockery": "^2.0", + "phpunit/phpunit": "^11.5" }, "license": "MIT", "authors": [ @@ -14,7 +18,8 @@ } ], "require": { - "php": "^7.3|^8.0" + "php": "^8.2", + "ext-sodium": "*" }, "autoload": { "psr-4": { @@ -23,11 +28,12 @@ }, "autoload-dev": { "psr-4": { - "Exonet\\SecureMessage\\": "tests" + "Exonet\\SecureMessage\\Tests\\": "tests/" } }, "scripts": { - "test": "phpunit --testdox tests/" + "test": "phpunit --testdox", + "analyse": "phpstan analyse --no-progress" }, "config": { "sort-packages": true From 2eb3df8ebfa9e7a232745106326c9718aba3f633 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:29 +0000 Subject: [PATCH 02/18] Migrate the core test suite to PHPUnit 11 Rewrites phpunit.xml to the 11.5 schema (backupStaticAttributes no longer exists, coverage include moved to ) with separate Core and Laravel test suites. Renames the test namespace to Exonet\SecureMessage\Tests, adds the Mockery-PHPUnit integration trait so expectations are actually verified, and adds void return types and strict_types to all tests. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++- phpunit.xml | 20 ++++++++++++-------- tests/CryptoTest.php | 32 +++++++++++++++++--------------- tests/FactoryTest.php | 19 ++++++++++++------- tests/SecureMessageTest.php | 18 ++++++++++-------- 5 files changed, 53 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index c1d5303..f46dce8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /vendor/ composer.lock .php-cs-fixer.cache -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +.phpunit.cache \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 8c86f30..a67a7c3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,17 @@ - - - - ./src - - + - - ./tests + + tests + tests/Laravel + + + tests/Laravel + + + src + + diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php index a200caf..3a0a2b1 100644 --- a/tests/CryptoTest.php +++ b/tests/CryptoTest.php @@ -1,6 +1,8 @@ assertSame(4823435472, $metaArray['expires_at']); } - public function testEncryptInvalidKeyLength() + public function testEncryptInvalidKeyLength(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -62,7 +64,7 @@ public function testEncryptInvalidKeyLength() $secureMessage->setVerificationCode('b'); $secureMessage->setDatabaseKey('c'); $secureMessage->setContent('Unit Test'); - $secureMessage->setHitPoints('1337'); + $secureMessage->setHitPoints(1337); $secureMessage->setExpiresAt(10); $this->expectException(InvalidKeyLengthException::class); @@ -70,7 +72,7 @@ public function testEncryptInvalidKeyLength() $crypto->encrypt($secureMessage); } - public function testEncryptInvalidMetaKeyLength() + public function testEncryptInvalidMetaKeyLength(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -79,7 +81,7 @@ public function testEncryptInvalidMetaKeyLength() $secureMessage->setVerificationCode('1234567890'); $secureMessage->setDatabaseKey('databaseKey'); $secureMessage->setContent('Unit Test'); - $secureMessage->setHitPoints('1337'); + $secureMessage->setHitPoints(1337); $secureMessage->setExpiresAt(10); $this->expectException(InvalidKeyLengthException::class); @@ -87,7 +89,7 @@ public function testEncryptInvalidMetaKeyLength() $crypto->encrypt($secureMessage); } - public function testDecrypt() + public function testDecrypt(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -114,7 +116,7 @@ public function testDecrypt() $this->assertNull($decrypted->getDatabaseKey()); } - public function testDecryptSecureMessageIsExpired() + public function testDecryptSecureMessageIsExpired(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -130,7 +132,7 @@ public function testDecryptSecureMessageIsExpired() $crypto->decrypt($secureMessage); } - public function testDecryptHitpointsReached() + public function testDecryptHitpointsReached(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -167,7 +169,7 @@ public function testDecryptHitpointsReached() $this->assertTrue($exceptionThrown); } - public function testDecryptInvalidVerificationCode() + public function testDecryptInvalidVerificationCode(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -204,7 +206,7 @@ public function testDecryptInvalidVerificationCode() $this->assertTrue($exceptionThrown); } - public function testValidateEncryptionKeyCorrectKey() + public function testValidateEncryptionKeyCorrectKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -219,7 +221,7 @@ public function testValidateEncryptionKeyCorrectKey() $this->assertTrue($crypto->validateEncryptionKey($secureMessage)); } - public function testValidateEncryptionKeyIncorrectKey() + public function testValidateEncryptionKeyIncorrectKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -235,7 +237,7 @@ public function testValidateEncryptionKeyIncorrectKey() $this->assertFalse($crypto->validateEncryptionKey($secureMessage)); } - public function testValidateEncryptionKeyKeyTooShort() + public function testValidateEncryptionKeyKeyTooShort(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -251,7 +253,7 @@ public function testValidateEncryptionKeyKeyTooShort() $this->assertFalse($crypto->validateEncryptionKey($secureMessage)); } - public function testDecryptMeta() + public function testDecryptMeta(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); @@ -273,7 +275,7 @@ public function testDecryptMeta() $this->assertNotNull($decrypted->getEncryptedContent()); } - public function testDecryptMetaInvalidMetaKey() + public function testDecryptMetaInvalidMetaKey(): void { $crypto = new Crypto(); $secureMessage = new SecureMessage(); diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php index fb6c5e9..031c6ab 100644 --- a/tests/FactoryTest.php +++ b/tests/FactoryTest.php @@ -1,11 +1,14 @@ assertSame(10, $resultAll->secureMessage->getExpiresAt()); } - public function testEncrypt() + public function testEncrypt(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -60,7 +65,7 @@ public function testEncrypt() $this->assertSame($secureMessageResult, $factory->encrypt()); } - public function testDecrypt() + public function testDecrypt(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -80,7 +85,7 @@ public function testDecrypt() $this->assertSame($secureMessageResult, $factory->decrypt($secureMessage)); } - public function testDecryptMeta() + public function testDecryptMeta(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessageResult = new SecureMessage(); @@ -100,7 +105,7 @@ public function testDecryptMeta() $this->assertSame($secureMessageResult, $factory->decryptMeta($secureMessage)); } - public function testValidateEncryptionKey() + public function testValidateEncryptionKey(): void { $factory = (new Factory('metaKey___'))->make('Unit Test', 3, 1337); $secureMessage = new SecureMessage(); @@ -119,7 +124,7 @@ public function testValidateEncryptionKey() $this->assertTrue($factory->validateEncryptionKey($secureMessage)); } - public function testSetMetaKey() + public function testSetMetaKey(): void { $factory = new Factory(); diff --git a/tests/SecureMessageTest.php b/tests/SecureMessageTest.php index aae9791..d2d5fa0 100644 --- a/tests/SecureMessageTest.php +++ b/tests/SecureMessageTest.php @@ -1,6 +1,8 @@ setDatabaseKey('abc'); @@ -33,7 +35,7 @@ public function testWipeKeysFromMemory() $this->assertNull($secureMessage->getVerificationCode()); } - public function testWipeContentFromMemory() + public function testWipeContentFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setContent('abc'); @@ -42,7 +44,7 @@ public function testWipeContentFromMemory() $this->assertNull($secureMessage->getContent()); } - public function testWipeEncryptedContentFromMemory() + public function testWipeEncryptedContentFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setEncryptedContent('abc'); @@ -51,7 +53,7 @@ public function testWipeEncryptedContentFromMemory() $this->assertNull($secureMessage->getEncryptedContent()); } - public function testWipeEncryptedMetaFromMemory() + public function testWipeEncryptedMetaFromMemory(): void { $secureMessage = new SecureMessage(); $secureMessage->setEncryptedMeta('abc'); @@ -60,7 +62,7 @@ public function testWipeEncryptedMetaFromMemory() $this->assertEmpty($secureMessage->getEncryptionKey()); } - public function testGetEncryptionKey() + public function testGetEncryptionKey(): void { $secureMessage = new SecureMessage(); $secureMessage->setDatabaseKey('abc'); @@ -70,7 +72,7 @@ public function testGetEncryptionKey() $this->assertSame('abcdefghi', $secureMessage->getEncryptionKey()); } - public function testSettersGetters() + public function testSettersGetters(): void { $secureMessage = new SecureMessage(); $this->assertSame('storageKey', $secureMessage->setStorageKey('storageKey')->getStorageKey()); @@ -86,7 +88,7 @@ public function testSettersGetters() $this->assertSame(1, $secureMessage->setExpiresAt(1)->getExpiresAt()); } - public function testIsEncrypted() + public function testIsEncrypted(): void { $secureMessage = new SecureMessage(); From 3fa2368eab6432d87e0cbf5bfe7ddaf5531005db Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:44 +0000 Subject: [PATCH 03/18] Run the Laravel integration tests on orchestra/testbench These tests never ran: they required a host Laravel application bootstrap file and their setUp/tearDown signatures have been fatal since PHPUnit 8. They now run on testbench with an in-memory sqlite database. This also fixes fixtures that omitted NOT NULL columns, a Carbon::setTestNow() leak, a Storage factory mock that stood in for the Filesystem returned by disk(), and moves the tests out of src/ so they no longer ship in dist installs. Co-Authored-By: Claude Fable 5 --- .../tests => tests/Laravel}/FactoryTest.php | 180 ++++++++---------- .../Laravel}/HousekeepingTest.php | 24 +-- 2 files changed, 93 insertions(+), 111 deletions(-) rename {src/Laravel/tests => tests/Laravel}/FactoryTest.php (77%) rename {src/Laravel/tests => tests/Laravel}/HousekeepingTest.php (83%) diff --git a/src/Laravel/tests/FactoryTest.php b/tests/Laravel/FactoryTest.php similarity index 77% rename from src/Laravel/tests/FactoryTest.php rename to tests/Laravel/FactoryTest.php index 9a6bfc0..e63b5e9 100644 --- a/src/Laravel/tests/FactoryTest.php +++ b/tests/Laravel/FactoryTest.php @@ -1,6 +1,8 @@ app->make('db')->beginTransaction(); - } + use MockeryPHPUnitIntegration; + use RefreshDatabase; - protected function tearDown() + protected function tearDown(): void { - $this->app->make('db')->rollBack(); + Carbon::setTestNow(); parent::tearDown(); } - public function createApplication() - { - $app = require __DIR__.'/../../../../bootstrap/app.php'; - $app->make(Kernel::class)->bootstrap(); - - return $app; - } - - public function testEncrypt() + public function testEncrypt(): void { Carbon::setTestNow(Carbon::create(2018, 4, 24, 9, 32, 33)); $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); @@ -73,8 +68,8 @@ public function testEncrypt() ->times(4) ->andReturn('encryptedKey', 'encryptedMeta', 'encryptedContent', 'encryptedDbKey'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('put')->withArgs(['secureMessageKey', 'encryptedKey'])->once(); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('put')->withArgs(['secureMessageKey', 'encryptedKey'])->once(); $factory = new Factory($secureMessageFactoryMock, $storageMock, $encrypterMock, $configMock, $eventMock); $encryptedMessage = $factory->encrypt('Unit Test'); @@ -82,17 +77,13 @@ public function testEncrypt() $this->assertDatabaseHas('secure_messages', ['id' => $encryptedMessage->getId()]); } - public function testDecryptMessage() + public function testDecryptMessage(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); @@ -108,9 +99,9 @@ public function testDecryptMessage() $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->twice()->andReturn('meta'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->twice()->andReturn('content'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->twice()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->twice()->andReturn('encryptedStorageKey'); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->twice()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->twice()->andReturn('encryptedStorageKey'); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('decrypt')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { @@ -130,24 +121,17 @@ public function testDecryptMessage() $this->assertSame('Decrypted content', $factory->decrypt('unitTest', '1337')); } - public function testCheckVerificationCode() + public function testCheckVerificationCode(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); @@ -156,9 +140,9 @@ public function testCheckVerificationCode() $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('validateEncryptionKey')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { @@ -177,24 +161,17 @@ public function testCheckVerificationCode() $this->assertTrue($factory->checkVerificationCode('unitTest', '1337')); } - public function testDecryptMessageStorageKeyNotFound() + public function testDecryptMessageStorageKeyNotFound(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); @@ -203,13 +180,13 @@ public function testDecryptMessageStorageKeyNotFound() $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnFalse(); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnFalse(); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === DecryptionFailed::class; + return $event::class === DecryptionFailed::class; })])->once(); $this->expectException(DecryptException::class); @@ -219,24 +196,17 @@ public function testDecryptMessageStorageKeyNotFound() $factory->decryptMessage('unitTest', '1337'); } - public function testDecryptMessageHitpointLimitReached() + public function testDecryptMessageHitpointLimitReached(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); @@ -245,15 +215,15 @@ public function testDecryptMessageHitpointLimitReached() $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new HitPointLimitReachedException('The maximum number of hit points is reached.')); $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === HitPointLimitReached::class; + return $event::class === HitPointLimitReached::class; })])->once(); $this->expectException(DecryptException::class); @@ -262,24 +232,17 @@ public function testDecryptMessageHitpointLimitReached() $factory->decryptMessage('unitTest', '1337'); } - public function testDecryptMessageMessageExpired() + public function testDecryptMessageMessageExpired(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); @@ -288,15 +251,15 @@ public function testDecryptMessageMessageExpired() $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedContent'])->once()->andReturn('content'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); - $storageMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('decrypt')->withAnyArgs()->once()->andThrow(new ExpiredException('This secure message is expired.')); $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { - return get_class($event) === SecureMessageExpired::class; + return $event::class === SecureMessageExpired::class; })])->once(); $this->expectException(DecryptException::class); @@ -305,17 +268,13 @@ public function testDecryptMessageMessageExpired() $factory->decryptMessage('unitTest', '1337'); } - public function testDecryptMeta() + public function testDecryptMeta(): void { - SecureMessageModel::insert([ - 'id' => 'unitTest', - 'key' => 'encryptedDatabaseKey', - 'meta' => 'encryptedMeta', - 'content' => 'encryptedContent', - ]); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); @@ -326,9 +285,13 @@ public function testDecryptMeta() $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedDatabaseKey'])->once()->andReturn('databaseKey'); $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedMeta'])->once()->andReturn('meta'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('exists')->withArgs(['unitTest'])->once()->andReturnTrue(); + $storageDiskMock->shouldReceive('get')->withArgs(['unitTest'])->once()->andReturn('encryptedStorageKey'); + $encrypterMock->shouldReceive('decrypt')->withArgs(['encryptedStorageKey'])->once()->andReturn('storageKey'); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('decryptMeta')->withArgs([\Mockery::on(function (SecureMessage $secureMessage) { @@ -343,24 +306,22 @@ public function testDecryptMeta() $this->assertSame($decryptedSecureMessage, $factory->getMeta('unitTest')); } - public function testDestroy() + public function testDestroy(): void { - SecureMessageModel::insert(['id' => 'unitTest']); + $this->insertSecureMessageRecord(); $secureMessageFactoryMock = \Mockery::mock(SecureMessageFactory::class); $storageMock = \Mockery::mock(Storage::class); + $storageDiskMock = \Mockery::mock(Filesystem::class); $encrypterMock = \Mockery::mock(Encrypter::class); $configMock = \Mockery::mock(Config::class); $eventMock = \Mockery::mock(Event::class); - $decryptedSecureMessage = new SecureMessage(); - $decryptedSecureMessage->setContent('Decrypted content'); - $configMock->shouldReceive('get')->withArgs(['secure_messages.meta_key'])->once()->andReturn('metaKey'); $configMock->shouldReceive('get')->withArgs(['secure_messages.storage_disk_name'])->once()->andReturn('secure_messages'); - $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturnSelf(); - $storageMock->shouldReceive('delete')->withArgs(['unitTest'])->once()->andReturnSelf(); + $storageMock->shouldReceive('disk')->withArgs(['secure_messages'])->once()->andReturn($storageDiskMock); + $storageDiskMock->shouldReceive('delete')->withArgs(['unitTest'])->once()->andReturnSelf(); $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); @@ -369,4 +330,23 @@ public function testDestroy() $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. + */ + private function insertSecureMessageRecord(): void + { + SecureMessageModel::insert([ + 'id' => 'unitTest', + 'key' => 'encryptedDatabaseKey', + 'meta' => 'encryptedMeta', + 'content' => 'encryptedContent', + 'created_at' => Carbon::now(), + ]); + } } diff --git a/src/Laravel/tests/HousekeepingTest.php b/tests/Laravel/HousekeepingTest.php similarity index 83% rename from src/Laravel/tests/HousekeepingTest.php rename to tests/Laravel/HousekeepingTest.php index 85e2a4a..7aa0883 100644 --- a/src/Laravel/tests/HousekeepingTest.php +++ b/tests/Laravel/HousekeepingTest.php @@ -1,28 +1,25 @@ make(Kernel::class)->bootstrap(); - - return $app; - } + use MockeryPHPUnitIntegration; - public function testHandle() + public function testHandle(): void { $modelMock = \Mockery::mock(SecureMessageModel::class); $factoryMock = \Mockery::mock(SecureMessageFactory::class); @@ -49,4 +46,9 @@ public function testHandle() $command->handle($factoryMock, $modelMock); } + + protected function getPackageProviders($app): array + { + return [SecureMessageServiceProvider::class]; + } } From 549725d91418b9c1fe2b8e4395c5f999ac8ccbfe Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:44 +0000 Subject: [PATCH 04/18] Modernise the code base to PHP 8.2 with strict types declare(strict_types=1) everywhere, typed properties, constructor promotion, readonly and match. Properties wiped by sodium_memzero() stay nullable because memzero nulls its by-reference argument. setMeta() casts hit_points/expires_at to int to preserve v1 behaviour for numeric-string input. Behavioural fixes: - A malformed encrypted message now throws DecryptException instead of crashing with a TypeError (the wire format itself is unchanged). - The Laravel event payload is now a public readonly property; it used to be private without a getter, so listeners could never read it. - The migration is an anonymous class (same filename, so existing installations are unaffected). - The secure_messages config comment claimed the meta key is 32 characters; it is 10. Co-Authored-By: Claude Fable 5 --- docs/examples/basic_example.php | 2 + src/Crypto.php | 34 ++++++--- src/Exceptions/DecryptException.php | 12 ++-- src/Exceptions/ExpiredException.php | 2 + .../HitPointLimitReachedException.php | 2 + src/Exceptions/InvalidKeyException.php | 2 + src/Exceptions/InvalidKeyLengthException.php | 2 + src/Exceptions/SecureMessageException.php | 2 + src/Factory.php | 30 ++++---- src/Laravel/Console/Housekeeping.php | 2 + ...16_142926_create_secure_messages_table.php | 7 +- src/Laravel/Database/SecureMessage.php | 13 +++- src/Laravel/Events/DecryptionFailed.php | 2 + src/Laravel/Events/HitPointLimitReached.php | 2 + src/Laravel/Events/SecureMessageEvent.php | 12 +--- src/Laravel/Events/SecureMessageExpired.php | 2 + src/Laravel/Factory.php | 69 ++++++------------- .../SecureMessageServiceProvider.php | 8 +-- src/Laravel/SecureMessageFacade.php | 4 +- src/Laravel/config/secure_messages.php | 4 +- src/SecureMessage.php | 52 +++++++++----- 21 files changed, 156 insertions(+), 109 deletions(-) diff --git a/docs/examples/basic_example.php b/docs/examples/basic_example.php index 5843fee..da190f2 100644 --- a/docs/examples/basic_example.php +++ b/docs/examples/basic_example.php @@ -1,5 +1,7 @@ isMetaEncrypted() === false) { - if (strlen($secureMessage->getMetaKey()) !== 32) { + if (strlen((string) $secureMessage->getMetaKey()) !== 32) { throw new InvalidKeyLengthException('The key must be 32 bytes/characters.'); } @@ -99,7 +101,7 @@ public function decrypt(SecureMessage $secureMessage): SecureMessage if ($content === false) { $secureMessage = $this->reduceHitPoints($secureMessage); - throw new DecryptException('Unable to or failed decrypt the contents of the message.', $secureMessage); + throw new DecryptException('Unable to or failed to decrypt the contents of the message.', $secureMessage); } $secureMessage->setContent($content); @@ -112,13 +114,15 @@ public function decrypt(SecureMessage $secureMessage): SecureMessage /** * Check if the encryption key can be used to decrypt the message. * - * @param SecureMessage $secureMessage + * @param SecureMessage $secureMessage The secure message to validate the encryption key of. + * + * @throws DecryptException When the encrypted content is malformed. * * @return bool Whether or not the encryption key is valid. */ public function validateEncryptionKey(SecureMessage $secureMessage): bool { - if (strlen($secureMessage->getEncryptionKey()) !== 32 || strlen($secureMessage->getMetaKey()) !== 32) { + if (strlen($secureMessage->getEncryptionKey()) !== 32 || strlen((string) $secureMessage->getMetaKey()) !== 32) { return false; } @@ -151,7 +155,7 @@ public function validateEncryptionKey(SecureMessage $secureMessage): bool */ public function decryptMeta(SecureMessage $secureMessage): SecureMessage { - if (!$secureMessage->isMetaEncrypted() || strlen($secureMessage->getMetaKey()) !== 32) { + if (!$secureMessage->isMetaEncrypted() || strlen((string) $secureMessage->getMetaKey()) !== 32) { throw new DecryptException('Unable to or failed to decrypt the meta data.', $secureMessage); } @@ -190,13 +194,27 @@ private function toString(string $nonce, string $encryptedContent): string * * @param string $content The base64 string as generated by $this->toString(). * + * @throws DecryptException When the given string is not valid base64 or does not contain a nonce/message pair. + * * @return string[] The nonce and the encrypted message. */ private function fromString(string $content): array { - [$nonce, $message] = json_decode(base64_decode($content, true)); + $decoded = base64_decode($content, true); + $parts = $decoded === false ? null : json_decode($decoded, true); + + if (!is_array($parts) || count($parts) !== 2 || !is_string($parts[0]) || !is_string($parts[1])) { + throw new DecryptException('The encrypted message is malformed.'); + } + + $nonce = base64_decode($parts[0], true); + $message = base64_decode($parts[1], true); + + if ($nonce === false || $message === false) { + throw new DecryptException('The encrypted message is malformed.'); + } - return ['nonce' => base64_decode($nonce, true), 'data' => base64_decode($message, true)]; + return ['nonce' => $nonce, 'data' => $message]; } /** @@ -207,7 +225,7 @@ private function fromString(string $content): array * * @throws HitPointLimitReachedException If there are no hit points remaining. * - * @return SecureMessage The secure message with the hit points reduces by 1. + * @return SecureMessage The secure message with the hit points reduced by 1. */ private function reduceHitPoints(SecureMessage $secureMessage): SecureMessage { diff --git a/src/Exceptions/DecryptException.php b/src/Exceptions/DecryptException.php index 7acac40..cdba670 100644 --- a/src/Exceptions/DecryptException.php +++ b/src/Exceptions/DecryptException.php @@ -1,5 +1,7 @@ metaKey); } @@ -191,7 +197,7 @@ protected function generateId(): string * Create three keys (with a length of 32 bytes in total) that will be used to encrypt the message. The keys are * divided in three parts, so they can be stored at three different locations. * - * @return array The keys to use as encryption key. + * @return string[] The keys to use as encryption key. */ protected function generateKeys(): array { diff --git a/src/Laravel/Console/Housekeeping.php b/src/Laravel/Console/Housekeeping.php index fb6ba14..fb57954 100644 --- a/src/Laravel/Console/Housekeeping.php +++ b/src/Laravel/Console/Housekeeping.php @@ -1,5 +1,7 @@ secureMessage = $secureMessage; - } + public function __construct(public readonly SecureMessage $secureMessage) {} } diff --git a/src/Laravel/Events/SecureMessageExpired.php b/src/Laravel/Events/SecureMessageExpired.php index 9295734..1258deb 100644 --- a/src/Laravel/Events/SecureMessageExpired.php +++ b/src/Laravel/Events/SecureMessageExpired.php @@ -1,5 +1,7 @@ secureMessageFactory = $secureMessageFactory->setMetaKey($config->get('secure_messages.meta_key')); $this->storage = $storage->disk($config->get('secure_messages.storage_disk_name')); - $this->laravelEncryption = $laravelEncryption; - $this->config = $config; - $this->event = $event; } /** @@ -77,6 +62,7 @@ public function __construct( * * @param string $content The content to store secure. * @param Carbon|null $expireDate The expire date of the secure message. (Optional) + * @param int|null $hitPoints The number of hit points. (Optional) * * @return SecureMessage The secure message. */ @@ -119,9 +105,9 @@ public function encrypt(string $content, ?Carbon $expireDate = null, ?int $hitPo * @param string $secureMessageId The secure message ID. * @param string $verificationCode The verification code for the secure message. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the secure message can not be decrypted. * - * @return string The contents of the secure message. + * @return string|null The contents of the secure message. */ public function decrypt(string $secureMessageId, string $verificationCode): ?string { @@ -137,7 +123,7 @@ public function decrypt(string $secureMessageId, string $verificationCode): ?str * @param string $secureMessageId The secure message ID. * @param string $verificationCode The verification code for the secure message. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the secure message can not be decrypted. * * @return SecureMessage The decrypted secure message, with the keys removed. */ @@ -173,22 +159,11 @@ public function decryptMessage(string $secureMessageId, string $verificationCode } // Dispatch events. - switch (get_class($exception)) { - case HitPointLimitReachedException::class: - $this->event->dispatch(new HitPointLimitReached($secureMessage)); - - break; - - case ExpiredException::class: - $this->event->dispatch(new SecureMessageExpired($secureMessage)); - - break; - - default: - $this->event->dispatch(new DecryptionFailed($secureMessage)); - - break; - } + match ($exception::class) { + HitPointLimitReachedException::class => $this->event->dispatch(new HitPointLimitReached($secureMessage)), + ExpiredException::class => $this->event->dispatch(new SecureMessageExpired($secureMessage)), + default => $this->event->dispatch(new DecryptionFailed($secureMessage)), + }; // And throw the exception again, so the user can catch it. throw $exception; @@ -232,7 +207,7 @@ public function checkVerificationCode(string $secureMessageId, string $verificat * * @param string $secureMessageId The secure message ID. * - * @throws DecryptException If the secure message can not be encrypted. + * @throws DecryptException If the meta data can not be decrypted. * * @return SecureMessage The secure message with only the (decrypted) meta. */ @@ -271,7 +246,7 @@ public function getMeta(string $secureMessageId): SecureMessage * * @param string $secureMessageId The secure message ID. */ - public function destroy(string $secureMessageId) + public function destroy(string $secureMessageId): void { SecureMessageModel::destroy($secureMessageId); $this->storage->delete($secureMessageId); diff --git a/src/Laravel/Providers/SecureMessageServiceProvider.php b/src/Laravel/Providers/SecureMessageServiceProvider.php index 958ae39..b3a01ea 100644 --- a/src/Laravel/Providers/SecureMessageServiceProvider.php +++ b/src/Laravel/Providers/SecureMessageServiceProvider.php @@ -1,5 +1,7 @@ publishes([ __DIR__.'/../config/secure_messages.php' => config_path('secure_messages.php'), @@ -31,8 +33,6 @@ public function register(): void $this->commands([Housekeeping::class]); // Create a container binding (used by the facade). - $this->app->bind('secureMessage', function () { - return $this->app->make(LaravelSecureMessageFactory::class); - }); + $this->app->bind('secureMessage', fn () => $this->app->make(LaravelSecureMessageFactory::class)); } } diff --git a/src/Laravel/SecureMessageFacade.php b/src/Laravel/SecureMessageFacade.php index 87fc00a..9b58693 100644 --- a/src/Laravel/SecureMessageFacade.php +++ b/src/Laravel/SecureMessageFacade.php @@ -1,5 +1,7 @@ Array holding the different keys used for this secure message. + * + * Note: these keys are wiped with sodium_memzero(), which nulls its by-reference argument. The + * array values must therefore always allow null. */ - private $keys = ['database' => null, 'storage' => null, 'verification' => null, 'meta' => null]; + private array $keys = ['database' => null, 'storage' => null, 'verification' => null, 'meta' => null]; /** - * @var string The message content. Can be plain text or encrypted. + * @var string|null The message content. Can be plain text or encrypted. Nullable because it is + * wiped with sodium_memzero(), which nulls its by-reference argument. */ - private $content; + private ?string $content = null; /** - * @var string The encrypted version of the content. + * @var string|null The encrypted version of the content. Nullable because it is wiped with + * sodium_memzero(), which nulls its by-reference argument. */ - private $contentEncrypted; + private ?string $contentEncrypted = null; /** - * @var int[] The meta data for this secure message. + * @var array The meta data for this secure message. */ - private $meta = ['hit_points' => null, 'expires_at' => null]; + private array $meta = ['hit_points' => null, 'expires_at' => null]; /** - * @var string[] The encrypted version of the meta. + * @var string|null The encrypted version of the meta. Nullable because it is wiped with + * sodium_memzero(), which nulls its by-reference argument. */ - private $metaEncrypted; + private ?string $metaEncrypted = null; /** * Wipe the sensitive keys from memory. @@ -161,7 +169,7 @@ public function isContentEncrypted(): bool } /** - * Set the boolean indicating the content is encrypted. + * Set the encrypted content. * * @param string $encrypted The encrypted content. * @@ -195,7 +203,7 @@ public function isMetaEncrypted(): bool } /** - * Set the boolean indicating the meta is encrypted. + * Set the encrypted meta data. * * @param string $encrypted The encrypted meta data. * @@ -229,8 +237,7 @@ public function getContent(): ?string } /** - * Set the content. Can be encrypted or unencrypted. Don't forget to also set the 'encrypted' boolean when updating - * this value. + * Set the content. Can be encrypted or unencrypted. * * @param string $content The content. * @@ -374,14 +381,23 @@ public function getMeta(): array } /** - * Set all meta data for this message. + * Set all meta data for this message. The hit points and expire timestamp are cast to integers + * to keep the (strictly typed) meta getters working for callers that provide numeric strings. * - * @param int[] The meta data. + * @param mixed[] $metaData The meta data. * * @return $this The current secure message instance. */ public function setMeta(array $metaData): self { + if (isset($metaData['hit_points'])) { + $metaData['hit_points'] = (int) $metaData['hit_points']; + } + + if (isset($metaData['expires_at'])) { + $metaData['expires_at'] = (int) $metaData['expires_at']; + } + $this->meta = $metaData; return $this; From aad421ceef55c55b3928267cd4708edfbd11aef7 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:58 +0000 Subject: [PATCH 05/18] Add PHPStan static analysis at level 6 Uses larastan for the Eloquent magic and phpstan-mockery for the test mocks. Runs clean via 'composer analyse'. Co-Authored-By: Claude Fable 5 --- phpstan.neon.dist | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 phpstan.neon.dist diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..1d4efb5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + level: 6 + paths: + - src + - tests + excludePaths: + # A Laravel config file, not code. Larastan only allows env() calls in an + # application config/ directory, which this package directory is not. + - src/Laravel/config/secure_messages.php From 9fa9370d3aaf0d19fef83d0aa7059cd7aa30e041 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:59 +0000 Subject: [PATCH 06/18] Rewrite CI: PHP 8.2-8.4 matrix against Laravel 12 and 13 Replaces the php-actions images with shivammathur/setup-php and the composer-managed PHPUnit. Laravel 11 is EOL with open security advisories (composer refuses to install it), so the matrix covers testbench 10 (Laravel 12) and 11 (Laravel 13, PHP 8.3+). Adds a PHPStan job and the declare_strict_types rule to php-cs-fixer; the auto-commit style job is unchanged. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 57 ++++++++++++++++++++++++++++----------- .php-cs-fixer.php | 2 ++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3fa1896..103c9a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,33 +11,60 @@ on: jobs: run-tests: - name: Run tests + name: Tests (PHP ${{ matrix.php }}, testbench ${{ matrix.testbench }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - versions: [ - { php: "7.3", phpunit: "9" }, - { php: "7.4", phpunit: "9" }, - { php: "8.0", phpunit: "9" }, - { php: "8.1", phpunit: "10" }, - ] + php: ["8.2", "8.3", "8.4"] + # orchestra/testbench 10 = Laravel 12, 11 = Laravel 13. + testbench: ["^10.0", "^11.0"] + exclude: + # orchestra/testbench 11 (Laravel 13) requires PHP >= 8.3. + - php: "8.2" + testbench: "^11.0" steps: - name: Checkout repository uses: actions/checkout@v5 - - name: Composer install - uses: php-actions/composer@v6 + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - php_version: ${{ matrix.versions.php }} + php-version: ${{ matrix.php }} + extensions: sodium + coverage: none + + - name: Select testbench version + run: composer require --dev --no-update "orchestra/testbench:${{ matrix.testbench }}" + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress - name: Run unit tests - uses: php-actions/phpunit@v3.0.3 + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - configuration: phpunit.xml - version: ${{ matrix.versions.phpunit }} - php_version: ${{ matrix.versions.php }} + php-version: "8.3" + extensions: sodium + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress check-code-style: name: Check code style @@ -53,7 +80,7 @@ jobs: - name: Run php-cs-fixer uses: docker://oskarstark/php-cs-fixer-ga - + - name: Apply php-cs-fixer changes uses: stefanzweifel/git-auto-commit-action@v6 with: diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index b9c9edd..330f1a6 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -8,7 +8,9 @@ $config = new PhpCsFixer\Config(); return $config + ->setRiskyAllowed(true) ->setRules([ + 'declare_strict_types' => true, '@PSR2' => true, '@Symfony' => true, '@PhpCsFixer' => true, From ddc9cecf32596b55ba296a3de698189ac8aaa8c7 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:59 +0000 Subject: [PATCH 07/18] Update documentation for v2 Fixes the meta key examples (the README used 32 characters, docs/using.md used 11; the required length is exactly 10), documents the new requirements and adds an upgrade guide covering the breaking changes. Co-Authored-By: Claude Fable 5 --- README.md | 27 ++++++++++++++++++++++++--- docs/laravel.md | 2 +- docs/using.md | 4 ++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 718aee8..c8920d6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ The verification code can be sent (securely) to the receiver of the secure messa message and read it. ## Requirements -This package requires at least PHP 7.3 with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled. +This package requires PHP 8.2 or newer with the [sodium](https://www.php.net/manual/en/sodium.installation.php) extension enabled. +The optional Laravel integration supports Laravel 12 and 13. For PHP 7.3 up to 8.1, use version 1.x of this package. ## Install @@ -33,8 +34,8 @@ $ composer require exonet/securemessage ```php // Create the factory. $secureMessageFactory = new Exonet\SecureMessage\Factory(); -// Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('A_10_random_characters_long_key.'); +// Set the (application wide) meta key. This key must be exactly 10 characters long. +$secureMessageFactory->setMetaKey('djuyteb765'); // Create a new SecureMessage. Note: it is not encrypted yet! $secureMessage = $secureMessageFactory->make('Hello, world!'); @@ -44,6 +45,26 @@ $encryptedMessage = $secureMessage->encrypt(); Please see the `/docs` folder for complete documentation and additional examples. +## Upgrading from v1 + +Messages encrypted with v1 can still be decrypted with v2: the encrypted format and the key structure are unchanged. +Notable changes: + +- PHP 8.2 or newer is required and the Laravel integration requires Laravel 12 or 13. +- The `sodium` extension is now a hard composer requirement (`ext-sodium`). On servers without the extension, + `composer install` fails immediately instead of the package failing at the first encrypt. +- The whole code base is strictly typed (`declare(strict_types=1)`). Make sure you pass the documented types. + In particular, check your published `config/secure_messages.php`: `hit_points` and `expires_in` must be real + integers. A numeric string (for example from an `env()` call) was silently coerced by v1, but throws a + `TypeError` in v2. +- The Laravel events (`DecryptionFailed`, `HitPointLimitReached`, `SecureMessageExpired`) now expose the secure + message through a `public readonly` property `$secureMessage` (previously this property was private and inaccessible + to listeners). +- A malformed encrypted message now throws a `DecryptException` when decrypting or validating a key, instead of + failing with a PHP error. Code catching `TypeError` for this case should catch `DecryptException` instead. +- The migration class `CreateSecureMessagesTable` is now an anonymous class. The migration filename is unchanged, + so existing installations are unaffected, but code referencing the class by name no longer works. + ## Change log Please see [releases][link-releases] for more information on what has changed recently. diff --git a/docs/laravel.md b/docs/laravel.md index aefdf6c..81f3a58 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -2,7 +2,7 @@ ### Installation - Run `composer require exonet/securemessage`. -- If you use Laravel 5.5 or newer, the required ServiceProvider is automatically registered. For older Laravel versions you need to register the service provider `\Exonet\SecureMessage\Laravel\Providers\SecureMessageServiceProvider::class` in your `config/app.php`. +- The required ServiceProvider is automatically registered via package discovery. - In your `.env` file add the following key `SECURE_MESSAGE_META_KEY`. Give it an alphanumeric 10 characters long [random](https://www.random.org/strings/?num=1&len=10&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new) value. - In your `config/filesystems.php` file, add a new storage disk with the name `secure_messages`. For example: `'secure_messages' => ['driver' => 'local', 'root' => storage_path('/secure_messages')],`. - (optional) If you'd like to change the storage disk name, default hit points or default expire date, run `php artisan vendor:publish --provider="Exonet\\SecureMessage\\Laravel\\Providers\\SecureMessageServiceProvider" --tag=config` to get the config file to edit those settings. diff --git a/docs/using.md b/docs/using.md index ff5965b..1c043bd 100644 --- a/docs/using.md +++ b/docs/using.md @@ -6,7 +6,7 @@ By using the provided factory it is pretty easy to create a new secure message: // Create the factory. $secureMessageFactory = new \Exonet\SecureMessage\Factory(); // Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('djuyteb765d'); +$secureMessageFactory->setMetaKey('djuyteb765'); // Create a new SecureMessage. Note: it is not encrypted yet! $secureMessage = $secureMessageFactory->make('Hello, world!'); @@ -31,7 +31,7 @@ Assuming you've the correct keys: // Create the factory. $secureMessageFactory = new Exonet\SecureMessage\Factory(); // Set the (application wide) meta key. -$secureMessageFactory->setMetaKey('djuyteb765d'); +$secureMessageFactory->setMetaKey('djuyteb765'); $secureMessage = new \Exonet\SecureMessage\SecureMessage(); $secureMessage->setEncryptedContent('[the encrypted content]'); From c18f0606825037592706439b82340e45e934f74f Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 08:59:59 +0000 Subject: [PATCH 08/18] Add AGENTS.md with guidance for AI agents Co-Authored-By: Claude Fable 5 --- AGENTS.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f6a364d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +## What this is + +`exonet/securemessage` is a framework-agnostic PHP library (with an optional +Laravel integration) for encrypting messages using libsodium secretbox. The +32-byte encryption key is deliberately split into three parts stored in +different places, so a single compromised store never yields a complete key: + +- **database key** — 11 random bytes, stored in a database. +- **storage key** — 11 random bytes, stored on a disk/filesystem. +- **verification code** — 10 characters, never stored; sent to the recipient. + +The message meta data (expiry timestamp, remaining "hit points" = allowed +failed decrypt attempts) is encrypted separately with a **meta key**: an +application-wide 10-character key concatenated with the database and storage +keys (10 + 11 + 11 = 32 bytes). + +## Layout + +- `src/Crypto.php` — sodium encrypt/decrypt, hit-point reduction, key validation. +- `src/Factory.php` — creates messages, generates the three key parts. +- `src/SecureMessage.php` — value object holding content, keys and meta; has + `wipe*FromMemory()` methods built on `sodium_memzero()`. +- `src/Exceptions/` — all extend `SecureMessageException`; `ExpiredException` + and `HitPointLimitReachedException` extend `DecryptException`. +- `src/Laravel/` — service provider, facade, Eloquent model + migration, + config, events and the `secure_message:housekeeping` command. Persists the + storage key via a Laravel filesystem disk and the rest in the database, each + wrapped in Laravel's own `Encrypter` as a second layer. +- `tests/` — PHPUnit tests for the core library; `tests/Laravel/` — tests for + the Laravel integration, running on orchestra/testbench (in-memory sqlite). +- `docs/` — usage documentation and a runnable example. + +## Commands + +- `composer test` — runs the whole suite (testsuites `Core` and `Laravel`, + PHPUnit 11). Requires PHP with the `sodium` extension (available locally). +- `composer analyse` — PHPStan level 6 (with larastan and phpstan-mockery), + configured in `phpstan.neon.dist`. Keep it clean. +- Code style is enforced by php-cs-fixer using `.php-cs-fixer.php` + (`@PSR2` + `@Symfony` + `@PhpCsFixer` plus overrides, including + `declare_strict_types`). CI runs it on every PR **and auto-commits the + fixes to the PR branch**, so don't be surprised by extra commits; running + the fixer locally before pushing avoids them. + +## Constraints and gotchas + +- **PHP compatibility: `^8.2`** (v2). CI tests 8.2, 8.3 and 8.4, against both + Laravel 12 (testbench `^10.0`) and Laravel 13 (testbench `^11.0`). Typed + properties, promotion, readonly and match are in use; typed class constants + are NOT (8.3+ feature). +- **Everything is `declare(strict_types=1)`.** When adding code paths, mind + implicit coercions that no longer happen (e.g. `SecureMessage::setMeta()` + deliberately casts `hit_points`/`expires_at` to int for this reason). +- **No production dependencies** other than `php` and `ext-sodium`. The + Laravel classes reference `illuminate/*` and `nesbot/carbon`, which resolve + via orchestra/testbench in dev and via the host app in production. Don't + add them to `require`. +- **`sodium_memzero()` nulls its by-reference argument.** Every property that + gets wiped in `SecureMessage::wipe*FromMemory()` must stay nullable + (`?string = null`) and must never be `readonly`, or wiping throws a + `TypeError` in the security-critical path. +- **Don't add native types to inherited Laravel properties** (`$table`, + `$incrementing`, `$keyType`, `$signature`, `$description`) — the parents + declare them untyped, so typing them is a fatal error. +- **The migration filename must never change.** Laravel records migrations by + filename; renaming re-runs it and crashes existing installs. +- **Key lengths are load-bearing.** `Factory::setMetaKey()` requires exactly + 10 characters; `Crypto` requires the *combined* keys to be exactly 32 bytes + (11-byte database key + 11-byte storage key + 10-char verification code). +- **Security invariants — preserve them when touching `Crypto`/`SecureMessage`:** + nonces are randomly generated per encryption and never reused; failed or + invalid decrypt attempts must keep reducing hit points (this is the + brute-force protection); plaintext, keys and decrypted meta are wiped with + `sodium_memzero()` after use. Don't weaken or reorder these paths. +- Changing the encrypted wire format (`Crypto::toString()`/`fromString()`: + base64 of a JSON array of base64 nonce + ciphertext) breaks decryption of + all previously stored messages — treat it as a breaking change. + +## Conventions + +- Every method has a full PHPDoc block (`@param`/`@throws`/`@return` with + descriptions) — match this style; php-cs-fixer enforces the ordering. +- Setters return `$this` (fluent); properties are `private` with getters/setters. +- Follow SemVer. PRs need tests, documentation updates for behaviour changes, + and **exactly the labels CI expects** (`bugfix`, `new-feature`, + `breaking-change`, `enhancement`, `documentation`, `dependencies`, + `maintenance`, `ci`, …) — the `verify-pr-labels` workflow blocks unlabeled + PRs, and release-drafter builds the changelog and version bump from labels. +- Security issues go to development@exonet.nl, never the public issue tracker. From 757c9ca4fb30d9feaa7ff3240e2293a54ac686f9 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 09:30:11 +0000 Subject: [PATCH 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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. From 7d2ca68e575f94b96b7a3ab4a85add2a35523433 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Tue, 11 Aug 2026 10:01:01 +0000 Subject: [PATCH 15/18] Wipe keys before dispatching decrypt-failure events The Laravel events expose their SecureMessage as a public readonly property, so listeners (and anything they serialise the event to, such as a queued listener writing to Redis) can read it. Most decrypt-failure paths hand over a wiped instance, because the DecryptException constructor wipes the keys when it is given the secure message. Three paths throw without it and left the decrypted key material on the dispatched instance: - a missing storage key file (database key + verification code present); - malformed stored ciphertext (all key parts present); - a missing file blob (database key + verification code present). This violates the split-key promise that the key parts never co-locate. Wipe the secure message in the catch block, before any event is dispatched, regardless of whether the exception carried it. The already-wiped paths are unaffected (wiping is idempotent). Regression tests assert the dispatched event carries no key material on both null paths. Co-Authored-By: Claude Fable 5 --- src/Laravel/Factory.php | 7 +++++++ tests/Laravel/FactoryTest.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Laravel/Factory.php b/src/Laravel/Factory.php index 22dd73e..28fe09a 100644 --- a/src/Laravel/Factory.php +++ b/src/Laravel/Factory.php @@ -259,6 +259,13 @@ public function decryptMessage(string $secureMessageId, string $verificationCode $record->save(); } + // Wipe the keys before the secure message is handed to event listeners. Most failure paths + // already wipe the keys (the DecryptException constructor does so when it is given the secure + // message), but the paths that throw without it - a missing key file, a missing file blob or + // malformed stored ciphertext - would otherwise expose the decrypted keys on this instance to + // listeners (and to anything they serialize the event to, such as a queue). + $secureMessage->wipeKeysFromMemory(); + // Dispatch events. match ($exception::class) { HitPointLimitReachedException::class => $this->event->dispatch(new HitPointLimitReached($secureMessage)), diff --git a/tests/Laravel/FactoryTest.php b/tests/Laravel/FactoryTest.php index 288d081..3b95fcb 100644 --- a/tests/Laravel/FactoryTest.php +++ b/tests/Laravel/FactoryTest.php @@ -187,6 +187,13 @@ public function testDecryptMessageStorageKeyNotFound(): void $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + // The event must not expose any key material to listeners, even though this failure path + // throws without a secure message (so the DecryptException constructor never wiped it). + $this->assertNull($event->secureMessage->getDatabaseKey()); + $this->assertNull($event->secureMessage->getStorageKey()); + $this->assertNull($event->secureMessage->getMetaKey()); + $this->assertNull($event->secureMessage->getVerificationCode()); + return $event::class === DecryptionFailed::class; })])->once(); @@ -489,6 +496,13 @@ public function testDecryptFileMessageBlobMissing(): void $secureMessageFactoryMock->shouldReceive('setMetaKey')->withArgs(['metaKey'])->once()->andReturnSelf(); $eventMock->shouldReceive('dispatch')->withArgs([\Mockery::on(function ($event) { + // The event must not expose any key material to listeners, even though this failure path + // throws without a secure message (so the DecryptException constructor never wiped it). + $this->assertNull($event->secureMessage->getDatabaseKey()); + $this->assertNull($event->secureMessage->getStorageKey()); + $this->assertNull($event->secureMessage->getMetaKey()); + $this->assertNull($event->secureMessage->getVerificationCode()); + return $event::class === DecryptionFailed::class; })])->once(); From 6fde4cf3ba0a67785684c149b7cd5ba357012b5b Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Thu, 27 Aug 2026 12:47:03 +0000 Subject: [PATCH 16/18] Clarify the two size factors in the docs The 1.8x in using.md is the core encrypted payload; the 3x in laravel.md and the config is the stored blob, which the Laravel integration encrypts a second time. Both are measured values; the note makes the difference explicit so they no longer read as a contradiction. Co-Authored-By: Claude Fable 5 --- docs/using.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/using.md b/docs/using.md index 0f8f688..0f7b009 100644 --- a/docs/using.md +++ b/docs/using.md @@ -72,7 +72,8 @@ $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. + message is roughly 1.8 times the original file size. An integration may add its own encryption on top of this: + the Laravel integration double encrypts, bringing the stored blob to roughly 3 times the original file size. - The file name must be valid UTF-8 (the meta data is JSON encoded). For files with a non-UTF-8 name, pass an explicit name: `$factory->makeFile($path, fileName: 'sanitized-name.bin')`. The same applies to files on temporary paths (such as uploads), where the base name of the path is meaningless. From 5bab28253a4c9fcbe9b001763076cb268bcf7518 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Thu, 27 Aug 2026 12:50:14 +0000 Subject: [PATCH 17/18] Document why UTF-8 validation uses PCRE mb_check_encoding reads nicer, but mbstring is not a package dependency and PCRE always is; both validate strict UTF-8 equally. Co-Authored-By: Claude Fable 5 --- src/SecureMessage.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SecureMessage.php b/src/SecureMessage.php index a04fd59..40c4333 100644 --- a/src/SecureMessage.php +++ b/src/SecureMessage.php @@ -433,6 +433,7 @@ public function isFile(): bool */ public function setFileName(string $fileName): self { + // UTF-8 validation via PCRE instead of mb_check_encoding: mbstring is not a package dependency. if (preg_match('//u', $fileName) !== 1) { throw new InvalidFileException('The file name must be valid UTF-8.'); } @@ -466,6 +467,7 @@ public function getFileName(): ?string */ public function setMimeType(string $mimeType): self { + // UTF-8 validation via PCRE instead of mb_check_encoding: mbstring is not a package dependency. if (preg_match('//u', $mimeType) !== 1) { throw new InvalidFileException('The mime type must be valid UTF-8.'); } From 8de8b7053e31ae5f9da372e9ca7791ee53cd40f3 Mon Sep 17 00:00:00 2001 From: Robbin Janssen Date: Thu, 27 Aug 2026 13:04:56 +0000 Subject: [PATCH 18/18] Document the composed 32 byte meta key The configured meta key is 10 characters; the key the crypto validates is the composed database key (11) + storage key (11) + meta key (10). Spelled out so the 10 in the config and the 32 in Crypto no longer read as a contradiction. Co-Authored-By: Claude Fable 5 --- docs/using.md | 3 ++- src/SecureMessage.php | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/using.md b/docs/using.md index 1c043bd..6f0e71c 100644 --- a/docs/using.md +++ b/docs/using.md @@ -18,7 +18,8 @@ $encryptedMessage = $secureMessage->encrypt(); after storing them, you call `$encryptedMessage->wipeKeysFromMemory()` to securely erase the keys. The `meta key` is a string of 10 characters that is used when encrypting the meta data in combination with the database -and storage key. This can be the same key for each secure message (because of the use of the database and storage +and storage key: together they form the 32 byte key that the encryption requires (11 + 11 + 10), so the code that +validates a 32 character meta key operates on that composed key, not on the configured one. This can be the same key for each secure message (because of the use of the database and storage keys the complete key used for encryption is never the same) or per secure message. However, if you're using a meta key per secure message, please note that you must store it somewhere or that you can recreate it, because it is necessary for every decrypt/validation action. diff --git a/src/SecureMessage.php b/src/SecureMessage.php index 1867d60..87a8dba 100644 --- a/src/SecureMessage.php +++ b/src/SecureMessage.php @@ -107,9 +107,11 @@ public function getEncryptionKey(): string } /** - * Get the meta key. + * Get the composed meta encryption key: database key (11) + storage key (11) + the configured + * 10 character meta key = the 32 bytes sodium requires. The configured key alone is never + * enough to decrypt the meta data. * - * @return string|null The meta key. + * @return string|null The composed 32 byte meta key. */ public function getMetaKey(): ?string {