Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Sealed/DecompressionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

namespace Fingerprint\ServerAPI\Sealed;

class DecompressionException extends \Exception
use Exception;

/**
* Thrown when decompression of the decrypted sealed data fails.
*/
class DecompressionException extends Exception
{
/**
* Creates a new DecompressionException instance.
*/
public function __construct()
{
parent::__construct('Decompression failed');
Expand Down
3 changes: 3 additions & 0 deletions src/Sealed/DecryptionAlgorithm.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Fingerprint\ServerAPI\Sealed;

/**
* Supported decryption algorithms for sealed results.
*/
class DecryptionAlgorithm
{
public const AES_256_GCM = 'aes-256-gcm';
Expand Down
25 changes: 20 additions & 5 deletions src/Sealed/DecryptionKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,38 @@

namespace Fingerprint\ServerAPI\Sealed;

/**
* Holds a decryption key and its algorithm for unsealing sealed results.
*/
class DecryptionKey
{
private $key;
private $algorithm;
private readonly string $key;
private readonly string $algorithm;

public function __construct($key, $algorithm)
/**
* Creates a new DecryptionKey instance.
*
* @param string $key raw binary decryption key
* @param string $algorithm algorithm identifier ({@see DecryptionAlgorithm})
*/
public function __construct(string $key, string $algorithm)
{
$this->key = $key;
$this->algorithm = $algorithm;
}

public function getKey()
/**
* Returns the raw binary decryption key.
*/
public function getKey(): string
{
return $this->key;
}

public function getAlgorithm()
/**
* Returns the algorithm identifier ({@see DecryptionAlgorithm}).
*/
public function getAlgorithm(): string
{
return $this->algorithm;
}
Expand Down
10 changes: 9 additions & 1 deletion src/Sealed/InvalidSealedDataException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

namespace Fingerprint\ServerAPI\Sealed;

class InvalidSealedDataException extends \InvalidArgumentException
use InvalidArgumentException;

/**
* Thrown when decrypted sealed data does not contain a valid event response.
*/
class InvalidSealedDataException extends InvalidArgumentException
{
/**
* Creates a new InvalidSealedDataException instance.
*/
public function __construct()
{
parent::__construct('Invalid sealed data');
Expand Down
10 changes: 9 additions & 1 deletion src/Sealed/InvalidSealedDataHeaderException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

namespace Fingerprint\ServerAPI\Sealed;

class InvalidSealedDataHeaderException extends \InvalidArgumentException
use InvalidArgumentException;

/**
* Thrown when the sealed payload does not start with the expected header bytes.
*/
class InvalidSealedDataHeaderException extends InvalidArgumentException
{
/**
* Creates a new InvalidSealedDataHeaderException instance.
*/
public function __construct()
{
parent::__construct('Invalid sealed data header');
Expand Down
70 changes: 49 additions & 21 deletions src/Sealed/Sealed.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,37 @@

namespace Fingerprint\ServerAPI\Sealed;

use Exception;
use Fingerprint\ServerAPI\Model\EventsGetResponse;
use Fingerprint\ServerAPI\ObjectSerializer;
use Fingerprint\ServerAPI\SerializationException;
use GuzzleHttp\Psr7\Response;
use InvalidArgumentException;

/**
* Provides methods to decrypt and deserialize sealed results.
*
* Sealed results are encrypted and compressed payloads.
*/
class Sealed
{
/** @var int AES-256-GCM nonce length in bytes. */
private const NONCE_LENGTH = 12;

/** @var int AES-256-GCM authentication tag length in bytes. */
private const AUTH_TAG_LENGTH = 16;
private static $SEAL_HEADER = "\x9E\x85\xDC\xED";

/** @var string Magic bytes that prefix every sealed payload. */
private const SEAL_HEADER = "\x9E\x85\xDC\xED";

/**
* @param DecryptionKey[] $keys
* Unseals and deserializes a sealed response into an EventsGetResponse.
*
* @param string $sealed raw sealed payload
* @param DecryptionKey[] $keys decryption keys to try in order
*
* @throws UnsealAggregateException
* @throws SerializationException
* @throws UnsealAggregateException when decryption fails with every provided key
* @throws InvalidSealedDataException when decrypted payload is not a valid event response
* @throws Exception
*/
public static function unsealEventResponse(string $sealed, array $keys): EventsGetResponse
{
Expand All @@ -37,14 +52,17 @@ public static function unsealEventResponse(string $sealed, array $keys): EventsG
/**
* Decrypts the sealed response with the provided keys.
*
* @param string $sealed Base64 encoded sealed data
* @param DecryptionKey[] $keys Decryption keys. The SDK will try to decrypt the result with each key until it succeeds.
* Tries each key in order; returns the decrypted plaintext on the first
* successful decryption. If all keys fail, throws an aggregate exception.
*
* @throws UnsealAggregateException
* @param string $sealed raw sealed payload
* @param DecryptionKey[] $keys decryption keys to try in order
*
* @throws UnsealAggregateException when decryption fails with every provided key
*/
public static function unseal(string $sealed, array $keys): string
{
if (substr($sealed, 0, strlen(self::$SEAL_HEADER)) !== self::$SEAL_HEADER) {
if (!str_starts_with($sealed, self::SEAL_HEADER)) {
throw new InvalidSealedDataHeaderException();
}

Expand All @@ -54,10 +72,10 @@ public static function unseal(string $sealed, array $keys): string
switch ($key->getAlgorithm()) {
case DecryptionAlgorithm::AES_256_GCM:
try {
$data = substr($sealed, strlen(self::$SEAL_HEADER));
$data = substr($sealed, strlen(self::SEAL_HEADER));

return self::decryptAes256Gcm($data, $key->getKey());
} catch (\Exception $exception) {
} catch (Exception $exception) {
$aggregateException->addException(new UnsealException(
'Failed to decrypt',
$exception,
Expand All @@ -68,20 +86,24 @@ public static function unseal(string $sealed, array $keys): string
break;

default:
throw new \InvalidArgumentException('Invalid decryption algorithm');
throw new InvalidArgumentException('Invalid decryption algorithm');
}
}

throw $aggregateException;
}

/**
* @param mixed $sealedData
* @param mixed $decryptionKey
* Decrypts an AES-256-GCM payload and decompresses the result.
*
* @param string $sealedData nonce + ciphertext + auth tag
* @param string $decryptionKey raw 256-bit key
*
* @throws \Exception
* @return string decompressed plaintext
*
* @throws Exception on decryption failure
*/
private static function decryptAes256Gcm($sealedData, $decryptionKey): string
private static function decryptAes256Gcm(string $sealedData, string $decryptionKey): string
{
$nonce = substr($sealedData, 0, self::NONCE_LENGTH);
$ciphertext = substr($sealedData, self::NONCE_LENGTH);
Expand All @@ -92,23 +114,29 @@ private static function decryptAes256Gcm($sealedData, $decryptionKey): string
$decryptedData = openssl_decrypt($ciphertext, 'aes-256-gcm', $decryptionKey, OPENSSL_RAW_DATA, $nonce, $tag);

if (false === $decryptedData) {
throw new \Exception('Decryption failed');
throw new Exception('Decryption failed');
}

return self::decompress($decryptedData);
}

/**
* @param mixed $data
* Decompresses raw-deflated data.
*
* @param bool|string $data raw deflate-compressed data
*
* @throws \Exception
* @return string decompressed data
*
* @throws DecompressionException when decompression fails or input is empty
*/
private static function decompress($data): string
private static function decompress(bool|string $data): string
{
if (false === $data || 0 === strlen($data)) {
throw new DecompressionException();
}
$inflated = @gzinflate($data); // Ignore warnings, because we check the decompressed data's validity and throw error if necessary

// Ignore warnings, because we check the decompressed data's validity and throw error if necessary
$inflated = @gzinflate($data);

if (false === $inflated) {
throw new DecompressionException();
Expand Down
28 changes: 22 additions & 6 deletions src/Sealed/UnsealAggregateException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,40 @@

namespace Fingerprint\ServerAPI\Sealed;

class UnsealAggregateException extends \Exception
use Exception;

/**
* Thrown when unsealing fails with all provided decryption keys.
*
* Contains the individual {@see UnsealException} for each key that was tried.
*/
class UnsealAggregateException extends Exception
{
/** @var Exception[] */
private array $exceptions = [];

/**
* @var \Exception[]
* Creates a new UnsealAggregateException instance.
*/
private $exceptions;

public function __construct()
{
parent::__construct('Failed to unseal with all decryption keys');
}

public function addException(\Exception $exception)
/**
* Adds a decryption failure to this aggregate.
*/
public function addException(Exception $exception): void
{
$this->exceptions[] = $exception;
}

public function getExceptions()
/**
* Returns all collected decryption exceptions.
*
* @return Exception[]
*/
public function getExceptions(): array
{
return $this->exceptions;
}
Expand Down
40 changes: 34 additions & 6 deletions src/Sealed/UnsealException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,48 @@

namespace Fingerprint\ServerAPI\Sealed;

class UnsealException extends \Exception
use Exception;

/**
* Thrown when a single decryption key fails to unseal the data.
*
* Carries the {@see DecryptionKey} that was used, so callers can
* identify which key failed.
*/
class UnsealException extends Exception
{
public $decryptionKeyDescription;
private readonly DecryptionKey $decryptionKey;

public function __construct($message, $cause, $decryptionKey)
/**
* Creates a new UnsealException instance.
*
* @param string $message error description
* @param Exception $cause underlying decryption exception
* @param DecryptionKey $decryptionKey the key that failed
*/
public function __construct(string $message, Exception $cause, DecryptionKey $decryptionKey)
{
parent::__construct($message, 0, $cause);
$this->decryptionKeyDescription = $decryptionKey;
$this->decryptionKey = $decryptionKey;
}

/**
* Returns the decryption key that was used when this failure occurred.
*/
public function getDecryptionKey(): DecryptionKey
{
return $this->decryptionKey;
}

public function __toString()
/**
* String representation of the exception.
*
* @return string
*/
public function __toString(): string
{
return 'UnsealException{'.
'decryptionKey='.$this->decryptionKeyDescription.
'decryptionKey='.$this->decryptionKey->getAlgorithm().
', message='.$this->getMessage().
', cause='.$this->getPrevious().
'}';
Expand Down
Loading
Loading