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();