Skip to content

Commit

Permalink
Merge pull request #14 from IlicMiljan/add-decryption-failure-placeho…
Browse files Browse the repository at this point in the history
…lder

Allow Placeholder Value When Decryption Fails
  • Loading branch information
IlicMiljan committed Mar 15, 2024
2 parents 1bc45cb + dd206f2 commit bd1e665
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 9 deletions.
53 changes: 45 additions & 8 deletions src/Attribute/Encrypted.php
Expand Up @@ -7,26 +7,63 @@
/**
* Marks a property for encryption.
*
* Use this attribute to indicate that a specific property within a class should
* be encrypted when the class is persisted to a database or any other storage
* medium.
* This attribute indicates that a specific property within a class should be
* encrypted when the class is saved to a database or other storage medium.
*
* The actual encryption and decryption process is expected to be
* handled by the consuming code or framework.
* Additionally, you can specify a nullable placeholder value to be used when
* decryption fails. Depending on the configuration of the consuming code or
* framework, an exception may be thrown during decryption failure if the
* placeholder is null.
*
* For instance, a consuming application may throw an exception in production
* environments to immediately highlight and address decryption issues. On the
* other hand, it might be preferable to use a placeholder value to allow for
* continued testing in development environments, even if the data is encrypted
* with a different key.
*
* @example
* ```php
* class User {
* #[Encrypted]
* #[Encrypted(placeholder: "********")]
* private string $password;
*
* #[Encrypted] // Behavior on decryption failure depends on configuration.
* private string $secret;
* }
* ```
*
* In the above example, the `$password` property of the User class will be
* marked for encryption.
* In the above examples, the `$password` property of the User class will be
* marked for encryption, and "********" may be used as a placeholder if the
* decryption process fails.
* The behavior of the `$secret` property on decryption failure depends on the
* configuration.
*
* @see Attribute::TARGET_PROPERTY Indicates that this attribute can only be
* applied to class properties.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class Encrypted
{
/**
* Constructs the Encrypted attribute.
*
* @param string|null $placeholder The placeholder value to use when
* decryption fails, or null to indicate
* that an exception may be thrown based
* on configuration.
*/
public function __construct(
private ?string $placeholder = null
) {
}

/**
* Retrieves the placeholder value.
*
* @return string|null The placeholder value.
*/
public function getPlaceholder(): ?string
{
return $this->placeholder;
}
}
25 changes: 25 additions & 0 deletions src/Exception/SingleEncryptedAttributeExpected.php
@@ -0,0 +1,25 @@
<?php

namespace IlicMiljan\SecureProps\Exception;

use RuntimeException;
use Throwable;

class SingleEncryptedAttributeExpected extends RuntimeException implements EncryptionServiceException
{
public function __construct(
private int $count,
?Throwable $previous = null
) {
parent::__construct(
'Each property must be annotated with a single instance of the Encrypted attribute.',
0,
$previous
);
}

public function getCount(): int
{
return $this->count;
}
}
40 changes: 39 additions & 1 deletion src/ObjectEncryptionService.php
Expand Up @@ -4,9 +4,12 @@

use IlicMiljan\SecureProps\Attribute\Encrypted;
use IlicMiljan\SecureProps\Cipher\Cipher;
use IlicMiljan\SecureProps\Cipher\Exception\FailedDecryptingValue;
use IlicMiljan\SecureProps\Exception\SingleEncryptedAttributeExpected;
use IlicMiljan\SecureProps\Exception\ValueMustBeObject;
use IlicMiljan\SecureProps\Exception\ValueMustBeString;
use IlicMiljan\SecureProps\Reader\ObjectPropertiesReader;
use ReflectionAttribute;
use ReflectionProperty;
use SensitiveParameter;

Expand Down Expand Up @@ -80,6 +83,41 @@ private function updatePropertyValue(ReflectionProperty $property, object $objec
throw new ValueMustBeString(gettype($currentValue));
}

$property->setValue($object, $callback($currentValue));
try {
$property->setValue($object, $callback($currentValue));
} catch (FailedDecryptingValue $e) {
$this->handleDecryptionFailure($object, $property, $e);
}
}

public function handleDecryptionFailure(
object $object,
ReflectionProperty $property,
FailedDecryptingValue $e,
): void {
$placeholderValue = $this->getPropertyPlaceholderValue($property);

if ($placeholderValue === null) {
throw $e;
}

$property->setValue($object, $placeholderValue);
}

