From c17c97702ac79e0d6045447d302f9bb4982ce229 Mon Sep 17 00:00:00 2001 From: Tom Kay Date: Tue, 29 Sep 2026 12:50:47 +0100 Subject: [PATCH] Add encrypted cookies with key rotation EncryptedCookieHandler encrypts cookie values with Encryption\Encrypter: XChaCha20-Poly1305 via ext-sodium, with a list of keys where the first encrypts and every key decrypts, so a key can be rotated without invalidating existing cookies at once. Payloads match cubex/framework 2.7, so either can read the other's cookies with the same keys. A handler can now reject a request cookie by throwing InvalidCookieException, and the jar leaves that cookie out. A cookie that fails to decrypt is dropped rather than read as an empty value. Co-Authored-By: Claude Opus 5.5 --- composer.json | 4 + src/Cookies/CookieJar.php | 8 +- src/Cookies/EncryptedCookieHandler.php | 48 +++++++ src/Cookies/InvalidCookieException.php | 9 ++ src/Encryption/DecryptException.php | 6 + src/Encryption/Encrypter.php | 152 ++++++++++++++++++++ src/Encryption/EncrypterInterface.php | 16 +++ tests/EncryptedCookieHandlerTest.php | 84 +++++++++++ tests/Encryption/EncrypterTest.php | 188 +++++++++++++++++++++++++ 9 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 src/Cookies/EncryptedCookieHandler.php create mode 100644 src/Cookies/InvalidCookieException.php create mode 100644 src/Encryption/DecryptException.php create mode 100644 src/Encryption/Encrypter.php create mode 100644 src/Encryption/EncrypterInterface.php create mode 100644 tests/EncryptedCookieHandlerTest.php create mode 100644 tests/Encryption/EncrypterTest.php diff --git a/composer.json b/composer.json index 56f112c..3607ba3 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/src/Cookies/CookieJar.php b/src/Cookies/CookieJar.php index da5e405..f82b5ad 100644 --- a/src/Cookies/CookieJar.php +++ b/src/Cookies/CookieJar.php @@ -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) + { + } } } diff --git a/src/Cookies/EncryptedCookieHandler.php b/src/Cookies/EncryptedCookieHandler.php new file mode 100644 index 0000000..321defa --- /dev/null +++ b/src/Cookies/EncryptedCookieHandler.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/src/Cookies/InvalidCookieException.php b/src/Cookies/InvalidCookieException.php new file mode 100644 index 0000000..c70d8fe --- /dev/null +++ b/src/Cookies/InvalidCookieException.php @@ -0,0 +1,9 @@ +_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[] = + * encryption_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.'); + } +} diff --git a/src/Encryption/EncrypterInterface.php b/src/Encryption/EncrypterInterface.php new file mode 100644 index 0000000..7ed18e4 --- /dev/null +++ b/src/Encryption/EncrypterInterface.php @@ -0,0 +1,16 @@ +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!'); + } +} diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php new file mode 100644 index 0000000..7ae0a49 --- /dev/null +++ b/tests/Encryption/EncrypterTest.php @@ -0,0 +1,188 @@ +assertInstanceOf(EncrypterInterface::class, $encrypter); + foreach(['', 'value', random_bytes(1000)] as $plaintext) + { + $this->assertSame($plaintext, $encrypter->decrypt($encrypter->encrypt($plaintext))); + } + } + + public function testPayloadFormat() + { + $payload = (new Encrypter(self::CURRENT))->encrypt('value'); + $this->assertRegExp('/^[A-Za-z0-9_-]+$/', $payload); + $binary = self::_decode($payload); + $this->assertSame(Encrypter::VERSION, $binary[0]); + $this->assertSame(1 + 24 + strlen('value') + 16, strlen($binary)); + // 45 plaintext bytes encrypt to 115 characters + $this->assertSame(115, strlen((new Encrypter(self::CURRENT))->encrypt(str_repeat('x', 45)))); + } + + public function testNonceIsRandom() + { + $encrypter = new Encrypter(self::CURRENT); + $this->assertNotSame($encrypter->encrypt('value'), $encrypter->encrypt('value')); + } + + public function testRotation() + { + $old = new Encrypter(self::OLDER); + $rotating = new Encrypter([self::CURRENT, '', self::OLDER]); + $this->assertSame('old', $rotating->decrypt($old->encrypt('old'))); + + $payload = $rotating->encrypt('new'); + $this->assertSame('new', (new Encrypter(self::CURRENT))->decrypt($payload)); + $this->expectException(DecryptException::class); + $old->decrypt($payload); + } + + public function testWrongKeyThrows() + { + $payload = (new Encrypter(self::OLDER))->encrypt('value'); + $this->expectException(DecryptException::class); + $this->expectExceptionMessage('any configured key'); + (new Encrypter(self::CURRENT))->decrypt($payload); + } + + public static function tamperProvider(): array + { + return [ + 'version' => [0], + 'nonce' => [5], + 'ciphertext' => [26], + 'tag' => [-1], + ]; + } + + /** + * @dataProvider tamperProvider + */ + public function testTamperingThrows(int $offset) + { + $encrypter = new Encrypter(self::CURRENT); + $binary = self::_decode($encrypter->encrypt('value')); + $offset = $offset < 0 ? strlen($binary) + $offset : $offset; + $binary[$offset] = chr(ord($binary[$offset]) ^ 1); + $this->expectException(DecryptException::class); + $encrypter->decrypt(self::_encode($binary)); + } + + public function testVersionIsAuthenticated() + { + $encrypter = new Encrypter(self::CURRENT); + $binary = self::_decode($encrypter->encrypt('value')); + $key = hash_hkdf('sha256', self::CURRENT, 32, Encrypter::KDF_INFO); + $nonce = substr($binary, 1, 24); + $forged = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('value', "\x02", $nonce, $key); + $this->expectException(DecryptException::class); + $encrypter->decrypt(self::_encode(Encrypter::VERSION . $nonce . $forged)); + } + + public static function malformedProvider(): array + { + return [ + 'not base64url' => ['not a payload!', 'base64url'], + 'padded' => ['AQ==', 'base64url'], + 'bad length' => ['AAAAA', 'base64url'], + 'too short' => [self::_encode("\x01short"), 'too short'], + 'bad version' => [self::_encode("\x02" . str_repeat('a', 60)), 'version'], + ]; + } + + /** + * @dataProvider malformedProvider + */ + public function testMalformedPayloadThrows(string $payload, string $message) + { + $this->expectException(DecryptException::class); + $this->expectExceptionMessage($message); + (new Encrypter(self::CURRENT))->decrypt($payload); + } + + public static function invalidKeyProvider(): array + { + return [ + 'empty string' => ['', 'must not be empty'], + 'empty list' => [[], 'must not be empty'], + 'empty first entry' => [['', self::OLDER], 'must not be empty'], + 'all empty' => [['', ''], 'must not be empty'], + 'short current key' => ['fifteen-bytes!!', 'at least 16 bytes'], + 'short previous key' => [[self::CURRENT, 'short'], 'at least 16 bytes'], + ]; + } + + /** + * @dataProvider invalidKeyProvider + */ + public function testInvalidKeysThrow($keys, string $message) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + new Encrypter($keys); + } + + public function testNormaliseKeys() + { + $this->assertSame([self::CURRENT], Encrypter::normaliseKeys(self::CURRENT)); + $this->assertSame( + [self::CURRENT, self::OLDER], + Encrypter::normaliseKeys(['x' => self::CURRENT, '', null, self::OLDER]) + ); + } + + public function testMissingSodiumThrows() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ext-sodium or paragonie/sodium_compat'); + new NoSodiumEncrypter(self::CURRENT); + } + + public function testFromIniConfig() + { + $config = new IniConfigProvider(); + $config->loadString("[security]\nencryption_key[] = " . self::CURRENT . "\nencryption_key[] = " . self::OLDER . "\n"); + $encrypter = Encrypter::fromConfig($config); + $this->assertSame('v', (new Encrypter(self::CURRENT))->decrypt($encrypter->encrypt('v'))); + $this->assertSame('v', $encrypter->decrypt((new Encrypter(self::OLDER))->encrypt('v'))); + } + + public function testFromConfigWithoutKeyThrows() + { + $this->expectException(\InvalidArgumentException::class); + Encrypter::fromConfig(new ConfigProvider()); + } +} + +class NoSodiumEncrypter extends Encrypter +{ + protected static function _sodiumAvailable(): bool + { + return false; + } +}