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
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"require-dev": {
"phpunit/phpunit": "^8.0"
},
"suggest": {
"ext-sodium": "Required for Packaged\\Http\\Encryption and EncryptedCookieHandler",
"paragonie/sodium_compat": "Pure-PHP fallback when ext-sodium is unavailable"
},
"autoload": {
"psr-4": {
"Packaged\\Http\\": "src"
Expand Down
8 changes: 7 additions & 1 deletion src/Cookies/CookieJar.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ public function hydrate(Request $request)
foreach($request->cookies->all() as $name => $cookie)
{
$handler = $this->_getHandler($name, $cookie);
$this->_requestCookies[$handler->decodeName($name)] = $handler->decodeValue($cookie);
try
{
$this->_requestCookies[$handler->decodeName($name)] = $handler->decodeValue($cookie);
}
catch(InvalidCookieException $e)
{
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions src/Cookies/EncryptedCookieHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
namespace Packaged\Http\Cookies;

use Packaged\Http\Encryption\DecryptException;
use Packaged\Http\Encryption\EncrypterInterface;

/**
* Encrypts cookie values. A request cookie that does not decrypt, because it was tampered with or encrypted
* with a key that is no longer configured, is left out of the CookieJar.
*
* $jar->addHandler(new EncryptedCookieHandler(Encrypter::fromConfig($config)));
*/
class EncryptedCookieHandler extends AbstractCookieHandler
{
protected EncrypterInterface $_encrypter;
/**
* @var string[] names of cookies stored as plain text, e.g. cookies read by client-side scripts
*/
protected array $_plainTextCookies;

public function __construct(EncrypterInterface $encrypter, array $plainTextCookies = [])
{
$this->_encrypter = $encrypter;
$this->_plainTextCookies = $plainTextCookies;
}

public function canHandle(string $name, $value = null): bool
{
return !in_array($name, $this->_plainTextCookies, true);
}

public function decodeValue(string $value): string
{
try
{
return $this->_encrypter->decrypt($value);
}
catch(DecryptException $e)
{
throw new InvalidCookieException($e->getMessage(), 0, $e);
}
}

public function encodeValue(string $value): string
{
return $this->_encrypter->encrypt($value);
}
}
9 changes: 9 additions & 0 deletions src/Cookies/InvalidCookieException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
namespace Packaged\Http\Cookies;

/**
* Thrown by a CookieHandler that rejects a request cookie's value. The CookieJar leaves that cookie out.
*/
class InvalidCookieException extends \RuntimeException
{
}
6 changes: 6 additions & 0 deletions src/Encryption/DecryptException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
namespace Packaged\Http\Encryption;

class DecryptException extends \RuntimeException
{
}
152 changes: 152 additions & 0 deletions src/Encryption/Encrypter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php
namespace Packaged\Http\Encryption;

use Packaged\Config\ConfigProviderInterface;

/**
* Authenticated encryption with XChaCha20-Poly1305 (IETF). Requires ext-sodium
* or paragonie/sodium_compat.
*
* Payload: base64url, unpadded, of
* version (1 byte) || nonce (24 bytes) || ciphertext || tag (16 bytes)
* The version byte is bound as additional data, so it cannot be altered.
*
* Keys are a single key or a list of keys. The first key is the current key:
* it encrypts and is tried first on decrypt. Later keys are decrypt-only, to
* allow a key to be rotated without invalidating existing payloads at once.
* Each configured key must be at least 16 bytes; the 32 byte cipher key is
* derived from it with HKDF-SHA256 using the info string KDF_INFO.
*
* A key is normally rotated because it is compromised, so anything that
* decrypts with a previous key may have been forged by whoever holds that
* key. Keep the rotation window short, remove previous keys once it ends,
* and do not base security decisions on data decrypted during the window.
*/
class Encrypter implements EncrypterInterface
{
const VERSION = "\x01";
// Shared with cubex/framework 2.7, so either can read the other's payloads with the same keys
const KDF_INFO = 'cubex-encryption-v1-xchacha20poly1305';
const MIN_KEY_BYTES = 16;
const KEY_BYTES = 32;
const NONCE_BYTES = 24;
const TAG_BYTES = 16;

/**
* Derived cipher keys, current key first
*
* @var string[]
*/
protected array $_keys = [];

/**
* @param string|string[] $keys a key, or a list of keys with the current key
* first. Empty previous keys are ignored.
*
* @throws \InvalidArgumentException when the current key is empty or any
* key is shorter than MIN_KEY_BYTES
* @throws \RuntimeException when sodium is unavailable
*/
public function __construct(string|array $keys)
{
if(!static::_sodiumAvailable())
{
throw new \RuntimeException('Packaged\Http\Encryption requires ext-sodium or paragonie/sodium_compat.');
}

foreach(static::normaliseKeys($keys) as $key)
{
if(strlen($key) < static::MIN_KEY_BYTES)
{
throw new \InvalidArgumentException('Encryption keys must be at least ' . static::MIN_KEY_BYTES . ' bytes.');
}
$this->_keys[] = hash_hkdf('sha256', $key, static::KEY_BYTES, static::KDF_INFO);
}
}

/**
* Build from a key, or list of keys, in configuration, e.g.
*
* [security]
* encryption_key[] = <current key>
* encryption_key[] = <previous key>
*
* @throws \InvalidArgumentException when no key is configured
*/
public static function fromConfig(
ConfigProviderInterface $config, string $section = 'security', string $item = 'encryption_key'
): static
{
return new static($config->getItem($section, $item, ''));
}

protected static function _sodiumAvailable(): bool
{
// paragonie/sodium_compat defines the same functions
return function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt');
}

/**
* @param string|string[] $keys
*
* @return string[] the non-empty keys, current key first
*
* @throws \InvalidArgumentException when the current key is empty
*/
public static function normaliseKeys(string|array $keys): array
{
$keys = array_map('strval', is_array($keys) ? array_values($keys) : [$keys]);

// An empty first entry is usually an unset environment variable. It is
// rejected rather than replaced by the next entry, which would encrypt
// new data with a key that is being retired.
if(!isset($keys[0]) || $keys[0] === '')
{
throw new \InvalidArgumentException('The first encryption key is the current key and must not be empty.');
}
return array_values(array_filter($keys, 'strlen'));
}

public function encrypt(string $plaintext): string
{
$nonce = random_bytes(static::NONCE_BYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, static::VERSION, $nonce, $this->_keys[0]);
return rtrim(strtr(base64_encode(static::VERSION . $nonce . $ciphertext), '+/', '-_'), '=');
}

public function decrypt(string $payload): string
{
$binary = false;
if(strlen($payload) % 4 !== 1 && preg_match('/^[A-Za-z0-9_-]*$/', $payload))
{
$binary = base64_decode(strtr($payload, '-_', '+/'), true);
}
if($binary === false)
{
throw new DecryptException('The payload is not valid base64url.');
}

$headerBytes = 1 + static::NONCE_BYTES;
if(strlen($binary) < $headerBytes + static::TAG_BYTES)
{
throw new DecryptException('The payload is too short.');
}
$version = $binary[0];
if($version !== static::VERSION)
{
throw new DecryptException('The payload version is not supported.');
}

$nonce = substr($binary, 1, static::NONCE_BYTES);
$ciphertext = substr($binary, $headerBytes);
foreach($this->_keys as $key)
{
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, $version, $nonce, $key);
if($plaintext !== false)
{
return $plaintext;
}
}
throw new DecryptException('The payload could not be decrypted with any configured key.');
}
}
16 changes: 16 additions & 0 deletions src/Encryption/EncrypterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
namespace Packaged\Http\Encryption;

interface EncrypterInterface
{
/**
* @return string an authenticated, URL-safe payload
*/
public function encrypt(string $plaintext): string;

/**
* @throws DecryptException when the payload is malformed, tampered with, or
* was not encrypted with a configured key
*/
public function decrypt(string $payload): string;
}
84 changes: 84 additions & 0 deletions tests/EncryptedCookieHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Packaged\Tests\Http;

use Packaged\Helpers\Objects;
use Packaged\Http\Cookies\CookieJar;
use Packaged\Http\Cookies\EncryptedCookieHandler;
use Packaged\Http\Cookies\InvalidCookieException;
use Packaged\Http\Encryption\Encrypter;
use Packaged\Http\Request;
use Packaged\Http\Response;
use PHPUnit\Framework\TestCase;

class EncryptedCookieHandlerTest extends TestCase
{
const CURRENT = 'a-new-key-of-any-length-over-16';
const OLDER = 'older-key-000002';

protected static function _jar(array $keys, array $plainText = []): CookieJar
{
$jar = new CookieJar();
$jar->addHandler(new EncryptedCookieHandler(new Encrypter($keys), $plainText));
return $jar;
}

protected static function _responseCookies(CookieJar $jar): array
{
$response = $jar->applyToResponse(new Response());
return Objects::mpull($response->headers->getCookies(), 'getValue', 'getName');
}

public function testRoundTrip()
{
$jar = self::_jar([self::CURRENT]);
$jar->store('session', 'secret-value', 10);
$cookies = self::_responseCookies($jar);
self::assertNotSame('secret-value', $cookies['session']);

$jar = self::_jar([self::CURRENT]);
$jar->hydrate(new Request([], [], [], $cookies));
self::assertSame('secret-value', $jar->read('session'));
}

public function testPreviousKeyDecrypts()
{
$old = self::_jar([self::OLDER]);
$old->store('session', 'value');

$jar = self::_jar([self::CURRENT, self::OLDER]);
$jar->hydrate(new Request([], [], [], self::_responseCookies($old)));
self::assertSame('value', $jar->read('session'));
}

public function testUndecryptableCookiesAreDropped()
{
$old = self::_jar([self::OLDER]);
$old->store('retired', 'value');
$cookies = self::_responseCookies($old) + ['forged' => 'not-a-payload', 'other' => 'plain'];

$jar = self::_jar([self::CURRENT], ['other']);
$jar->hydrate(new Request([], [], [], $cookies));
self::assertFalse($jar->has('retired'));
self::assertFalse($jar->has('forged'));
self::assertSame('plain', $jar->read('other'));
}

public function testPlainTextCookies()
{
$jar = self::_jar([self::CURRENT], ['theme']);
$jar->store('theme', 'dark');
$jar->store('session', 'value');
$cookies = self::_responseCookies($jar);
self::assertSame('dark', $cookies['theme']);
self::assertNotSame('value', $cookies['session']);
}

public function testDecodeValueThrowsInvalidCookie()
{
$handler = new EncryptedCookieHandler(new Encrypter(self::CURRENT));
$this->expectException(InvalidCookieException::class);
$this->expectExceptionMessage('base64url');
$handler->decodeValue('not a payload!');
}
}
Loading
Loading