private function getPropertyPlaceholderValue(ReflectionProperty $property): ?string
{
$encryptedAttributes = $property->getAttributes(Encrypted::class);

if (count($encryptedAttributes) !== 1) {
throw new SingleEncryptedAttributeExpected(count($encryptedAttributes));
}

/** @var ReflectionAttribute $encryptedAttribute */
$encryptedAttribute = array_pop($encryptedAttributes);

/** @var Encrypted $encryptedAttributeInstance */
$encryptedAttributeInstance = $encryptedAttribute->newInstance();

return $encryptedAttributeInstance->getPlaceholder();
}
}
30 changes: 30 additions & 0 deletions tests/Attribute/EncryptedTest.php
@@ -0,0 +1,30 @@
<?php

namespace IlicMiljan\SecureProps\Tests\Attribute;

use IlicMiljan\SecureProps\Attribute\Encrypted;
use PHPUnit\Framework\TestCase;

class EncryptedTest extends TestCase
{
public function testCanBeCreated(): void
{
$encrypted = new Encrypted();

$this->assertInstanceOf(Encrypted::class, $encrypted);
}
public function testCanRetrieveSpecifiedPlaceholder(): void
{
$placeholder = "********";
$encryptedAttribute = new Encrypted(placeholder: $placeholder);

$this->assertSame($placeholder, $encryptedAttribute->getPlaceholder());
}

public function testDefaultPlaceholderIsNull(): void
{
$encryptedAttribute = new Encrypted();

$this->assertNull($encryptedAttribute->getPlaceholder());
}
}
9 changes: 9 additions & 0 deletions tests/Attribute/TestAttribute.php
Expand Up @@ -7,4 +7,13 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
class TestAttribute
{
public function __construct(
private ?string $placeholder = null
) {
}

public function getPlaceholder(): ?string
{
return $this->placeholder;
}
}
70 changes: 70 additions & 0 deletions tests/ObjectEncryptionServiceTest.php
Expand Up @@ -5,6 +5,8 @@
use IlicMiljan\SecureProps\Attribute\Encrypted;
use IlicMiljan\SecureProps\Cipher\Cipher;
use IlicMiljan\SecureProps\Cipher\Exception\CipherException;
use IlicMiljan\SecureProps\Cipher\Exception\FailedDecryptingValue;
use IlicMiljan\SecureProps\Exception\EncryptionServiceException;
use IlicMiljan\SecureProps\Exception\ValueMustBeObject;
use IlicMiljan\SecureProps\Exception\ValueMustBeString;
use IlicMiljan\SecureProps\ObjectEncryptionService;
Expand Down Expand Up @@ -218,6 +220,7 @@ public function testEncryptThrowsValueMustBeStringExceptionForNonString(): void
* @throws ReflectionException
* @throws CipherException
* @throws ReaderException
* @throws EncryptionServiceException
*/
public function testDecryptThrowsValueMustBeStringExceptionForNonString(): void
{
Expand All @@ -237,4 +240,71 @@ public function testDecryptThrowsValueMustBeStringExceptionForNonString(): void

$this->service->decrypt($object);
}

/**
* @throws ReflectionException
* @throws ReaderException
* @throws CipherException
* @throws EncryptionServiceException
*/
public function testDecryptSetsPlaceholderOnFailure(): void
{
$object = new class {
#[Encrypted(placeholder: 'placeholder')]
public string $sensitive = 'encryptedText';
};

$reflectionProperty = new ReflectionProperty($object, 'sensitive');
$reflectionProperty->setAccessible(true);

$this->objectPropertiesReaderMock
->expects($this->once())
->method('getPropertiesWithAttribute')
->with($object, Encrypted::class)
->willReturn([$reflectionProperty]);

$this->cipherMock
->expects($this->once())
->method('decrypt')
->with('encryptedText')
->willThrowException(new FailedDecryptingValue());

$decryptedObject = $this->service->decrypt($object);

/** @phpstan-ignore-next-line */
$this->assertEquals('placeholder', $decryptedObject->sensitive);
}

/**
* @throws ReflectionException
* @throws CipherException
* @throws ReaderException
* @throws EncryptionServiceException
*/
public function testDecryptThrowsExceptionWhenNoPlaceholderAvailable(): void
{
$object = new class {
#[Encrypted]
public string $sensitive = 'encryptedText';
};

$reflectionProperty = new ReflectionProperty($object, 'sensitive');
$reflectionProperty->setAccessible(true);

$this->objectPropertiesReaderMock
->expects($this->once())
->method('getPropertiesWithAttribute')
->with($object, Encrypted::class)
->willReturn([$reflectionProperty]);

$this->cipherMock
->expects($this->once())
->method('decrypt')
->with('encryptedText')
->willThrowException(new FailedDecryptingValue());

$this->expectException(FailedDecryptingValue::class);

$this->service->decrypt($object);
}
}

0 comments on commit bd1e665

Please sign in to comment.