diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..28a6105
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,106 @@
+name: CI
+
+on:
+ push:
+ branches: [main, 2.x, 1.x]
+ pull_request:
+ branches: [main, 2.x, 1.x]
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }}
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4']
+ dependencies: [highest]
+ include:
+ - php: '8.1'
+ dependencies: lowest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: openssl, sodium
+ coverage: none
+ tools: composer:v2
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.dependencies }}-
+
+ - name: Install dependencies (highest)
+ if: matrix.dependencies == 'highest'
+ run: composer update --no-interaction --no-progress --prefer-dist
+
+ - name: Install dependencies (lowest)
+ if: matrix.dependencies == 'lowest'
+ run: composer update --no-interaction --no-progress --prefer-dist --prefer-lowest
+
+ - name: Run tests
+ run: composer test
+
+ static-analysis:
+ name: Static analysis
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: openssl, sodium
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer update --no-interaction --no-progress --prefer-dist
+
+ - name: PHPStan
+ run: composer phpstan
+
+ coding-style:
+ name: Coding style
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: openssl, sodium
+ coverage: none
+ tools: composer:v2, php-cs-fixer
+
+ - name: Install dependencies
+ run: composer update --no-interaction --no-progress --prefer-dist
+
+ - name: Check style
+ run: composer cs-check
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..ed9b8b6
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,35 @@
+name: Security
+
+on:
+ push:
+ branches: [main, 2.x, 1.x]
+ pull_request:
+ branches: [main, 2.x, 1.x]
+ schedule:
+ - cron: '17 6 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ composer-audit:
+ name: composer audit
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: openssl, sodium
+ coverage: none
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer update --no-interaction --no-progress --prefer-dist
+
+ - name: Run composer audit
+ run: composer audit --no-dev --locked || composer audit
diff --git a/.gitignore b/.gitignore
index a879886..99f3a2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,10 @@
/.vs/
/.vscode/
/vendor/
-/composer.lock
\ No newline at end of file
+/composer.lock
+/build/
+/coverage/
+/.phpunit.cache/
+/.phpunit.result.cache
+/.php-cs-fixer.cache
+/.phpstan.cache
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..b2df5dc
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,39 @@
+in([__DIR__ . '/src', __DIR__ . '/tests'])
+ ->name('*.php')
+ ->ignoreDotFiles(true)
+ ->ignoreVCS(true);
+
+return (new PhpCsFixer\Config())
+ ->setRiskyAllowed(true)
+ ->setFinder($finder)
+ ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache')
+ ->setRules([
+ '@PSR12' => true,
+ '@PSR12:risky' => true,
+ 'declare_strict_types' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'ordered_imports' => [
+ 'sort_algorithm' => 'alpha',
+ 'imports_order' => ['class', 'function', 'const'],
+ ],
+ 'no_unused_imports' => true,
+ 'single_quote' => true,
+ 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
+ 'no_trailing_whitespace' => true,
+ 'no_whitespace_in_blank_line' => true,
+ 'no_extra_blank_lines' => ['tokens' => ['extra', 'throw', 'use']],
+ 'blank_line_after_opening_tag' => true,
+ 'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try']],
+ 'cast_spaces' => ['space' => 'single'],
+ 'concat_space' => ['spacing' => 'one'],
+ 'native_function_invocation' => [
+ 'include' => ['@compiler_optimized'],
+ 'scope' => 'namespaced',
+ 'strict' => true,
+ ],
+ ]);
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..86575b8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,49 @@
+# Changelog
+
+All notable changes to `initphp/encryption` will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Planned for 2.0.0
+
+This is the first development entry of the upcoming 2.0 release. The 2.x line is
+a deliberate hard reset of the public surface; ciphertexts produced by 1.x cannot
+be decrypted by 2.x and vice versa. A migration guide will ship with the final
+release.
+
+#### Added
+
+- Tooling: PHPUnit 10, PHPStan level 8, PHP-CS-Fixer (PSR-12), GitHub Actions CI
+ matrix across PHP 8.1–8.4, `composer audit` workflow.
+- `composer.json` scripts: `test`, `test-coverage`, `phpstan`, `cs-check`,
+ `cs-fix`, `qa`.
+- Package-level `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`.
+
+#### Changed
+
+- **BREAKING:** Minimum PHP version raised to `^8.1`.
+
+#### To be done (tracked, not yet shipped)
+
+- **BREAKING:** New self-describing ciphertext format (versioned header) — v1
+ ciphertexts will not be readable by 2.x.
+- **BREAKING:** Default payload serialization switches from `serialize()`/
+ `unserialize()` to JSON. PHP serialization remains available as an opt-in.
+- Sodium handler derives a 32-byte key from any-length user-supplied key
+ material via `sodium_crypto_generichash`, fixing the silent failure when a
+ short key was provided.
+- OpenSSL handler uses `random_bytes()` for IV generation.
+- OpenSSL handler computes the HMAC length from the actual hash output instead
+ of parsing the algorithm name as a numeric suffix.
+- Mandatory key validation with a descriptive `EncryptionException` instead of
+ obscure `TypeError`s deep inside the extensions.
+- Drop runtime dependency on `ext-mbstring`.
+- PSR-12 compliance across the codebase.
+- Removal of `Encrypt::create()` (alias of `Encrypt::use()`).
+
+## [1.0.0] - 2022
+
+Initial release.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c589aa2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,77 @@
+# Contributing to `initphp/encryption`
+
+This package follows the [InitPHP org-wide contribution guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md).
+Please read it first — everything below is in addition to that guide, not a
+replacement for it.
+
+## Local Development
+
+```bash
+git clone https://github.com/InitPHP/Encryption.git
+cd Encryption
+composer install
+```
+
+Required PHP extensions for the test suite:
+
+- `ext-openssl`
+- `ext-sodium`
+
+## Quality Gates
+
+The CI pipeline runs three checks. Run them locally before pushing — every PR
+must pass all three.
+
+| Command | What it does |
+| --- | --- |
+| `composer test` | Run the PHPUnit suite. |
+| `composer phpstan` | Static analysis at level 8. |
+| `composer cs-check` | Verify PSR-12 compliance (read-only). |
+| `composer cs-fix` | Apply PSR-12 fixes automatically. |
+| `composer qa` | Run cs-check, phpstan and tests in sequence. |
+
+## Writing Tests
+
+- Unit tests live in `tests/Unit/` and must not require any I/O or extension
+ state beyond what `ext-openssl` and `ext-sodium` provide.
+- Integration tests live in `tests/Integration/` and may pin golden
+ ciphertexts for backwards-compatibility verification.
+- A bug fix PR must include a regression test that fails on `main` and passes
+ with the fix applied.
+- Cover both the happy path and the failure paths (tampered ciphertext,
+ invalid configuration, missing key, etc.).
+
+## Security-Sensitive Changes
+
+Any change that affects cryptographic primitives, key derivation, ciphertext
+format, or the trust boundary between an attacker and the plaintext requires:
+
+1. An explicit reviewer note in the PR description describing the threat model.
+2. A test that exercises the failure path (e.g. tampered HMAC must be rejected).
+3. A `CHANGELOG.md` entry under the appropriate section.
+
+If you believe you have found a vulnerability, **do not open a public issue or
+PR.** Follow the [security policy](./SECURITY.md) instead.
+
+## Commit Messages
+
+We use [Conventional Commits](https://www.conventionalcommits.org/). Typical
+scopes for this repository:
+
+- `openssl` — changes to `OpenSSL` handler
+- `sodium` — changes to `Sodium` handler
+- `base` — changes to `BaseHandler`
+- `factory` — changes to `Encrypt`
+- `docs`, `test`, `ci`, `chore` — as in the org guide
+
+Example:
+
+```
+fix(openssl): handle openssl_decrypt failure before unserialize
+
+openssl_decrypt() returns false on failure, which then caused
+unserialize(false) to throw a TypeError on PHP 8.x. Detect the false
+return and throw EncryptionException with a meaningful message.
+
+Closes #NN
+```
diff --git a/README.md b/README.md
index 9d9a8b5..e44c63e 100644
--- a/README.md
+++ b/README.md
@@ -1,132 +1,241 @@
-# Encryption
-PHP OpenSSL/Sodium Encryption and Decryption
+# initphp/encryption
-[](https://packagist.org/packages/initphp/encryption) [](https://packagist.org/packages/initphp/encryption) [](https://packagist.org/packages/initphp/encryption) [](https://packagist.org/packages/initphp/encryption) [](https://packagist.org/packages/initphp/encryption)
+Secure, modern symmetric encryption for PHP on top of OpenSSL and libsodium.
+
+[](https://packagist.org/packages/initphp/encryption)
+[](https://packagist.org/packages/initphp/encryption)
+[](https://packagist.org/packages/initphp/encryption)
+[](https://packagist.org/packages/initphp/encryption)
+
+## Why
+
+PHP's encryption primitives are powerful but unforgiving. Pick the wrong cipher,
+mix up IV and HMAC ordering, forget constant-time comparison, or hand libsodium
+a 14-byte "key" and you ship a vulnerability — or, more often, a silent failure
+that "works on my machine".
+
+This package wraps `ext-openssl` and `ext-sodium` behind a small, opinionated
+API:
+
+- Authenticated by default. OpenSSL uses encrypt-then-MAC; Sodium uses the
+ built-in AEAD construction.
+- Keys of any non-empty length are accepted and derived to the size the
+ primitive actually requires.
+- Ciphertexts are self-describing: a 2-byte header (version + serializer flag)
+ lets the library reject malformed or out-of-date input with a clear error
+ instead of returning garbage.
+- A single `EncryptionException` covers every failure mode, so a `try` /
+ `catch` is enough to handle all error paths.
+- JSON is the default payload serializer, so the historical
+ `unserialize()`-on-attacker-controlled-bytes pitfall is closed by default.
## Requirements
-- PHP 7.4 or higher
-- MB_String extension
-- Depending on usage:
- - OpenSSL extesion
- - Sodium extension
+- PHP **8.1** or higher
+- `ext-openssl` for the OpenSSL handler
+- `ext-sodium` for the Sodium handler
+Both extensions ship with mainstream PHP distributions, but the package only
+loads the one you actually instantiate — you can use one handler without the
+other being available.
## Installation
-```
+```bash
composer require initphp/encryption
```
-## Configuration
+## Quickstart
-```php
-$options = [
- 'algo' => 'SHA256',
- 'cipher' => 'AES-256-CTR',
- 'key' => null,
- 'blocksize' => 16,
-];
-```
+```php
+ getenv('APP_ENCRYPTION_KEY'),
+]);
-```php
-require_once "vendor/autoload.php";
-use \InitPHP\Encryption\Encrypt;
+$ciphertext = $handler->encrypt(['user_id' => 42, 'role' => 'admin']);
+// → "02006f1c…": hex string, safe to store in cookies / DBs / URL params
-// OpenSSL Handler
-/** @var $openssl \InitPHP\Encryption\HandlerInterface */
-$openssl = Encrypt::use(\InitPHP\Encryption\OpenSSL::class, [
- 'algo' => 'SHA256',
- 'cipher' => 'AES-256-CTR',
- 'key' => 'TOP_Secret_Key',
-]);
+$plaintext = $handler->decrypt($ciphertext);
+// → ['user_id' => 42, 'role' => 'admin']
+```
+
+The Sodium handler has the same surface:
+
+```php
+use InitPHP\Encryption\Encrypt;
+use InitPHP\Encryption\Sodium;
-// Sodium Handler
-/** @var $sodium \InitPHP\Encryption\HandlerInterface */
-$sodium = Encrypt::use(\InitPHP\Encryption\Sodium::class, [
- 'key' => 'TOP_Secret_Key',
- 'blocksize' => 16,
+$handler = Encrypt::use(Sodium::class, [
+ 'key' => getenv('APP_ENCRYPTION_KEY'),
]);
+
+$ciphertext = $handler->encrypt('a secret message');
+$plaintext = $handler->decrypt($ciphertext);
```
-### Methods
+## Configuration
-#### `encrypt()`
+Every option is optional except `key`. Unknown keys are ignored. Keys are
+case-insensitive on input (`'CIPHER'` and `'cipher'` are the same option).
-```php
-public function encrypt(mixed $data, array $options = []): string;
-```
+| Option | Used by | Default | Description |
+| ------------ | --------- | -------------- | ----------- |
+| `key` | both | _required_ | The user-supplied secret. Any non-empty string; the handler derives a key of the correct length internally. |
+| `cipher` | OpenSSL | `AES-256-CTR` | Any algorithm from `openssl_get_cipher_methods()`. |
+| `algo` | OpenSSL | `SHA256` | Any algorithm from `hash_hmac_algos()`. Used both for HKDF key derivation and for the HMAC tag. |
+| `blocksize` | Sodium | `16` | Block size for `sodium_pad()` / `sodium_unpad()`. Must be a positive integer. |
+| `serializer` | both | `'json'` | One of `'json'`, `'php_serialize'`, `'php'`, `'serialize'`. See [Serialization](#serialization). |
+
+Options can be set in three places, in order of precedence (highest wins):
-#### `decrypt()`
+```php
+// 1) Per-call override
+$handler->encrypt($data, ['cipher' => 'AES-256-GCM']);
-```php
-public function decrypt(string $data, array $options = []): mixed;
+// 2) Mutated on the handler
+$handler->setOption('cipher', 'AES-256-GCM');
+$handler->setOptions(['cipher' => 'AES-256-GCM', 'algo' => 'SHA512']);
+
+// 3) Constructor / factory
+$handler = Encrypt::use(OpenSSL::class, ['cipher' => 'AES-256-GCM']);
```
-## Writing Your Own Handler
+Per-call options do **not** mutate the handler — they are merged into a fresh
+array for that single call only.
-```php
-namespace App;
+## Serialization
-use \InitPHP\Encryption\{HandlerInterface, BaseHandler};
+`encrypt()` accepts `mixed` and round-trips the value through a serializer
+chosen via the `serializer` option. The flag is embedded in the ciphertext, so
+`decrypt()` always restores the original type without you having to track the
+choice yourself.
-class MyHandler extends BaseHandler implements HandlerInterface
-{
- public function encrypt($data, array $options = []): string
- {
- $options = $this->options($options);
- // ... process
- }
+| `serializer` value | On-the-wire flag | Behaviour |
+| --- | --- | --- |
+| `'json'` (default) | `0x00` | Uses `json_encode`/`json_decode` with `JSON_THROW_ON_ERROR`. Safe: no PHP class is ever instantiated during decoding. Cannot carry raw binary bytes — use `php_serialize` if you need that. |
+| `'php_serialize'`, `'php'`, `'serialize'` | `0x01` | Uses `serialize()`/`unserialize()` with `['allowed_classes' => false]`. Round-trips scalars, arrays and binary strings; custom objects degrade to `__PHP_Incomplete_Class` on decode. |
- public function decrypt($data, array $options = [])
- {
- $options = $this->options($options);
- // ... process
- }
-}
-```
+The PHP serializer is opt-in for one reason only: even though we always pass
+`allowed_classes:false`, the safer default lets you not have to think about
+object-injection vectors at all.
-```php
-use \InitPHP\Encryption\Encrypt;
+## Writing a Custom Handler
-$myhandler = Encrypt::use(\App\MyHandler::class);
-```
+Extend `BaseHandler` (not `OpenSSL` / `Sodium` — those are `final`) and
+implement `encrypt()` and `decrypt()`:
-## Getting Help
+```php
+namespace App\Crypto;
-If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker.
+use InitPHP\Encryption\BaseHandler;
+use InitPHP\Encryption\Exceptions\EncryptionException;
+
+final class MyHandler extends BaseHandler
+{
+ public function encrypt(mixed $data, array $options = []): string
+ {
+ $options = $this->resolveOptions($options);
+ $key = $this->requireKey($options);
+ $serializerFlag = $this->serializerFlag($options);
-## Getting Involved
+ $payload = $this->serializePayload($data, $serializerFlag);
+ // ... apply your primitive of choice, return a hex-encoded string ...
+ }
-> All contributions to this project will be published under the MIT License. By submitting a pull request or filing a bug, issue, or feature request, you are agreeing to comply with this waiver of copyright interest.
+ public function decrypt(string $data, array $options = []): mixed
+ {
+ $options = $this->resolveOptions($options);
+ $key = $this->requireKey($options);
-There are two primary ways to help:
+ // ... reverse the encoding, return $this->unserializePayload($plain, $flag) ...
+ }
+}
-- Using the issue tracker, and
-- Changing the code-base.
-
-### Using the issue tracker
+// Use it via the factory just like the built-in handlers:
+$handler = \InitPHP\Encryption\Encrypt::use(\App\Crypto\MyHandler::class, [
+ 'key' => 'secret',
+]);
+```
-Use the issue tracker to suggest feature requests, report bugs, and ask questions. This is also a great way to connect with the developers of the project as well as others who are interested in this solution.
+`BaseHandler` gives you `resolveOptions()`, `requireKey()`,
+`serializerFlag()`, `serializePayload()` and `unserializePayload()` for free,
+so you only write the cryptographic glue.
-Use the issue tracker to find ways to contribute. Find a bug or a feature, mention in the issue that you will take on that effort, then follow the Changing the code-base guidance below.
+## Error Handling
-### Changing the code-base
+Every failure path raises `InitPHP\Encryption\Exceptions\EncryptionException`
+(which extends `\RuntimeException`). A single `catch` covers everything:
-Generally speaking, you should fork this repository, make changes in your own fork, and then submit a pull request. All new code should have associated unit tests that validate implemented features and the presence or lack of defects. Additionally, the code should follow any stylistic and architectural guidelines prescribed by the project. In the absence of such guidelines, mimic the styles and patterns in the existing code-base.
+```php
+use InitPHP\Encryption\Exceptions\EncryptionException;
-## Credits
+try {
+ $plaintext = $handler->decrypt($incoming);
+} catch (EncryptionException $e) {
+ // Bad input, tampered ciphertext, missing key, unsupported format
+ // version, unknown cipher or hash algorithm, …
+ $logger->warning('decrypt failed', ['reason' => $e->getMessage()]);
+}
+```
-- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr)
+Notable messages you may see:
+
+- `Unsupported ciphertext format version 0x01; expected 0x02. Ciphertexts produced by 1.x are not readable by 2.x.`
+- `HMAC verification failed; ciphertext is corrupted or has been tampered with.`
+- `Sodium decryption failed; ciphertext is corrupted or has been tampered with.`
+- `The "key" option is required and must be a non-empty string.`
+- `Unknown OpenSSL cipher "…".`
+
+## Security Notes
+
+- **Key management is your job.** Store the key outside the code repository —
+ environment variable, secret manager, KMS, etc. — and rotate it like any
+ other secret.
+- **Key strength matters.** The handler accepts any non-empty user key and
+ derives one of the right length, but it cannot add entropy that the input
+ does not contain. Use a random 256-bit string (`bin2hex(random_bytes(32))`)
+ in production rather than a passphrase.
+- **Authentication is always on.** OpenSSL ciphertexts include an HMAC of the
+ header, IV, and ciphertext; Sodium uses its built-in AEAD. There is no
+ "encrypt without authenticate" mode.
+- **Format is versioned.** The first byte of every ciphertext identifies the
+ format. A future major release that changes the layout will bump this byte
+ and reject older ciphertexts with a clear error.
+- **Found something concerning?** See [SECURITY.md](./SECURITY.md) — please do
+ not open a public issue for vulnerabilities.
+
+## Upgrading from 1.x
+
+Version 2.0 is a hard reset of the public surface and the on-wire format:
+
+- Minimum PHP version is now **8.1**.
+- Ciphertexts produced by 1.x **cannot be decrypted by 2.x.** Plan a
+ re-encryption migration before upgrading.
+- The default payload serializer is JSON (was `serialize`). Pass
+ `'serializer' => 'php_serialize'` to keep the old behaviour.
+- `Encrypt::create()` has been removed; use `Encrypt::use()`.
+- The Sodium handler no longer requires a 32-byte key — any non-empty string
+ is now accepted and derived internally.
+- `ext-mbstring` is no longer required.
+
+A full migration walk-through lives in
+[`docs/08-migration-v1-to-v2.md`](./docs/08-migration-v1-to-v2.md) (shipped
+with the package; see also the [docs/](./docs/) index).
+
+## Contributing
+
+PRs are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) first — it
+covers the local quality gates (`composer test`, `composer phpstan`,
+`composer cs-check`) and the security-review process for changes touching the
+cryptographic primitives.
## License
-Copyright © 2022 [MIT License](./LICENSE)
+MIT — see [LICENSE](./LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..28bfc1a
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,53 @@
+# Security Policy
+
+`initphp/encryption` ships cryptographic primitives, so a vulnerability here
+typically affects every downstream user. Please report responsibly.
+
+This package follows the [InitPHP org-wide security policy](https://github.com/InitPHP/.github/blob/main/SECURITY.md).
+Refer to that document for:
+
+- How to report a vulnerability (GitHub Private Vulnerability Reporting is
+ preferred; email is accepted).
+- The response timeline and disclosure process.
+- Which versions receive security updates.
+
+## Quick Reference
+
+- **Do not** open public issues, Discussions, or PRs for security
+ vulnerabilities.
+- **Preferred channel:** GitHub Private Vulnerability Reporting on this
+ repository's **Security** tab.
+- **Alternative channel:** Email
+ [info@muhammetsafak.com.tr](mailto:info@muhammetsafak.com.tr) with `SECURITY`
+ in the subject line.
+
+## Supported Versions
+
+| Version | Status |
+| --- | --- |
+| 2.x | ✅ Active — security fixes land here. |
+| 1.x | ❌ End-of-life on the day 2.0.0 ships. Please upgrade. |
+
+When in doubt, run `composer show initphp/encryption` to confirm your version
+and `composer outdated initphp/encryption` to see if an upgrade is available.
+
+## Scope Notes Specific to This Package
+
+The following are **in scope** for this package's security policy:
+
+- Cryptographic correctness of the OpenSSL and Sodium handlers (padding,
+ authentication, IV/nonce reuse, key derivation).
+- Side-channel issues in the verification paths (timing, error oracles).
+- Deserialization or injection issues in the public encrypt/decrypt API.
+- Documentation that materially understates a known cryptographic limitation.
+
+The following are **out of scope**:
+
+- Misuse of the API by application code (e.g. shipping the secret key in
+ client-visible storage). Documentation PRs that help others avoid such
+ pitfalls are welcome.
+- Vulnerabilities in PHP itself, `ext-openssl`, or `libsodium` — report those
+ upstream.
+- Brute-force attacks against weak user-supplied keys. The package will derive
+ a key of the correct length from any input, but it cannot add entropy that
+ the input does not contain.
diff --git a/composer.json b/composer.json
index 637fdb3..55cb2ba 100644
--- a/composer.json
+++ b/composer.json
@@ -1,13 +1,19 @@
{
"name": "initphp/encryption",
- "description": "PHP OpenSSL/Sodium Encryption and Decryption",
+ "description": "Secure, modern symmetric encryption for PHP built on top of OpenSSL and libsodium.",
"type": "library",
"license": "MIT",
- "autoload": {
- "psr-4": {
- "InitPHP\\Encryption\\": "src/"
- }
- },
+ "keywords": [
+ "encryption",
+ "decryption",
+ "cryptography",
+ "openssl",
+ "sodium",
+ "libsodium",
+ "aes",
+ "security",
+ "initphp"
+ ],
"authors": [
{
"name": "Muhammet ŞAFAK",
@@ -16,9 +22,65 @@
"homepage": "https://www.muhammetsafak.com.tr"
}
],
- "minimum-stability": "stable",
+ "support": {
+ "issues": "https://github.com/InitPHP/Encryption/issues",
+ "source": "https://github.com/InitPHP/Encryption",
+ "security": "https://github.com/InitPHP/Encryption/security/policy"
+ },
"require": {
- "php": ">=7.4",
- "ext-mbstring": "*"
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5",
+ "phpstan/phpstan": "^1.11",
+ "friendsofphp/php-cs-fixer": "^3.59"
+ },
+ "suggest": {
+ "ext-openssl": "Required by the OpenSSL handler.",
+ "ext-sodium": "Required by the Sodium handler."
+ },
+ "autoload": {
+ "psr-4": {
+ "InitPHP\\Encryption\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "InitPHP\\Encryption\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit --colors=always",
+ "test-coverage": "phpunit --colors=always --coverage-html build/coverage --coverage-text",
+ "phpstan": "phpstan analyse --memory-limit=512M",
+ "cs-check": "php-cs-fixer fix --dry-run --diff",
+ "cs-fix": "php-cs-fixer fix",
+ "qa": [
+ "@cs-check",
+ "@phpstan",
+ "@test"
+ ]
+ },
+ "scripts-descriptions": {
+ "test": "Run the PHPUnit test suite.",
+ "test-coverage": "Run PHPUnit with HTML + text coverage reports.",
+ "phpstan": "Run PHPStan static analysis.",
+ "cs-check": "Check coding style (PSR-12) without changing files.",
+ "cs-fix": "Apply coding style fixes in place.",
+ "qa": "Run cs-check, phpstan and tests in sequence."
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true,
+ "preferred-install": "dist",
+ "optimize-autoloader": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.x-dev",
+ "dev-2.x": "2.x-dev",
+ "dev-1.x": "1.x-dev"
+ }
}
}
diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md
new file mode 100644
index 0000000..99d1d73
--- /dev/null
+++ b/docs/01-getting-started.md
@@ -0,0 +1,134 @@
+# Getting Started
+
+This page takes you from "I just ran `composer require`" to a working
+encrypt/decrypt round-trip. Five minutes.
+
+## Install
+
+```bash
+composer require initphp/encryption
+```
+
+The package itself has no Composer runtime dependencies — it ships handlers
+for the two PHP extensions you (probably) already have.
+
+## Pick a Handler
+
+| You want… | Use |
+| --- | --- |
+| The widest possible compatibility, custom cipher choice, FIPS-friendly primitives | [`OpenSSL`](./02-openssl-handler.md) |
+| A modern AEAD with no knobs to mis-set, fast performance | [`Sodium`](./03-sodium-handler.md) |
+
+If you have no opinion, **pick Sodium**. It is harder to misuse and the
+defaults are what you want.
+
+Both handlers are interchangeable through the same `HandlerInterface`, so
+swapping later is just a constructor change — at the cost of re-encrypting
+already-stored data, since the ciphertext formats differ.
+
+## Your First Encrypt / Decrypt
+
+Create `try-encryption.php`:
+
+```php
+ 'change-me-to-a-real-secret',
+]);
+
+$ciphertext = $handler->encrypt(['user_id' => 42, 'email' => 'alice@example.com']);
+echo "ciphertext: {$ciphertext}\n";
+
+$plaintext = $handler->decrypt($ciphertext);
+echo "plaintext: " . json_encode($plaintext) . "\n";
+```
+
+Run it:
+
+```bash
+php try-encryption.php
+```
+
+Expected output (the ciphertext bytes vary every run; the structure does not):
+
+```
+ciphertext: 020047ae3f8c...long hex string...
+plaintext: {"user_id":42,"email":"alice@example.com"}
+```
+
+Two things to notice:
+
+1. The ciphertext starts with `02`. That is the format version byte;
+ anything that doesn't start with `02` will be rejected by 2.x with a clear
+ error.
+2. The plaintext came back as the original array, not a serialized blob.
+ The handler tracks the serializer choice in the ciphertext itself, so
+ `decrypt()` always returns the original type.
+
+## Where Should the Key Live?
+
+**Not in your source code.** A typical layout:
+
+```php
+$key = getenv('APP_ENCRYPTION_KEY')
+ ?: throw new RuntimeException('APP_ENCRYPTION_KEY is not set');
+
+$handler = Encrypt::use(\InitPHP\Encryption\Sodium::class, ['key' => $key]);
+```
+
+For production, generate a strong key once and store it in your secret
+manager / `.env` / Kubernetes Secret / whatever you already use:
+
+```bash
+php -r 'echo bin2hex(random_bytes(32)), "\n";'
+# → 64 hex characters; treat as you would any other secret
+```
+
+See [docs/07-security.md](./07-security.md) for key generation, storage and
+rotation patterns.
+
+## Verifying the Install
+
+Run a quick sanity script — useful to drop into CI to confirm both extensions
+are available:
+
+```php
+ 'self-test-key']);
+ $ct = $handler->encrypt(['hello' => 'world']);
+ $pt = $handler->decrypt($ct);
+ assert($pt === ['hello' => 'world'], "{$class} round-trip failed");
+ echo "{$class} OK\n";
+}
+```
+
+If either handler is missing its extension you'll get a clear
+`EncryptionException` at construction time:
+
+```
+The "sodium" extension is required by the Sodium handler.
+```
+
+## Next Steps
+
+- Configure the handler for your use case → [05 — Options Reference](./05-options.md)
+- Understand the handler you picked →
+ [02 — OpenSSL Handler](./02-openssl-handler.md) or
+ [03 — Sodium Handler](./03-sodium-handler.md)
+- Plan key management → [07 — Security](./07-security.md)
+- Know what can fail → [06 — Error Handling](./06-error-handling.md)
diff --git a/docs/02-openssl-handler.md b/docs/02-openssl-handler.md
new file mode 100644
index 0000000..71da204
--- /dev/null
+++ b/docs/02-openssl-handler.md
@@ -0,0 +1,178 @@
+# OpenSSL Handler
+
+`InitPHP\Encryption\OpenSSL` is an encrypt-then-MAC handler built on
+`ext-openssl` and `hash_hmac()`. It is the right choice when you need control
+over the symmetric cipher (e.g. for FIPS environments) or when libsodium is
+not available.
+
+## What the Handler Does Per Call
+
+```text
+encrypt($data, $options)
+ 1. resolve options (per-call options merged on top of handler defaults)
+ 2. require a non-empty 'key' option
+ 3. validate 'cipher' against openssl_get_cipher_methods()
+ 4. validate 'algo' against hash_hmac_algos()
+ 5. derive a per-handler secret: hash_hkdf($algo, $key)
+ 6. generate a fresh IV: random_bytes(openssl_cipher_iv_length($cipher))
+ 7. serialize $data via the configured serializer (default: JSON)
+ 8. openssl_encrypt(..., OPENSSL_RAW_DATA, $iv) → ciphertext bytes
+ 9. authenticate VERSION || SERIALIZER || IV || CIPHERTEXT with HMAC
+ 10. return bin2hex(VERSION || SERIALIZER || HMAC || IV || CIPHERTEXT)
+
+decrypt($data, $options)
+ 1. resolve options, require key, validate cipher and algo (same as above)
+ 2. hex2bin → binary
+ 3. read the 2-byte header; reject if version byte ≠ 0x02
+ 4. recompute the derived secret from the key and algo
+ 5. read HMAC (size = strlen(hash_hmac($algo, '', '', true)))
+ 6. read IV (size = openssl_cipher_iv_length($cipher))
+ 7. recompute the HMAC; hash_equals() against the one read from the wire
+ 8. openssl_decrypt(..., OPENSSL_RAW_DATA, $iv) → plaintext bytes
+ 9. deserialize per the serializer flag from the header → return value
+```
+
+## Ciphertext Layout
+
+The hex string returned by `encrypt()` decodes to:
+
+```
++---------+-----------+--------+--------+----------------+
+| 1 byte | 1 byte | N bytes| M bytes| variable |
++---------+-----------+--------+--------+----------------+
+| VERSION | SERIALIZER| HMAC | IV | ciphertext |
++---------+-----------+--------+--------+----------------+
+```
+
+- `VERSION` is `0x02` for every ciphertext this handler produces.
+- `SERIALIZER` is `0x00` for JSON (default), `0x01` for `php_serialize`.
+- `N` is `strlen(hash_hmac($algo, '', '', true))` — 32 for SHA-256, 64 for
+ SHA-512, etc. Computed at decrypt time, so changing the algorithm changes
+ the layout naturally.
+- `M` is `openssl_cipher_iv_length($cipher)` — 0 for stream ciphers without
+ an IV (rare), 16 for AES-CTR / AES-CBC, etc.
+
+The HMAC authenticates `VERSION || SERIALIZER || IV || ciphertext`. The
+serializer byte is inside the authenticated region, so an attacker cannot
+flip it to trick the decoder.
+
+## Choosing a Cipher
+
+`openssl_get_cipher_methods()` returns ~150 entries on a typical install.
+You almost certainly want one of these:
+
+| Cipher | Notes |
+| --- | --- |
+| `AES-256-CTR` (default) | Stream-style, fast, no padding required, 128-bit IV. |
+| `AES-256-CBC` | Classical block mode. Larger ciphertext (block-aligned). |
+| `AES-128-CTR` / `AES-128-CBC` | Same shapes, 128-bit key derived from the HKDF output. |
+| `ChaCha20` | Stream cipher; nice on platforms without AES-NI. Requires `ext-openssl` linked against OpenSSL 1.1+. |
+
+Do **not** use GCM modes (`AES-256-GCM`, `ChaCha20-Poly1305`) with this
+handler. GCM ciphers expect their authentication tag to be tracked separately
+via `openssl_encrypt`'s `$tag` parameter, which this handler does not surface.
+The HMAC-then-encrypt construction would double-authenticate and the tag
+would still be missing from the wire. If you want AEAD, use the
+[Sodium handler](./03-sodium-handler.md) instead.
+
+## Choosing a Hash Algorithm
+
+`algo` is used in two places:
+
+1. **HKDF** turns your user key into a per-cipher derived key.
+2. **HMAC** authenticates the ciphertext.
+
+Anything from `hash_hmac_algos()` works; pick something modern:
+
+- `SHA256` (default) — universally available, good performance.
+- `SHA512` — stronger; ~2× HMAC output size (64 bytes), so ciphertexts grow
+ by 32 bytes.
+- `SHA3-256` / `SHA3-512` — fine if you prefer the SHA-3 family.
+
+`MD5` and `SHA1` are technically accepted by `hash_hmac_algos()` and
+technically functional but are not recommended.
+
+## Worked Example: switching ciphers per call
+
+```php
+ 'a-real-secret',
+ 'cipher' => 'AES-256-CTR', // default for the handler
+]);
+
+$small = $handler->encrypt('cookie payload');
+$big = $handler->encrypt('CBC payload', ['cipher' => 'AES-256-CBC']);
+
+// Per-call options do NOT mutate the handler:
+echo $handler->getOption('cipher'); // "AES-256-CTR"
+
+// Decryption reads the cipher from the handler's current options, so you
+// must pass the same per-call override when decrypting:
+echo $handler->decrypt($big, ['cipher' => 'AES-256-CBC']); // "CBC payload"
+```
+
+The handler does not embed the cipher or algorithm in the ciphertext header —
+only the format version and serializer flag. If you change them across calls,
+you are responsible for tracking which ciphertext used which configuration.
+In practice almost everyone picks one set of options at deployment time and
+never overrides per-call.
+
+## Worked Example: HMAC tampering is detected
+
+```php
+ 'secret']);
+$ct = $handler->encrypt('hello');
+
+// Flip one byte deep in the ciphertext:
+$tampered = $ct;
+$tampered[-1] = $ct[-1] === '0' ? '1' : '0';
+
+try {
+ $handler->decrypt($tampered);
+} catch (EncryptionException $e) {
+ echo $e->getMessage(), "\n";
+ // → HMAC verification failed; ciphertext is corrupted or has been tampered with.
+}
+```
+
+## Performance Notes
+
+- HKDF is computed on every `encrypt()` and `decrypt()` call. For very
+ high-throughput cases (>10k ops/s), construct the handler once and reuse
+ it; the per-call cost is dominated by OpenSSL, not the option resolution.
+- `AES-256-CTR` is faster than `AES-256-CBC` on modern CPUs (no padding) and
+ is the default for that reason.
+- HMAC-SHA256 is dominant in the per-call cost for tiny payloads (<1 KiB).
+ Switching to SHA-512 makes a measurable difference only at multi-megabyte
+ payload sizes.
+
+## When to Pick Sodium Instead
+
+- You want a single primitive that "just works" — no cipher knob, no
+ algorithm knob, no IV / HMAC layout in your head.
+- You are happy with libsodium being a hard dependency.
+- You need the slight speed edge of XChaCha20-Poly1305 on platforms without
+ AES-NI.
+
+If none of those apply, OpenSSL is a perfectly reasonable production choice.
+
+## See Also
+
+- [05 — Options Reference](./05-options.md) — every option, default, range.
+- [06 — Error Handling](./06-error-handling.md) — every `EncryptionException`
+ this handler can throw.
+- [07 — Security](./07-security.md) — threat model and key management.
diff --git a/docs/03-sodium-handler.md b/docs/03-sodium-handler.md
new file mode 100644
index 0000000..6769271
--- /dev/null
+++ b/docs/03-sodium-handler.md
@@ -0,0 +1,195 @@
+# Sodium Handler
+
+`InitPHP\Encryption\Sodium` wraps libsodium's `crypto_secretbox` AEAD
+construction (XSalsa20-Poly1305). It is the recommended default: the
+underlying primitive has no tunables, the API is hard to misuse, and
+authentication is part of the construction rather than something you bolt on
+afterwards.
+
+## What the Handler Does Per Call
+
+```text
+encrypt($data, $options)
+ 1. resolve options (per-call options merged on top of handler defaults)
+ 2. require a non-empty 'key' option
+ 3. read 'blocksize' (default 16) and validate it is a positive integer
+ 4. derive a 32-byte secretbox key from the user key:
+ sodium_crypto_generichash($userKey, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES)
+ 5. serialize $data via the configured serializer (default: JSON)
+ 6. sodium_pad($serialized, $blocksize)
+ 7. generate a fresh 24-byte nonce: random_bytes(...)
+ 8. sodium_crypto_secretbox($padded, $nonce, $derivedKey) → box bytes
+ 9. zero the derived key from memory
+ 10. return bin2hex(VERSION || SERIALIZER || NONCE || BOX)
+
+decrypt($data, $options)
+ 1. resolve options, require key, resolve blocksize (same as above)
+ 2. hex2bin → binary
+ 3. reject if shorter than 2 + nonce + MAC bytes
+ 4. read the 2-byte header; reject if version byte ≠ 0x02
+ 5. read the 24-byte nonce
+ 6. re-derive the same secretbox key from the user key
+ 7. sodium_crypto_secretbox_open(...) — fails if the MAC doesn't match
+ 8. sodium_unpad(...) → original serialized bytes
+ 9. zero the derived key from memory
+ 10. deserialize per the serializer flag from the header → return value
+```
+
+## Ciphertext Layout
+
+The hex string returned by `encrypt()` decodes to:
+
+```
++---------+-----------+----------+----------------------+
+| 1 byte | 1 byte | 24 bytes | variable |
++---------+-----------+----------+----------------------+
+| VERSION | SERIALIZER| NONCE | secretbox(MAC || CT) |
++---------+-----------+----------+----------------------+
+```
+
+- `VERSION` is always `0x02`.
+- `SERIALIZER` is `0x00` for JSON (default), `0x01` for `php_serialize`.
+- The 24-byte nonce is `SODIUM_CRYPTO_SECRETBOX_NONCEBYTES`. It is generated
+ fresh on every call via `random_bytes()`.
+- `secretbox(MAC || CT)` is whatever `sodium_crypto_secretbox()` returns: the
+ Poly1305 MAC prepended to the ciphertext, with the same total length as
+ plaintext + `SODIUM_CRYPTO_SECRETBOX_MACBYTES` (16).
+
+Unlike the OpenSSL handler, the secretbox MAC authenticates `nonce + box`
+implicitly — there is no separate HMAC field.
+
+## Key Derivation
+
+The user key you pass to the handler can be **any non-empty string**. The
+handler runs it through BLAKE2b (`sodium_crypto_generichash`) to obtain the
+32-byte key that `crypto_secretbox` requires:
+
+```php
+$derivedKey = sodium_crypto_generichash(
+ $userKey,
+ '', // no key for the hash itself
+ SODIUM_CRYPTO_SECRETBOX_KEYBYTES // 32
+);
+```
+
+Implications:
+
+- The same user key always derives the same 32-byte key, so handlers in two
+ PHP processes interoperate with no key-sharing ceremony beyond agreeing on
+ the user key.
+- The derived key is held in a local variable and zeroed via `sodium_memzero`
+ inside a `finally` block. The user key you handed in is **not** zeroed —
+ managing that buffer is your responsibility.
+- BLAKE2b cannot turn a weak user key into a strong one. If your `key` is
+ "password123", that's what the security of the system is worth. See
+ [07 — Security](./07-security.md) for what a "real" key looks like.
+
+## Padding
+
+Sodium ciphertext length leaks the plaintext length modulo the block size.
+The handler pads inputs with `sodium_pad($payload, $blocksize)` to mitigate
+that leak.
+
+- `blocksize` defaults to `16`. Any positive integer is accepted; string
+ digits like `'32'` are coerced.
+- Larger block size = larger ciphertext = less length-leak. Pick `1` only if
+ you genuinely don't care about hiding plaintext length (and want the
+ smallest possible ciphertext).
+- An explicit `null` falls back to the default of 16. Pass `0`, a negative
+ number, a float, or a non-numeric string and you get
+ `EncryptionException: The "blocksize" option must be a positive integer.`
+
+## Worked Example: short user key is fine
+
+```php
+ 'short-key-1234']); // 14 bytes, < 32
+$ct = $handler->encrypt(['session_id' => 'abc']);
+$pt = $handler->decrypt($ct);
+
+assert($pt === ['session_id' => 'abc']);
+echo "OK\n";
+```
+
+The 1.x release silently failed for keys not exactly 32 bytes long. 2.x
+derives, so any non-empty string is accepted.
+
+## Worked Example: tampering is rejected by the MAC
+
+```php
+ 'secret']);
+$ct = $handler->encrypt('hello');
+
+// Flip a byte inside the secretbox region:
+$tampered = $ct;
+$tampered[-1] = $ct[-1] === '0' ? '1' : '0';
+
+try {
+ $handler->decrypt($tampered);
+} catch (EncryptionException $e) {
+ echo $e->getMessage(), "\n";
+ // → Sodium decryption failed; ciphertext is corrupted or has been tampered with.
+}
+```
+
+## Worked Example: tuning the padding block size
+
+```php
+ 'k', 'blocksize' => 1]); // no length hiding
+$padded = new Sodium(['key' => 'k', 'blocksize' => 256]); // hide length up to 256 bytes
+
+$shortPlaintext = 'hi';
+$ctSmall = $small->encrypt($shortPlaintext);
+$ctPadded = $padded->encrypt($shortPlaintext);
+
+// Both round-trip, but the padded ciphertext is much longer:
+assert($small->decrypt($ctSmall) === $shortPlaintext);
+assert($padded->decrypt($ctPadded) === $shortPlaintext);
+echo strlen($ctSmall), " vs ", strlen($ctPadded), "\n";
+// Approx: small=80, padded=592 (hex chars)
+```
+
+## Performance Notes
+
+- BLAKE2b key derivation runs on every call. It is fast (~microseconds), but
+ if you genuinely need maximum throughput, instantiate the handler once and
+ reuse it; per-call overhead is amortised.
+- Increasing `blocksize` increases ciphertext size linearly. The CPU cost of
+ padding is negligible compared to the secretbox operation itself.
+- libsodium uses XSalsa20-Poly1305, which is software-fast on every modern
+ platform. There is no equivalent to AES-NI hardware acceleration to worry
+ about.
+
+## When to Pick OpenSSL Instead
+
+- You are in an environment where libsodium is not available (rare on modern
+ PHP — it ships in core since 7.2).
+- Your compliance regime mandates AES (FIPS 140 contexts).
+- You need to interoperate with an existing OpenSSL-encrypt-then-MAC
+ consumer outside PHP.
+
+If none of those apply, this is the handler you want.
+
+## See Also
+
+- [05 — Options Reference](./05-options.md)
+- [06 — Error Handling](./06-error-handling.md)
+- [07 — Security](./07-security.md)
diff --git a/docs/04-custom-handlers.md b/docs/04-custom-handlers.md
new file mode 100644
index 0000000..27c29da
--- /dev/null
+++ b/docs/04-custom-handlers.md
@@ -0,0 +1,246 @@
+# Writing a Custom Handler
+
+`OpenSSL` and `Sodium` are deliberately `final` — they are end-points, not
+extension points. If you need a different cryptographic primitive (a key
+wrapped by a KMS, a different AEAD, a hardware-backed cipher), build a new
+handler on top of `BaseHandler`.
+
+This page walks through a complete worked example. The handler we build
+wraps libsodium's `crypto_aead_xchacha20poly1305_ietf_*` family, which gives
+you a 24-byte nonce (large enough to be safely random) and a single
+authenticated-encryption call.
+
+## The Contract You Must Honour
+
+`BaseHandler` does the boring parts for you: option resolution, key
+validation, payload serialization and deserialization, the format-version
+constant. Your subclass only owns the cryptographic glue and the on-wire
+layout between the format header and the ciphertext payload.
+
+Implement two methods:
+
+```php
+abstract public function encrypt(mixed $data, array $options = []): string;
+abstract public function decrypt(string $data, array $options = []): mixed;
+```
+
+…and follow three rules:
+
+1. **Start every ciphertext with the format header.** Two bytes:
+ - byte 0 = `BaseHandler::FORMAT_VERSION` (currently `0x02`)
+ - byte 1 = the serializer flag returned by `$this->serializerFlag($options)`
+2. **Hex-encode the entire wire payload** with `bin2hex()` on the way out,
+ and `hex2bin()` (or `@hex2bin()`) on the way in. The public surface is a
+ hex string.
+3. **Surface every failure as `EncryptionException`.** Wrap any underlying
+ exception (`SodiumException`, `OpenSSLException`, `JsonException`, ...)
+ so callers only need one `catch`.
+
+## A Full Example
+
+Save this as `src/XChaCha20Handler.php` (in your application namespace, not
+the package's):
+
+```php
+resolveOptions($options);
+ $userKey = $this->requireKey($options);
+ $flag = $this->serializerFlag($options);
+
+ $payload = $this->serializePayload($data, $flag);
+
+ $key = $this->deriveKey($userKey);
+ $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
+ $header = chr(self::FORMAT_VERSION) . chr($flag);
+
+ try {
+ // header is also the AEAD's additional data, so any flip
+ // to the version / serializer byte invalidates the MAC.
+ $box = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
+ $payload,
+ $header,
+ $nonce,
+ $key,
+ );
+ } finally {
+ sodium_memzero($key);
+ }
+
+ return bin2hex($header . $nonce . $box);
+ }
+
+ public function decrypt(string $data, array $options = []): mixed
+ {
+ $options = $this->resolveOptions($options);
+ $userKey = $this->requireKey($options);
+
+ $binary = @hex2bin($data);
+ if ($binary === false) {
+ throw new EncryptionException('Ciphertext is not valid hex-encoded data.');
+ }
+
+ $minLength = 2
+ + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
+ + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES;
+ if (strlen($binary) < $minLength) {
+ throw new EncryptionException('Ciphertext is too short.');
+ }
+
+ $version = ord($binary[0]);
+ if ($version !== self::FORMAT_VERSION) {
+ throw new EncryptionException(
+ sprintf('Unsupported ciphertext format version 0x%02x.', $version),
+ );
+ }
+ $flag = ord($binary[1]);
+ $header = substr($binary, 0, 2);
+
+ $nonce = substr($binary, 2, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
+ $box = substr($binary, 2 + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
+
+ $key = $this->deriveKey($userKey);
+ try {
+ $payload = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
+ $box,
+ $header,
+ $nonce,
+ $key,
+ );
+ } catch (SodiumException $e) {
+ throw new EncryptionException('XChaCha20 decryption failed.', 0, $e);
+ } finally {
+ sodium_memzero($key);
+ }
+
+ if ($payload === false) {
+ throw new EncryptionException(
+ 'XChaCha20 decryption failed; ciphertext is corrupted or has been tampered with.',
+ );
+ }
+
+ return $this->unserializePayload($payload, $flag);
+ }
+
+ private function deriveKey(string $userKey): string
+ {
+ return sodium_crypto_generichash(
+ $userKey,
+ '',
+ SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES,
+ );
+ }
+}
+```
+
+Use it the same way as the built-in handlers:
+
+```php
+ 'secret']);
+$ct = $handler->encrypt(['user_id' => 42]);
+$pt = $handler->decrypt($ct);
+
+assert($pt === ['user_id' => 42]);
+```
+
+## What `BaseHandler` Gives You
+
+These protected helpers are the contract — use them rather than rolling your
+own equivalents, so your handler picks up package-wide behaviour
+automatically.
+
+| Helper | What it does |
+| --- | --- |
+| `resolveOptions(array $options): array` | Merges per-call options on top of persistent ones; always returns a copy. Use this at the top of `encrypt()` / `decrypt()`. |
+| `requireKey(array $options): string` | Reads `'key'`; throws `EncryptionException` if missing, empty, or not a string. |
+| `serializerFlag(array $options): int` | Resolves the configured serializer name (`'json'`, `'php_serialize'`, aliases…) to the on-wire flag byte. |
+| `serializePayload(mixed $data, int $flag): string` | Encodes a payload according to the flag. Throws on JSON-encode failure. |
+| `unserializePayload(string $data, int $flag): mixed` | Reverses `serializePayload`; PHP-serialized payloads use `allowed_classes: false`. |
+
+Public option-management methods are inherited as-is: `setOption`,
+`setOptions`, `getOption`, `getOptions`. You do not need to redeclare them.
+
+## Adding Custom Option Keys
+
+If your handler needs configuration beyond what `BaseHandler` provides, just
+read it out of the resolved options:
+
+```php
+$options = $this->resolveOptions($options);
+$rotation = (int) ($options['rotation_id'] ?? 0);
+```
+
+`setOption(string, mixed)` and `setOptions(array)` already accept arbitrary
+keys (they are lowercased on the way in, so input is case-insensitive).
+There is no need to declare a property — just read what you need.
+
+If you want IDE/PHPStan help on your custom keys, declare a typed property
+or use a `@phpstan-type` alias.
+
+## Versioning Your Own Format
+
+The package reserves `0x02` for its own format. If you want to evolve your
+custom handler's layout without breaking deployed ciphertexts, do **not**
+overload the package's version byte — instead, add your own version byte
+*inside* your payload, between the format header and your data:
+
+```text
++---------+-----------+----------------+--------------------+
+| 0x02 | flag | my-handler-ver | ... your bytes ... |
++---------+-----------+----------------+--------------------+
+```
+
+That way the package-level "is this a v2 ciphertext?" check still works, and
+you have a private version byte you can bump independently.
+
+## Testing Your Handler
+
+The package's own test suite extends `BaseHandler` via
+`tests/Fixtures/DummyHandler.php` to exercise the protected helpers without
+needing a real cryptographic primitive. You can do the same in your own
+project; or, if your handler hits a real extension, write tests modelled on
+`tests/Unit/OpenSSLTest.php` and `tests/Unit/SodiumTest.php` and run them
+under PHPUnit.
+
+At minimum, every custom handler should have tests for:
+
+- A round-trip across the data types your callers will pass.
+- Tampered ciphertext is rejected.
+- A ciphertext with the wrong version byte is rejected.
+- A missing or empty `key` raises `EncryptionException`.
+- Per-call options do not mutate handler state (no leaked option writes).
+
+## See Also
+
+- [02 — OpenSSL Handler](./02-openssl-handler.md) — reference implementation
+ with HKDF + HMAC.
+- [03 — Sodium Handler](./03-sodium-handler.md) — reference implementation
+ with secretbox.
+- [07 — Security](./07-security.md) — what the package as a whole considers
+ in-scope vs out-of-scope.
diff --git a/docs/05-options.md b/docs/05-options.md
new file mode 100644
index 0000000..5410c83
--- /dev/null
+++ b/docs/05-options.md
@@ -0,0 +1,164 @@
+# Options Reference
+
+Every handler accepts the same option array. The set of *meaningful* keys
+depends on the handler; the rest are ignored. Unknown keys do not raise an
+error.
+
+## Quick Reference
+
+| Key | Type | Default | OpenSSL | Sodium | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `key` | non-empty string | _required_ | ✅ | ✅ | Your secret. Any length. |
+| `cipher` | string from `openssl_get_cipher_methods()` | `'AES-256-CTR'` | ✅ | — | Validated at every call. |
+| `algo` | string from `hash_hmac_algos()` | `'SHA256'` | ✅ | — | Used both for HKDF and HMAC. |
+| `blocksize` | positive integer (or string of digits) | `16` | — | ✅ | `sodium_pad()` block size. |
+| `serializer` | `'json'`, `'php_serialize'`, `'php'`, `'serialize'` | `'json'` | ✅ | ✅ | Embedded in the ciphertext header. |
+
+All option keys are **case-insensitive on input** (`'CIPHER'`, `'Cipher'`,
+`'cipher'` all set the same thing). They are stored as lower case.
+
+## Precedence
+
+When the same key appears in multiple places, the per-call value wins:
+
+```text
+1. encrypt($data, $perCallOptions) // highest priority
+2. $handler->setOption($name, $value)
+ $handler->setOptions([...])
+3. new OpenSSL([...]) // constructor / factory
+4. BaseHandler::$options defaults // lowest priority
+```
+
+Per-call options never mutate the handler — they are merged into a fresh
+array for that single call only.
+
+```php
+$handler = new \InitPHP\Encryption\OpenSSL([
+ 'key' => 'secret',
+ 'cipher' => 'AES-256-CTR',
+]);
+$handler->encrypt('x', ['cipher' => 'AES-128-CTR']);
+echo $handler->getOption('cipher'); // "AES-256-CTR"
+```
+
+## `key` — required
+
+The user-supplied secret. Any non-empty string is accepted.
+
+- Stored as-is on the handler (you can see it via `getOption('key')`).
+- Derived to the size each primitive needs:
+ - OpenSSL → `hash_hkdf($algo, $key)`, output sized to the hash.
+ - Sodium → `sodium_crypto_generichash($key, '', 32)`.
+- `null`, an empty string, or any non-string raises
+ `EncryptionException: The "key" option is required and must be a non-empty string.`
+
+What "good" looks like:
+
+```php
+'key' => getenv('APP_ENCRYPTION_KEY') ?: throw new \RuntimeException('key missing');
+```
+
+What "bad" looks like:
+
+```php
+'key' => 'password' // brute-forceable, no entropy.
+'key' => null // throws EncryptionException.
+'key' => '' // throws EncryptionException.
+```
+
+Generate a strong key once:
+
+```bash
+php -r 'echo bin2hex(random_bytes(32)), "\n";'
+```
+
+## `cipher` — OpenSSL only
+
+The OpenSSL cipher name as accepted by `openssl_encrypt()`. Validated against
+`openssl_get_cipher_methods()` (case-insensitive) on every call; an unknown
+value raises `EncryptionException: Unknown OpenSSL cipher "…"`.
+
+| Value | Notes |
+| --- | --- |
+| `'AES-256-CTR'` (default) | Stream-mode AES, no padding, 16-byte IV. |
+| `'AES-256-CBC'` | Block-mode AES with PKCS#7 padding. Slightly bigger ciphertext. |
+| `'AES-128-CTR'` / `'AES-128-CBC'` | Same shapes with smaller derived key. |
+| `'ChaCha20'` | Stream cipher; OpenSSL ≥1.1 required. |
+
+Do **not** use GCM modes (`AES-256-GCM`, `ChaCha20-Poly1305`) here — the
+handler does not surface OpenSSL's separate auth tag. Use the Sodium handler
+if you want AEAD.
+
+## `algo` — OpenSSL only
+
+The hashing algorithm used in two places:
+
+1. HKDF to derive the per-handler secret from your user key.
+2. HMAC to authenticate the ciphertext.
+
+Validated against `hash_hmac_algos()` (case-insensitive) on every call.
+
+| Value | Notes |
+| --- | --- |
+| `'SHA256'` (default) | Universal, fast. 32-byte HMAC. |
+| `'SHA512'` | Stronger; 64-byte HMAC adds 32 bytes per ciphertext. |
+| `'SHA3-256'` / `'SHA3-512'` | SHA-3 family. |
+| `'MD5'`, `'SHA1'` | Technically accepted, not recommended. |
+
+## `blocksize` — Sodium only
+
+Block size used for `sodium_pad()` and `sodium_unpad()` to hide the exact
+plaintext length.
+
+- Must be a **positive integer**. String digits like `'32'` are coerced
+ (`(int) '32' === 32`).
+- An explicit `null` falls back to the default `16` via PHP's `??` operator.
+- `0`, negative numbers, floats, arrays, and non-numeric strings raise
+ `EncryptionException: The "blocksize" option must be a positive integer.`
+- Larger values give more length-hiding but produce proportionally larger
+ ciphertexts.
+
+## `serializer`
+
+Picks how `encrypt()` turns your `mixed` payload into bytes (and how
+`decrypt()` reverses that).
+
+| Value | Flag byte | Behaviour |
+| --- | --- | --- |
+| `'json'` (default), `'JSON'` | `0x00` | `json_encode` / `json_decode` with `JSON_THROW_ON_ERROR`. Safe (no objects instantiated). Cannot carry raw binary bytes. |
+| `'php_serialize'`, `'php'`, `'serialize'`, `'SERIALIZE'` | `0x01` | `serialize()` / `unserialize($data, ['allowed_classes' => false])`. 8-bit clean. Custom objects degrade to `__PHP_Incomplete_Class` on decode. |
+
+The flag byte is embedded in the ciphertext header (byte 1), so `decrypt()`
+always restores using the same serializer `encrypt()` chose, even if you
+change the option in between.
+
+Unknown serializer names raise
+`EncryptionException: Unknown "serializer" option: …`.
+
+## Reading Options Back
+
+```php
+$handler->getOption('cipher'); // current value, default if unset
+$handler->getOption('foo', 'fallback'); // returns 'fallback' if unset
+$handler->getOptions(); // entire array
+```
+
+Keys passed to `getOption()` are lowercased before lookup, matching the
+case-insensitive write side.
+
+## Writing Custom Option Keys
+
+Custom handlers can store anything in the options array — there is no
+declared schema. Read with `$this->resolveOptions($options)['my_key'] ?? …`
+inside `encrypt()` / `decrypt()`.
+
+See [04 — Custom Handlers](./04-custom-handlers.md) for a worked example.
+
+## See Also
+
+- [02 — OpenSSL Handler](./02-openssl-handler.md) for the cipher/algo
+ trade-offs in context.
+- [03 — Sodium Handler](./03-sodium-handler.md) for the blocksize/padding
+ trade-offs.
+- [06 — Error Handling](./06-error-handling.md) for every option-related
+ exception message.
diff --git a/docs/06-error-handling.md b/docs/06-error-handling.md
new file mode 100644
index 0000000..95dea41
--- /dev/null
+++ b/docs/06-error-handling.md
@@ -0,0 +1,124 @@
+# Error Handling
+
+`initphp/encryption` has exactly one exception class:
+`InitPHP\Encryption\Exceptions\EncryptionException`. It extends
+`\RuntimeException`, so a single `try` / `catch` covers every failure path.
+
+```php
+use InitPHP\Encryption\Exceptions\EncryptionException;
+
+try {
+ $plaintext = $handler->decrypt($incoming);
+} catch (EncryptionException $e) {
+ // handle, log, surface — but DO NOT echo $e->getMessage() to end users.
+ $logger->warning('decryption failed', ['msg' => $e->getMessage()]);
+ return null;
+}
+```
+
+## When to Catch What
+
+| Caller intent | What to catch | Why |
+| --- | --- | --- |
+| "Decrypt this; if anything is wrong, the user gets a generic error." | `EncryptionException` | This is 95% of cases. The single class is enough. |
+| "I want to differentiate tampering from server misconfiguration." | `EncryptionException`, inspect `getMessage()` against the table below. | The package does not currently subclass `EncryptionException` per failure mode. Substring-match on the message if you really need to. |
+| "I want to retry on transient errors." | Don't. | No error in this package is transient. Either the input is wrong (retry won't help) or the configuration is wrong (retry won't help). |
+
+## Catalogue of Error Messages
+
+Every message the package can produce, what triggers it, and what a caller
+should do about it.
+
+### Factory (`Encrypt::use()`)
+
+| Message | Trigger | Caller action |
+| --- | --- | --- |
+| `Handler class "…" does not exist.` | Passed a string that is not a loaded class. | Fix the class name; this is a programming error. |
+| `Handler class "…" must implement InitPHP\Encryption\HandlerInterface.` | Passed a class that exists but doesn't implement the interface. | Either implement the interface or pass a different class. |
+
+### Common (raised by `BaseHandler`)
+
+| Message | Trigger | Caller action |
+| --- | --- | --- |
+| `The "key" option is required and must be a non-empty string.` | `key` option missing, `null`, empty string, or not a string. | Set `'key' => …` to a real secret. Most likely a deployment/config bug. |
+| `Unknown "serializer" option: ….` | `serializer` is not one of `json`, `php_serialize`, `php`, `serialize`. | Use one of the supported names. See [05 — Options](./05-options.md). |
+| `Unknown serializer flag 0x….` | Internal — should never happen unless your ciphertext was produced by an incompatible build. | Open an issue. |
+| `Failed to JSON-encode payload: …` | Payload contains a value `json_encode` rejects (NaN, INF, non-UTF-8 binary, resource). | Switch to `'serializer' => 'php_serialize'` or sanitise the payload. |
+| `Failed to JSON-decode payload: …` | Ciphertext was JSON-serialized but the bytes are no longer valid JSON. | Means tampering or corruption — treat like a decrypt failure. |
+| `Failed to unserialize the decrypted payload.` | Ciphertext was PHP-serialized but the bytes are no longer parseable. | Same — likely corruption. |
+
+### OpenSSL Handler
+
+| Message | Trigger | Caller action |
+| --- | --- | --- |
+| `The "openssl" extension is required by the OpenSSL handler.` | `ext-openssl` is not loaded. | Install the extension or switch to the Sodium handler. |
+| `The "cipher" option is required and must be a non-empty string.` | `cipher` option is missing or not a string. | Set `'cipher' => …` (default is `AES-256-CTR`). |
+| `Unknown OpenSSL cipher "…".` | `cipher` is not in `openssl_get_cipher_methods()`. | Use a supported cipher name. |
+| `The "algo" option is required and must be a non-empty string.` | `algo` is missing or not a string. | Set `'algo' => …` (default is `SHA256`). |
+| `Unknown HMAC hashing algorithm "…".` | `algo` is not in `hash_hmac_algos()`. | Use a supported hash name. |
+| `Unable to determine IV length for cipher "…".` | `openssl_cipher_iv_length()` returned `false`. Rare; usually means the cipher name is valid but the build doesn't support it. | Pick a different cipher. |
+| `OpenSSL encryption failed: ….` | `openssl_encrypt()` returned `false`. Includes the OpenSSL error string when available. | Investigate the included message; usually a cipher/IV mismatch. |
+| `OpenSSL decryption failed: ….` | `openssl_decrypt()` returned `false` after HMAC passed (extremely unlikely). | Treat as corruption. |
+| `Ciphertext is not valid hex-encoded data.` | `decrypt()` input is not even-length hex. | Reject input — user-supplied corruption. |
+| `Ciphertext is shorter than the 2-byte header.` | Input decodes to fewer than 2 bytes. | Reject input. |
+| `Unsupported ciphertext format version 0x..; expected 0x02. Ciphertexts produced by 1.x are not readable by 2.x.` | Version byte ≠ `0x02`. | Either 1.x data (see [migration guide](./08-migration-v1-to-v2.md)) or random/corrupt input. |
+| `Ciphertext is too short for the configured algorithm and cipher.` | Input doesn't have enough bytes for the header + HMAC + IV. | Reject input. |
+| `HMAC verification failed; ciphertext is corrupted or has been tampered with.` | The HMAC computed from the wire doesn't match the one stored. | **Reject and log.** This is what tampering looks like. |
+
+### Sodium Handler
+
+| Message | Trigger | Caller action |
+| --- | --- | --- |
+| `The "sodium" extension is required by the Sodium handler.` | `ext-sodium` is not loaded. | Install the extension or switch to OpenSSL. |
+| `The "blocksize" option must be a positive integer.` | `blocksize` is `0`, negative, non-integer (other than digit string), or non-numeric. | Use a positive integer. |
+| `Sodium padding failed: ….` | `sodium_pad()` raised a `SodiumException`. Rare; usually means the block size is unreasonable (e.g. `PHP_INT_MAX`). | Use a sane block size (16–256). |
+| `Sodium unpadding failed: ….` | `sodium_unpad()` raised — the decrypted bytes are not properly padded. Implies corruption since the MAC already passed. | Treat as corruption. |
+| `Ciphertext is not valid hex-encoded data.` | Same as OpenSSL. | Reject input. |
+| `Ciphertext is too short to contain a v2 Sodium payload.` | Input decodes to fewer than 2 + nonce + MAC bytes. | Reject input. |
+| `Unsupported ciphertext format version 0x..; expected 0x02. Ciphertexts produced by 1.x are not readable by 2.x.` | Version byte ≠ `0x02`. | Same as OpenSSL. |
+| `Sodium decryption failed; ciphertext is corrupted or has been tampered with.` | `sodium_crypto_secretbox_open()` returned `false`. | **Reject and log.** This is what tampering or wrong-key looks like. |
+
+## Logging Failed Decryptions
+
+A repeated burst of `HMAC verification failed` / `Sodium decryption failed`
+from one source is the textbook signature of either:
+
+- A serialisation bug in a caller that started corrupting ciphertexts.
+- An actual attacker trying random or modified ciphertexts.
+
+Log enough to tell the two apart (source identifier, count over time) — but
+do not log the offending ciphertext itself (it may contain secrets if the
+attacker is testing exfiltration). The exception message is safe.
+
+```php
+try {
+ $handler->decrypt($incoming);
+} catch (EncryptionException $e) {
+ $logger->warning('decrypt failed', [
+ 'source' => $request->ip(),
+ 'reason' => $e->getMessage(),
+ ]);
+}
+```
+
+## Don't Show Messages to End Users
+
+Most messages here would help an attacker tune their input. Map every
+`EncryptionException` to a generic user-facing message and put the detail in
+the server log only.
+
+```php
+try {
+ return $handler->decrypt($incoming);
+} catch (EncryptionException) {
+ return response('Invalid request', 400);
+}
+```
+
+## See Also
+
+- [05 — Options Reference](./05-options.md) — every option, every validation
+ rule.
+- [07 — Security](./07-security.md) — how to think about the attacker model.
+- [08 — Migration 1.x → 2.x](./08-migration-v1-to-v2.md) — what to do when
+ you see the `Unsupported ciphertext format version` message in production.
diff --git a/docs/07-security.md b/docs/07-security.md
new file mode 100644
index 0000000..09bab76
--- /dev/null
+++ b/docs/07-security.md
@@ -0,0 +1,222 @@
+# Security
+
+This document is the package's threat model: what `initphp/encryption`
+defends against, what it does not, and the operational practices you need
+to add for the whole thing to be useful in production.
+
+For *reporting* a vulnerability, see [`SECURITY.md`](../SECURITY.md). This
+file is the design context behind it.
+
+## Threat Model
+
+We assume an attacker who can:
+
+- See, store, modify or replay any ciphertext your application produces.
+- Submit arbitrary input to `decrypt()` — random bytes, hex of someone
+ else's ciphertext, bytes they generated themselves.
+- See timing of `decrypt()` calls.
+
+We assume the attacker **cannot**:
+
+- Read process memory.
+- Read your `key` from your secret store.
+- Modify the package source code at runtime.
+
+Given those assumptions, the package guarantees:
+
+1. **Confidentiality of plaintext.** An attacker who sees a ciphertext
+ cannot recover the plaintext without the key.
+2. **Integrity of ciphertext.** Any modification — single bit flip, byte
+ rearrangement, truncation, extension — is detected and rejected with
+ `EncryptionException`. `decrypt()` will never return a value that was not
+ produced by `encrypt()` with the same key.
+3. **Bound on what `decrypt()` does with attacker bytes.** Even the
+ `php_serialize` serializer is invoked with `allowed_classes: false`, so
+ `decrypt()` cannot instantiate arbitrary application classes via PHP's
+ gadget-chain machinery, even if an attacker somehow obtained the key.
+4. **No silent failure.** A failed decrypt always throws — never returns
+ `null`, `false`, or a corrupted value.
+
+What the package does **not** guarantee:
+
+- **Confidentiality of plaintext length.** Ciphertexts grow with their
+ plaintext. The Sodium handler can mitigate this with `blocksize`; the
+ OpenSSL handler cannot.
+- **Confidentiality of plaintext existence.** That you `encrypt()`'d
+ something is observable to anyone watching your process. The Sodium nonce
+ and OpenSSL IV are randomly different each call, so two ciphertexts of the
+ same plaintext look unrelated — but the fact that *a* ciphertext exists
+ is unhidden.
+- **Forward secrecy.** If your key is later compromised, every ciphertext
+ ever produced with that key is decryptable. Rotate keys (see below) to
+ bound the blast radius.
+- **Resistance to a brute-force search of weak keys.** The handler derives
+ a key of the correct length from arbitrary input, but no derivation can
+ add entropy. A user key of `"password"` is worth roughly nothing.
+
+## Cryptographic Constructions
+
+### OpenSSL handler
+
+- **Key derivation**: `hash_hkdf($algo, $userKey)`. Output length equals the
+ hash output (32 bytes for SHA-256, etc.) regardless of `$userKey` length.
+- **Encryption**: `openssl_encrypt(..., OPENSSL_RAW_DATA, $iv)` with a fresh
+ random IV per call (via `random_bytes()`, the OS CSPRNG).
+- **Authentication**: HMAC-SHA-N over `VERSION || SERIALIZER || IV ||
+ ciphertext`. Comparison uses `hash_equals()` to avoid timing leaks.
+- **Encoding**: hex.
+
+The construction is *encrypt-then-MAC* with a single derived key for both
+operations. The HMAC covers the format header so an attacker cannot flip
+the serializer byte to coax a different deserialization path.
+
+### Sodium handler
+
+- **Key derivation**: BLAKE2b via `sodium_crypto_generichash($userKey, '',
+ 32)`. The derived key is held in a local buffer and zeroed via
+ `sodium_memzero()` in a `finally` block.
+- **Encryption + authentication**: `sodium_crypto_secretbox()`
+ (XSalsa20-Poly1305). The Poly1305 MAC is part of the construction.
+- **Nonce**: 24 random bytes per call (`random_bytes()`). At 24 bytes the
+ collision probability is negligible — you can encrypt billions of
+ messages per key without practical risk.
+- **Padding**: `sodium_pad()` before encryption / `sodium_unpad()` after, to
+ reduce plaintext-length leakage.
+- **Encoding**: hex.
+
+## Key Management
+
+The package is only as secure as your key handling.
+
+### Generating a Key
+
+```bash
+php -r 'echo bin2hex(random_bytes(32)), "\n";'
+# → 64-character hex string, 256 bits of entropy
+```
+
+That goes into your secret store. Do **not** commit it to the repository.
+
+### Storing the Key
+
+In order of preference:
+
+1. A dedicated secrets manager (AWS Secrets Manager / GCP Secret Manager /
+ HashiCorp Vault / Doppler / 1Password Secrets Automation).
+2. An environment variable populated at process start by your orchestrator
+ (Kubernetes `Secret`, systemd `EnvironmentFile`, Docker secrets).
+3. A `.env` file outside the document root, chmod 600, owned by the PHP
+ process user. The bottom rung — appropriate for small deployments, not
+ for anything with regulatory exposure.
+
+What you must not do:
+
+- Commit the key into git.
+- Put the key in a file under the document root.
+- Pass the key on the command line (visible to `ps` and shell history).
+- Log the key, even in debug builds.
+
+### Rotating the Key
+
+Plan for rotation from day one. The recipe for a hot-rotation without
+downtime:
+
+```php
+// 1) Read both keys; new one is preferred for encryption.
+$oldKey = getenv('APP_ENCRYPTION_KEY_PREVIOUS');
+$newKey = getenv('APP_ENCRYPTION_KEY');
+
+// 2) All encrypts use the new key.
+$writer = Encrypt::use(Sodium::class, ['key' => $newKey]);
+
+// 3) Decrypts try the new key first, fall back to the old one.
+function decryptWithRotation(string $ct, string $newKey, ?string $oldKey): mixed
+{
+ try {
+ return (new Sodium(['key' => $newKey]))->decrypt($ct);
+ } catch (\InitPHP\Encryption\Exceptions\EncryptionException) {
+ if ($oldKey === null || $oldKey === '') {
+ throw new RuntimeException('decrypt failed and no fallback key available');
+ }
+ // Old key proves the cipher is from the previous epoch; opportunistically
+ // re-encrypt and persist with the new key from the caller.
+ return (new Sodium(['key' => $oldKey]))->decrypt($ct);
+ }
+}
+```
+
+After every stored ciphertext has been re-encrypted under the new key, drop
+`APP_ENCRYPTION_KEY_PREVIOUS` from the secret store.
+
+Caveat: every failed decrypt now triggers two MAC checks instead of one.
+That's a fixed cost, not an attacker-amplifiable one.
+
+### Compromise Response
+
+If you suspect the key has leaked:
+
+1. Generate a new key.
+2. Deploy with both keys present (as in the rotation pattern above).
+3. Re-encrypt every stored ciphertext.
+4. Drop the old key from secrets.
+5. Audit access to the key store: who saw it, when, from where.
+
+Every ciphertext encrypted under the leaked key is *retroactively
+decryptable* by the attacker. Forward secrecy is not part of the package's
+guarantee — if leak is a realistic concern, design your system so that
+ciphertexts that survive past their useful life are deleted, not just
+re-encrypted.
+
+## Ciphertext Storage
+
+- **Cookies and URL parameters**: fine. Hex is URL-safe. Set
+ `HttpOnly`, `Secure`, `SameSite=Strict` on cookies as usual.
+- **Databases**: fine. Store as `TEXT` / `VARCHAR`. Hex doubles the byte
+ size; if storage cost matters, base64-decode-then-store, but that is an
+ application choice the package does not currently make for you.
+- **Logs and error messages**: do not log ciphertext. It is not plaintext,
+ but it is sensitive (it tells an attacker what queries you encrypted, and
+ reveals the ciphertext for future cryptanalysis if a primitive break is
+ ever found).
+- **Public artefacts (web responses to anonymous users)**: only if the
+ ciphertext is *meant* to round-trip back to your service. A session
+ cookie, fine. A leaked ciphertext otherwise has no business being public.
+
+## Side Channels
+
+- **Timing**: HMAC comparison uses `hash_equals()` (constant-time). Sodium's
+ secretbox open is constant-time by construction. The OpenSSL handler's
+ hex-decode, format checks, and option resolution are *not* constant-time
+ — they reveal "this didn't even pass the size check" vs "this failed at
+ the MAC step", but the difference is not load-bearing in the threat model
+ above.
+- **Memory**: Sodium's derived key is wiped via `sodium_memzero()`. The
+ user-supplied key is not; it lives wherever the caller put it.
+- **Cache / Spectre / Meltdown**: out of scope. If you run untrusted
+ co-tenants on the same machine, no userland crypto library can help you.
+
+## Recommended Defaults
+
+If you have no strong opinion, use:
+
+```php
+use InitPHP\Encryption\Encrypt;
+use InitPHP\Encryption\Sodium;
+
+$handler = Encrypt::use(Sodium::class, [
+ 'key' => getenv('APP_ENCRYPTION_KEY'),
+ // 'serializer' => 'json', // default
+ // 'blocksize' => 16, // default
+]);
+```
+
+This is XSalsa20-Poly1305 AEAD with a 256-bit derived key, JSON-serialized
+payloads, and 16-byte length padding. There is nothing else to tune.
+
+## See Also
+
+- [03 — Sodium Handler](./03-sodium-handler.md) — implementation details of
+ the AEAD construction.
+- [06 — Error Handling](./06-error-handling.md) — every error message and
+ what an attacker may have triggered to produce it.
+- [`SECURITY.md`](../SECURITY.md) — how to report a vulnerability.
diff --git a/docs/08-migration-v1-to-v2.md b/docs/08-migration-v1-to-v2.md
new file mode 100644
index 0000000..957ef92
--- /dev/null
+++ b/docs/08-migration-v1-to-v2.md
@@ -0,0 +1,193 @@
+# Migrating from 1.x to 2.x
+
+`initphp/encryption` 2.0 is a deliberate hard reset. It tightens the type
+system, derives keys to the right length automatically, defaults to a safer
+serializer, and adopts a self-describing ciphertext format that rejects
+ambiguous input.
+
+The cost: **ciphertexts produced by 1.x cannot be decrypted by 2.x.** You
+need a re-encryption plan before you upgrade.
+
+This page is the full checklist.
+
+## TL;DR
+
+1. Bump PHP to **8.1+** in your CI and production environments.
+2. Add 1.x and 2.x side by side temporarily so you can decrypt with 1.x and
+ re-encrypt with 2.x (see [Re-encryption pattern](#re-encryption-pattern)).
+3. Replace `Encrypt::create(...)` with `Encrypt::use(...)`.
+4. Decide whether your stored payloads should switch to JSON (recommended)
+ or keep using `serialize`/`unserialize`. The new option is
+ `'serializer' => 'php_serialize'` if you keep the old behaviour.
+5. Remove the old, manually-derived 32-byte key dance for Sodium — pass any
+ non-empty key now.
+6. Drop `ext-mbstring` from your runtime requirements if you only required
+ it for this package.
+
+## What Changed
+
+### Minimum PHP version
+
+| | 1.x | 2.x |
+| --- | --- | --- |
+| PHP | `>= 7.4` | `^8.1` |
+
+The 2.x source uses `mixed`, `static` return types, `enum` (via match), and
+readonly-friendly patterns that require PHP 8.1.
+
+### Ciphertext format
+
+1.x ciphertexts had no version byte: the first bytes were the HMAC (for
+OpenSSL) or the nonce (for Sodium), followed directly by the encrypted
+payload. 2.x prepends a 2-byte header (`VERSION || SERIALIZER_FLAG`).
+
+Concretely, this means **a 2.x handler asked to decrypt a 1.x ciphertext
+will throw**:
+
+```text
+EncryptionException: Unsupported ciphertext format version 0x..; expected 0x02.
+Ciphertexts produced by 1.x are not readable by 2.x.
+```
+
+There is no auto-upgrade path. Re-encrypt your data (see below) before
+flipping the handler classes.
+
+### Default payload serializer
+
+| | 1.x | 2.x |
+| --- | --- | --- |
+| Default | `serialize()` / `unserialize()` | JSON |
+| Why | Backwards-compatible with PHP's native shapes. | `unserialize()` of attacker-controlled bytes is the canonical PHP object-injection vector. JSON cannot instantiate classes. |
+| Opt-in to old behaviour | n/a | `'serializer' => 'php_serialize'` |
+
+If your payloads are scalars, arrays, or plain objects (`stdClass` /
+deeply-mapped DTOs), JSON is fine and is the recommended target. If they
+contain raw binary blobs (e.g. `random_bytes()` output), JSON is not
+8-bit-clean — keep `php_serialize`.
+
+### Key length for Sodium
+
+1.x silently broke if the user key wasn't exactly 32 bytes long. The README
+example (`'key' => 'TOP_Secret_Key'`, 14 bytes) actually couldn't run.
+
+2.x derives a 32-byte key from any non-empty input via
+`sodium_crypto_generichash`. Any key string you can pass in now Just Works.
+
+If your application was working around this by passing a pre-derived
+32-byte key, **stop doing that** — let the package do the derivation. If you
+keep doing your own derivation and the result differs from BLAKE2b of your
+old key, your ciphertexts won't decrypt cleanly across the upgrade.
+
+### Removed API
+
+- **`Encrypt::create()`** — was an alias for `Encrypt::use()`. Use
+ `Encrypt::use()` instead.
+- **`ext-mbstring` requirement** — the package no longer uses any mbstring
+ function. Drop it from your `require` if it was there only for this
+ package.
+
+### Tightened API
+
+- `OpenSSL` and `Sodium` are now `final`. If you were extending them, switch
+ to extending `BaseHandler` (see [04 — Custom Handlers](./04-custom-handlers.md)).
+- `decrypt()` returns `mixed` instead of an untyped value. Consumers
+ typehinted `: string` will start to fail.
+- `EncryptionException` extends `\RuntimeException` (was `\Exception`). All
+ existing `catch (Exception $e)` and `catch (EncryptionException $e)`
+ callsites continue to work.
+- The HandlerInterface is now narrower: it requires `encrypt()`, `decrypt()`,
+ and `setOptions()` with `mixed` / `string` / `static` types. If you
+ implemented the interface yourself, update the signatures.
+
+### New API
+
+- `Encrypt::use()` accepts a typed `string|HandlerInterface` parameter
+ (previously documented but not enforced).
+- `BaseHandler::SERIALIZER_JSON` and `BaseHandler::SERIALIZER_PHP`
+ constants for setting the serializer option without string literals.
+- `BaseHandler::FORMAT_VERSION` constant (currently `0x02`) — useful when
+ writing tests against the wire format.
+- `BaseHandler::setOptions()` and `setOption()` return `static`, so
+ fluent chains preserve the concrete handler type.
+- New per-handler error messages identifying what specifically went wrong
+ (the 1.x message was always `"Decryption failed!"`).
+
+## Re-encryption Pattern
+
+The minimal-disruption recipe:
+
+```php
+// composer.json: require BOTH versions, namespaced separately, e.g. via a
+// fork like initphp/encryption-v1 (manual setup). In practice most users
+// vendor the 1.x source into App\Legacy\Crypto for the migration window
+// and delete it afterwards.
+
+use App\Legacy\Crypto\OpenSSL as LegacyOpenSSL; // 1.x
+use InitPHP\Encryption\OpenSSL; // 2.x
+
+$legacy = new LegacyOpenSSL([
+ 'key' => getenv('APP_ENCRYPTION_KEY'),
+ // ... whatever options 1.x was using
+]);
+
+$new = new OpenSSL([
+ 'key' => getenv('APP_ENCRYPTION_KEY'),
+ // 'serializer' => 'php_serialize', // ← keep the old serializer
+ // until you also convert payloads
+]);
+
+// Migration step (background job, batched):
+foreach (legacyCiphertextRows() as $row) {
+ try {
+ $plaintext = $legacy->decrypt($row->ciphertext);
+ } catch (\Throwable $e) {
+ // 1.x didn't have version bytes; a successful decrypt is the
+ // strongest evidence the row is in fact 1.x.
+ logger()->warning('legacy decrypt failed', [
+ 'id' => $row->id,
+ 'msg' => $e->getMessage(),
+ ]);
+ continue;
+ }
+ $newCiphertext = $new->encrypt($plaintext);
+ persist($row->id, $newCiphertext);
+}
+```
+
+When every row has been re-encrypted: remove the legacy import,
+`composer remove` the fork, delete the migration job, and drop the dual-key
+fallback from your runtime code path.
+
+## "I Can't Re-encrypt — Migration Is Off the Table"
+
+Then either:
+
+- **Stay on 1.x.** It still works on its supported PHP range. There is no
+ forced upgrade.
+- **Pin 1.x indefinitely** in your `composer.json` (`"initphp/encryption":
+ "^1.0"`) and accept that security fixes will only land in 2.x.
+
+Per the [org-wide security policy](../SECURITY.md), only the latest stable
+major receives security fixes. Once 2.0 ships, 1.x is end-of-life.
+
+## Quick Checklist Before You Deploy 2.x
+
+- [ ] CI passes on PHP 8.1+ (drop older PHP from your matrix).
+- [ ] All `Encrypt::create(...)` calls replaced with `Encrypt::use(...)`.
+- [ ] No code extends `OpenSSL` or `Sodium` directly (extend `BaseHandler`).
+- [ ] `decrypt()` callers handle `mixed` (or you cast in one place).
+- [ ] Re-encryption job has finished, or you have an explicit dual-stack
+ decrypt path with a `try` / `catch (EncryptionException)` fallback.
+- [ ] You decided whether to switch to `'serializer' => 'json'` or pin
+ `'serializer' => 'php_serialize'`.
+- [ ] `ext-mbstring` removed from your require list if you don't need it
+ elsewhere.
+
+## See Also
+
+- [01 — Getting Started](./01-getting-started.md) — to learn the new
+ defaults.
+- [05 — Options](./05-options.md) — the full new option surface.
+- [07 — Security](./07-security.md) — what `unserialize` on attacker bytes
+ used to risk, and why JSON is the new default.
+- [`CHANGELOG.md`](../CHANGELOG.md) — the canonical list of changes in 2.0.
diff --git a/docs/09-faq.md b/docs/09-faq.md
new file mode 100644
index 0000000..578ea61
--- /dev/null
+++ b/docs/09-faq.md
@@ -0,0 +1,160 @@
+# FAQ
+
+Common questions, organised roughly by how often they come up.
+
+## "Which handler should I use?"
+
+If you have no opinion, **Sodium**. The construction has no knobs, the
+defaults are correct, and the cryptography is modern. Pick OpenSSL only if
+you have a concrete reason: FIPS compliance, custom cipher requirements, or
+libsodium-isn't-available environments.
+
+See [03 — Sodium Handler](./03-sodium-handler.md) and
+[02 — OpenSSL Handler](./02-openssl-handler.md).
+
+## "Why hex output, not base64?"
+
+Three reasons:
+
+1. **URL-, cookie-, JSON-safe everywhere without escaping.** Base64 needs
+ `base64url` to be URL-safe; hex is URL-safe by default.
+2. **Easier to debug.** `02006f1c...` immediately tells you "this is a v2
+ ciphertext, JSON-serialized". A base64 string hides the format header.
+3. **Cheap to detect tampering at the boundary.** Any non-hex character is
+ rejected before any cryptographic work runs.
+
+The cost is a 2× size factor over the raw binary. If you store millions of
+ciphertexts and bytes matter, run `hex2bin()` before storage and `bin2hex()`
+again on read — the package's `encrypt()` / `decrypt()` only ever speak hex
+on the public surface.
+
+## "Can I store ciphertexts in URLs / cookies / query strings?"
+
+Yes — hex is URL-safe. For cookies, set the usual `Secure`, `HttpOnly`,
+`SameSite=Strict` flags. Ciphertext length is roughly `2 * (header + IV/nonce
++ HMAC + payload)`, so be mindful of the 4 KB cookie limit if you put a lot
+in there.
+
+## "Why JSON by default? My code uses `serialize`."
+
+Because `unserialize()` on attacker-controlled bytes is the canonical PHP
+object-injection vector, and "the bytes are HMAC-verified so the attacker
+can't control them" is true *only as long as your key never leaks*. JSON
+cannot instantiate classes — defense in depth.
+
+If your payloads genuinely need PHP's serialization (binary blobs, deeply
+custom classes), pass `'serializer' => 'php_serialize'` explicitly. The
+package always invokes `unserialize()` with `['allowed_classes' => false]`
+in that mode, so you still get a meaningful defense.
+
+See the table in [05 — Options Reference](./05-options.md#serializer).
+
+## "Can I disable the HMAC / authentication?"
+
+No. There is no "encrypt without authenticate" mode. Authenticated
+encryption is the only encryption a modern library should offer; the most
+common "I just want to hide bytes" use case has stronger guarantees with
+authentication on than off.
+
+If you have a legitimate need for unauthenticated encryption (e.g. you are
+streaming gigabytes and want to authenticate the whole stream at the end),
+this package is the wrong tool — use `openssl_encrypt()` directly.
+
+## "Does the package support streaming encryption?"
+
+Not in 2.x. Both handlers operate on the full payload in memory.
+
+For large blobs:
+
+- libsodium has `crypto_secretstream_xchacha20poly1305_*` which is the right
+ primitive for streaming AEAD. You can build a custom handler around it —
+ see [04 — Custom Handlers](./04-custom-handlers.md).
+- For raw OpenSSL streaming, `openssl_encrypt`/`openssl_decrypt` operate on
+ full buffers; you need to use `ext-openssl`'s stream filter machinery
+ directly, which is outside the package's scope.
+
+## "Two encrypts of the same plaintext return different ciphertexts. Is that a bug?"
+
+No, that's a feature. OpenSSL uses a fresh random IV per call; Sodium uses
+a fresh random nonce. If two ciphertexts of the same plaintext were
+identical, an attacker could deduce that the plaintexts match without ever
+decrypting either.
+
+If you need deterministic encryption for, say, a database index, the
+package is the wrong tool — that requires a separate construction
+(synthetic IV, FF1, etc.) with different security properties.
+
+## "Can two PHP processes encrypt with handler A and decrypt with handler B?"
+
+Two processes using the **same** handler class and the **same** options
+will interoperate. The Sodium key derivation and OpenSSL HKDF are both
+deterministic on the user key.
+
+You **cannot** decrypt an OpenSSL ciphertext with the Sodium handler or
+vice versa — the wire formats are different and (intentionally) the
+relevant integration test asserts this.
+
+## "How do I change the cipher / algo without breaking existing ciphertexts?"
+
+You can't, with the built-in handlers — the cipher and algorithm are not
+recorded in the wire format. Decryption uses whatever the handler is
+configured with at decrypt time.
+
+If you want self-describing cipher choice, build a custom handler that
+embeds a cipher ID in the payload (between the format header and the
+ciphertext bytes). See the "Versioning Your Own Format" section in
+[04 — Custom Handlers](./04-custom-handlers.md).
+
+## "Is `php_serialize` mode safe if I never set my own classes?"
+
+Effectively yes. `allowed_classes: false` means `unserialize()` returns
+`__PHP_Incomplete_Class` for any custom class rather than instantiating it
+— no constructor runs, no `__wakeup`/`__destruct` is triggered.
+
+That said, JSON is *categorically* safer because no class metadata is even
+parsed. Use JSON unless you have a concrete reason to use serialize.
+
+## "Why does `getOption('key')` still return the key after `sodium_memzero`?"
+
+The Sodium handler zeroes the **derived** key (the 32 bytes that go into
+`crypto_secretbox`) before returning, but the user key — the one you passed
+into the handler's options — lives in your `$options` array, which is
+your buffer to manage.
+
+If you want the user key wiped, manage that buffer yourself:
+
+```php
+$key = read_key_from_vault();
+$handler = new \InitPHP\Encryption\Sodium(['key' => $key]);
+// $handler now has the key inside its options array; that's the buffer
+// you'll need to clear if you want it gone. Either trust the GC or
+// hold a reference yourself for sodium_memzero().
+$ct = $handler->encrypt($payload);
+```
+
+## "Can I use this package to encrypt files?"
+
+For small files (anything that fits in PHP's `memory_limit` comfortably) —
+yes. `encrypt(file_get_contents(...))` / `file_put_contents(..., $ct)`
+works fine, but the entire file is processed as one buffer.
+
+For large files: see "Does the package support streaming encryption?"
+above.
+
+## "Why does the README say PHP 8.1, and the composer.json says `^8.1`?"
+
+`^8.1` means "8.1 or any later 8.x". When 9.0 comes out, this constraint
+prevents the package from auto-installing — that's intentional, since 9.0
+will probably have breaking language changes the package needs to verify
+against. A future 2.x release will widen the constraint.
+
+## "Where's the changelog?"
+
+[`CHANGELOG.md`](../CHANGELOG.md) at the repository root, in
+[Keep a Changelog](https://keepachangelog.com/) format.
+
+## "I have a question that isn't here."
+
+Open a [GitHub Discussion](https://github.com/orgs/InitPHP/discussions/categories/q-a)
+in the Q&A category. If the answer turns out to be a doc gap, the question
+ends up answered here in the next release.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..dddae81
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,55 @@
+# `initphp/encryption` — Developer Documentation
+
+This directory is the in-repo reference manual for the `initphp/encryption`
+package. The top-level [README](../README.md) is the front door; everything
+here is detail.
+
+## Contents
+
+| File | What it covers |
+| --- | --- |
+| [01 — Getting Started](./01-getting-started.md) | Installation, picking a handler, your first encrypt/decrypt, how to verify the install. |
+| [02 — OpenSSL Handler](./02-openssl-handler.md) | OpenSSL handler internals: cipher choice, hashing algorithm, encrypt-then-MAC layout, when to pick it. |
+| [03 — Sodium Handler](./03-sodium-handler.md) | Sodium handler internals: AEAD via secretbox, key derivation, padding, when to pick it. |
+| [04 — Custom Handlers](./04-custom-handlers.md) | Build your own handler on top of `BaseHandler` with a worked example. |
+| [05 — Options Reference](./05-options.md) | Every option, its type, default, valid values, precedence. |
+| [06 — Error Handling](./06-error-handling.md) | Every failure mode, what triggers it, what a caller should do. |
+| [07 — Security](./07-security.md) | Threat model, key management, what the package does and does not defend against. |
+| [08 — Migration 1.x → 2.x](./08-migration-v1-to-v2.md) | What changed in 2.0 and how to re-encrypt existing data. |
+| [09 — FAQ](./09-faq.md) | Common questions: hex vs base64, cookies/URLs, streaming, JSON vs serialize. |
+
+## Suggested Reading Order
+
+- **New to the package?** Read [01](./01-getting-started.md), then skim
+ [02](./02-openssl-handler.md) or [03](./03-sodium-handler.md) depending on
+ which handler you chose. Keep [05](./05-options.md) and
+ [06](./06-error-handling.md) bookmarked.
+- **Upgrading from 1.x?** Start with [08](./08-migration-v1-to-v2.md), then
+ the README's *Upgrading* section, then [01](./01-getting-started.md) to see
+ the new defaults in action.
+- **Need a non-standard primitive?** [04](./04-custom-handlers.md) walks you
+ through building a handler, and [07](./07-security.md) lists the contracts
+ you must honour.
+- **Reviewing the package for production use?** Read [07](./07-security.md)
+ end-to-end, then [02](./02-openssl-handler.md) and
+ [03](./03-sodium-handler.md) for the cryptographic constructions.
+
+## Conventions in These Docs
+
+- Every PHP code block is **verbatim runnable** against the current `src/`.
+ Copy a block into a file, `require 'vendor/autoload.php'`, and it will
+ produce the documented output.
+- "Ciphertext" always means the hex string returned by `encrypt()`. The
+ underlying bytes are described as "binary".
+- The 2-byte header on every ciphertext is referred to as the *format header*;
+ byte 0 is the *version byte* (always `0x02` in this release), byte 1 is the
+ *serializer flag* (`0x00` = JSON, `0x01` = `php_serialize`).
+- "User key" is whatever string you pass in the `key` option. "Derived key" is
+ what the handler actually feeds to the cryptographic primitive after key
+ derivation (HKDF for OpenSSL, BLAKE2b for Sodium).
+
+## Reporting Issues With the Docs
+
+Found a code sample that no longer works, an error message that doesn't match
+reality, or a typo? Open an issue or a PR — doc fixes are merged eagerly. See
+[CONTRIBUTING.md](../CONTRIBUTING.md).
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..9b360c7
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,8 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ - tests
+ tmpDir: .phpstan.cache
+ treatPhpDocTypesAsCertain: false
+ reportUnmatchedIgnoredErrors: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..31b5e0e
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,34 @@
+
+
+
+
+ tests/Unit
+
+
+ tests/Integration
+
+
+
+
+ src
+
+
+
diff --git a/src/BaseHandler.php b/src/BaseHandler.php
index cca8324..e847105 100644
--- a/src/BaseHandler.php
+++ b/src/BaseHandler.php
@@ -1,80 +1,231 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption;
-use const CASE_LOWER;
-
-use function array_merge;
-use function array_change_key_case;
-use function strtolower;
-use function mb_substr;
+use InitPHP\Encryption\Exceptions\EncryptionException;
+use JsonException;
+/**
+ * Shared option handling and payload (de)serialization for every concrete
+ * handler. Subclasses focus on the cryptographic primitive; this class owns
+ * the configuration surface and the boundary between PHP values and bytes.
+ *
+ * @phpstan-type EncryptionOptions array{
+ * algo?: string,
+ * cipher?: string,
+ * key?: string|null,
+ * blocksize?: int|string,
+ * serializer?: string,
+ * }
+ */
abstract class BaseHandler implements HandlerInterface
{
-
+ /**
+ * Magic byte placed at the start of every ciphertext produced by a 2.x
+ * handler. Decryption refuses any value with a different version byte,
+ * which is how this release rejects v1 ciphertexts.
+ */
+ public const FORMAT_VERSION = 0x02;
+
+ /**
+ * JSON serialization. Default. Safe against object-injection attacks
+ * because no PHP class is ever instantiated during decoding.
+ */
+ public const SERIALIZER_JSON = 'json';
+
+ /**
+ * PHP's native serialize()/unserialize() pair. Opt-in because
+ * unserialize() can instantiate arbitrary classes when allowed_classes
+ * is not constrained. This handler always passes
+ * ['allowed_classes' => false], so payloads round-trip stdClass
+ * (and scalars/arrays) but custom classes degrade to __PHP_Incomplete_Class.
+ */
+ public const SERIALIZER_PHP = 'php_serialize';
+
+ protected const SERIALIZER_FLAG_JSON = 0x00;
+ protected const SERIALIZER_FLAG_PHP = 0x01;
+
+ /**
+ * @var array
+ *
+ * @phpstan-var EncryptionOptions
+ */
protected array $options = [
- 'algo' => 'SHA256',
- 'cipher' => 'AES-256-CTR',
- 'key' => null,
+ 'algo' => 'SHA256',
+ 'cipher' => 'AES-256-CTR',
+ 'key' => null,
'blocksize' => 16,
+ 'serializer' => self::SERIALIZER_JSON,
];
- abstract public function encrypt($data, array $options = []): string;
-
- abstract public function decrypt($data, array $options = []);
-
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ */
public function __construct(array $options = [])
{
$this->setOptions($options);
}
- public function setOptions(array $options = []): self
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ */
+ public function setOptions(array $options = []): static
{
- if(empty($options)){
+ if ($options === []) {
return $this;
}
- $this->options = array_merge($this->options, array_change_key_case($options, CASE_LOWER));
+ /** @phpstan-var EncryptionOptions $merged */
+ $merged = array_merge($this->options, array_change_key_case($options, CASE_LOWER));
+ $this->options = $merged;
+
return $this;
}
- public function setOption(string $name, $value): self
+ public function setOption(string $name, mixed $value): static
{
$this->options[strtolower($name)] = $value;
+
return $this;
}
- public function getOption(string $name, $default = null)
+ public function getOption(string $name, mixed $default = null): mixed
{
- $name = strtolower($name);
- return $this->options[$name] ?? $default;
+ return $this->options[strtolower($name)] ?? $default;
}
+ /**
+ * @return array
+ *
+ * @phpstan-return EncryptionOptions
+ */
public function getOptions(): array
{
- return $this->options ?? [];
+ return $this->options;
+ }
+
+ /**
+ * Produce a fresh option array merged from the persistent options and a
+ * per-call override. The returned array is always a copy: subclasses may
+ * mutate or {@see sodium_memzero()} it without affecting handler state.
+ *
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @return array
+ *
+ * @phpstan-return EncryptionOptions
+ */
+ protected function resolveOptions(array $options = []): array
+ {
+ if ($options === []) {
+ return $this->options;
+ }
+
+ /** @phpstan-var EncryptionOptions $merged */
+ $merged = array_merge($this->options, array_change_key_case($options, CASE_LOWER));
+
+ return $merged;
+ }
+
+ /**
+ * Read and validate the 'key' option. Returns the key as a non-empty
+ * string or throws — handlers never have to second-guess key presence.
+ *
+ * @param array $options
+ *
+ * @throws EncryptionException
+ */
+ protected function requireKey(array $options): string
+ {
+ $key = $options['key'] ?? null;
+ if (!\is_string($key) || $key === '') {
+ throw new EncryptionException('The "key" option is required and must be a non-empty string.');
+ }
+
+ return $key;
+ }
+
+ /**
+ * Resolve the configured serializer name to its on-wire flag byte.
+ *
+ * @param array $options
+ *
+ * @throws EncryptionException
+ */
+ protected function serializerFlag(array $options): int
+ {
+ $name = strtolower((string) ($options['serializer'] ?? self::SERIALIZER_JSON));
+
+ return match ($name) {
+ self::SERIALIZER_JSON, 'json' => self::SERIALIZER_FLAG_JSON,
+ self::SERIALIZER_PHP, 'php', 'serialize' => self::SERIALIZER_FLAG_PHP,
+ default => throw new EncryptionException(\sprintf('Unknown "serializer" option: %s.', $name)),
+ };
}
- protected function options(array $options = []): array
+ /**
+ * Encode a payload according to the resolved serializer flag.
+ *
+ * @throws EncryptionException
+ */
+ protected function serializePayload(mixed $data, int $flag): string
{
- return empty($options) ? $this->options : array_merge($this->options, array_change_key_case($options, CASE_LOWER));
+ return match ($flag) {
+ self::SERIALIZER_FLAG_JSON => $this->jsonEncode($data),
+ self::SERIALIZER_FLAG_PHP => serialize($data),
+ default => throw new EncryptionException(\sprintf('Unknown serializer flag 0x%02x.', $flag)),
+ };
}
- protected function substr($str, int $offset, ?int $length = null): string
+ /**
+ * Decode a payload according to the serializer flag read from the
+ * ciphertext header. PHP-serialized payloads are decoded with
+ * allowed_classes:false to neutralise object-injection attempts.
+ *
+ * @throws EncryptionException
+ */
+ protected function unserializePayload(string $data, int $flag): mixed
{
- return mb_substr($str, $offset, $length, '8bit');
+ switch ($flag) {
+ case self::SERIALIZER_FLAG_JSON:
+ return $this->jsonDecode($data);
+ case self::SERIALIZER_FLAG_PHP:
+ $value = @unserialize($data, ['allowed_classes' => false]);
+ if ($value === false && $data !== serialize(false)) {
+ throw new EncryptionException('Failed to unserialize the decrypted payload.');
+ }
+
+ return $value;
+ default:
+ throw new EncryptionException(\sprintf('Unknown serializer flag 0x%02x.', $flag));
+ }
}
+ private function jsonEncode(mixed $data): string
+ {
+ try {
+ return json_encode(
+ $data,
+ JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+ );
+ } catch (JsonException $e) {
+ throw new EncryptionException('Failed to JSON-encode payload: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function jsonDecode(string $data): mixed
+ {
+ try {
+ return json_decode($data, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new EncryptionException('Failed to JSON-decode payload: ' . $e->getMessage(), 0, $e);
+ }
+ }
}
diff --git a/src/Encrypt.php b/src/Encrypt.php
index fe6ffef..b286e24 100644
--- a/src/Encrypt.php
+++ b/src/Encrypt.php
@@ -1,56 +1,58 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption;
-use \InitPHP\Encryption\Exceptions\EncryptionException;
+use InitPHP\Encryption\Exceptions\EncryptionException;
-use function is_string;
-use function class_exists;
-
-class Encrypt
+/**
+ * Convenience factory for handler instantiation.
+ *
+ * Accepts either a fully-qualified class name (which will be instantiated
+ * with the given options) or an already-constructed handler instance (whose
+ * options will be merged with the given overrides).
+ */
+final class Encrypt
{
-
/**
- * @param string|HandlerInterface $handler
- * @param array $options
- * @return HandlerInterface
- * @throws EncryptionException
+ * @template T of HandlerInterface
+ *
+ * @param class-string|T $handler Fully-qualified handler class name, or
+ * an already-constructed handler instance.
+ * @param array $options Options to apply on top of the handler's
+ * defaults. Keys are case-insensitive.
+ *
+ * @return T
+ *
+ * @throws EncryptionException If the class name does not exist, the resulting
+ * object does not implement {@see HandlerInterface},
+ * or the handler's own constructor rejects the options.
*/
- public static function use($handler, array $options = []): HandlerInterface
+ public static function use(string|HandlerInterface $handler, array $options = []): HandlerInterface
{
- if(is_string($handler) && class_exists($handler)){
- $handler = new $handler($options);
- $options = null;
+ if (\is_string($handler)) {
+ if (!class_exists($handler)) {
+ throw new EncryptionException(\sprintf('Handler class "%s" does not exist.', $handler));
+ }
+
+ $instance = new $handler($options);
+ if (!$instance instanceof HandlerInterface) {
+ throw new EncryptionException(\sprintf(
+ 'Handler class "%s" must implement %s.',
+ $handler,
+ HandlerInterface::class,
+ ));
+ }
+
+ /** @var T $instance */
+ return $instance;
}
- if(!($handler instanceof HandlerInterface)){
- throw new EncryptionException('');
+
+ if ($options !== []) {
+ $handler->setOptions($options);
}
- return empty($options) ? $handler : $handler->setOptions($options);
- }
- /**
- * @param string|HandlerInterface $handler
- * @param array $options
- * @return HandlerInterface
- * @throws EncryptionException
- */
- public static function create($handler, array $options = []): HandlerInterface
- {
- return self::use($handler, $options);
+ return $handler;
}
-
-
}
diff --git a/src/Exceptions/EncryptionException.php b/src/Exceptions/EncryptionException.php
index ae5f7d8..826c060 100644
--- a/src/Exceptions/EncryptionException.php
+++ b/src/Exceptions/EncryptionException.php
@@ -1,20 +1,19 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption\Exceptions;
-class EncryptionException extends \Exception
+use RuntimeException;
+
+/**
+ * Thrown by every failure mode of the package: invalid configuration, missing
+ * required options, tampered ciphertext, unsupported format version, missing
+ * extensions, or any error raised by the underlying cryptographic primitives.
+ *
+ * Catching this exception is sufficient to handle every error path the
+ * library can produce.
+ */
+class EncryptionException extends RuntimeException
{
}
diff --git a/src/HandlerInterface.php b/src/HandlerInterface.php
index 2d8d6f6..7f87a96 100644
--- a/src/HandlerInterface.php
+++ b/src/HandlerInterface.php
@@ -1,27 +1,51 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption;
+use InitPHP\Encryption\Exceptions\EncryptionException;
+
+/**
+ * Contract every encryption handler must implement.
+ *
+ * Handlers translate arbitrary PHP values into an opaque, hex-encoded
+ * ciphertext string and back. The encoding, key derivation and
+ * authentication strategy are entirely up to the implementation; callers
+ * only see the {@see encrypt()} / {@see decrypt()} pair.
+ */
interface HandlerInterface
{
+ /**
+ * Encrypt a value and return its ciphertext.
+ *
+ * @param mixed $data Any value the configured serializer can encode.
+ * @param array $options Per-call option overrides merged on top of the
+ * handler's persistent options.
+ *
+ * @throws EncryptionException When configuration is invalid or the underlying
+ * primitive fails to encrypt.
+ */
+ public function encrypt(mixed $data, array $options = []): string;
- public function encrypt($data, array $options = []): string;
-
- public function decrypt($data, array $options = []);
-
- public function setOptions(array $options = []): HandlerInterface;
+ /**
+ * Decrypt a ciphertext produced by the same handler version.
+ *
+ * @param string $data Ciphertext previously returned by {@see encrypt()}.
+ * @param array $options Per-call option overrides merged on top of the
+ * handler's persistent options.
+ *
+ * @throws EncryptionException When the ciphertext is malformed, the format version
+ * is unsupported, authentication fails, or deserialization fails.
+ */
+ public function decrypt(string $data, array $options = []): mixed;
+ /**
+ * Merge the given options on top of the current configuration.
+ *
+ * Option keys are case-insensitive.
+ *
+ * @param array $options
+ */
+ public function setOptions(array $options = []): static;
}
diff --git a/src/OpenSSL.php b/src/OpenSSL.php
index 7adafdf..2ce8467 100644
--- a/src/OpenSSL.php
+++ b/src/OpenSSL.php
@@ -1,81 +1,205 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption;
-use const OPENSSL_RAW_DATA;
-
-use function extension_loaded;
-use function serialize;
-use function unserialize;
-use function bin2hex;
-use function hex2bin;
-use function openssl_encrypt;
-use function openssl_decrypt;
-use function openssl_cipher_iv_length;
-use function openssl_random_pseudo_bytes;
-use function hash_hkdf;
-use function hash_hmac;
-use function hash_equals;
-
-class OpenSSL extends BaseHandler implements HandlerInterface
-{
+use InitPHP\Encryption\Exceptions\EncryptionException;
+/**
+ * Encrypts and decrypts arbitrary PHP values with OpenSSL plus an
+ * encrypt-then-MAC construction.
+ *
+ * Ciphertext layout (binary, then hex-encoded):
+ *
+ * [1B VERSION] [1B SERIALIZER] [N B HMAC] [M B IV] [... ciphertext ...]
+ *
+ * - VERSION is {@see BaseHandler::FORMAT_VERSION} (0x02).
+ * - SERIALIZER is the serializer flag chosen at encrypt time.
+ * - HMAC length equals strlen(hash_hmac($algo, '', '', true)) for the
+ * configured algorithm and authenticates VERSION || SERIALIZER || IV || ciphertext.
+ * - IV length equals openssl_cipher_iv_length($cipher); may be zero for
+ * stream ciphers that take no IV.
+ *
+ * @phpstan-import-type EncryptionOptions from BaseHandler
+ */
+final class OpenSSL extends BaseHandler
+{
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If the openssl extension is not loaded.
+ */
public function __construct(array $options = [])
{
- if(extension_loaded('openssl') === FALSE){
- throw new \InitPHP\Encryption\Exceptions\EncryptionException('The "openssl" extension must be installed.');
+ if (!\extension_loaded('openssl')) {
+ throw new EncryptionException('The "openssl" extension is required by the OpenSSL handler.');
}
parent::__construct($options);
}
- public function encrypt($data, array $options = []): string
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If configuration is invalid or OpenSSL fails to encrypt.
+ */
+ public function encrypt(mixed $data, array $options = []): string
{
- $options = $this->options($options);
+ $options = $this->resolveOptions($options);
+ $key = $this->requireKey($options);
+ $cipher = $this->resolveCipher($options);
+ $algo = $this->resolveAlgo($options);
+ $serializerFlag = $this->serializerFlag($options);
+
+ $payload = $this->serializePayload($data, $serializerFlag);
+ $secret = hash_hkdf($algo, $key);
+
+ $ivLength = $this->ivLength($cipher);
+ $iv = $ivLength > 0 ? random_bytes($ivLength) : '';
+
+ $ciphertext = openssl_encrypt($payload, $cipher, $secret, OPENSSL_RAW_DATA, $iv);
+ if ($ciphertext === false) {
+ throw new EncryptionException(
+ 'OpenSSL encryption failed: ' . (openssl_error_string() ?: 'unknown error'),
+ );
+ }
+
+ $header = \chr(self::FORMAT_VERSION) . \chr($serializerFlag);
+ $hmac = hash_hmac($algo, $header . $iv . $ciphertext, $secret, true);
+
+ return bin2hex($header . $hmac . $iv . $ciphertext);
+ }
+
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If the ciphertext is malformed, the format
+ * version is unsupported, HMAC verification fails,
+ * or OpenSSL fails to decrypt.
+ */
+ public function decrypt(string $data, array $options = []): mixed
+ {
+ $options = $this->resolveOptions($options);
+ $key = $this->requireKey($options);
+ $cipher = $this->resolveCipher($options);
+ $algo = $this->resolveAlgo($options);
+
+ $binary = @hex2bin($data);
+ if ($binary === false) {
+ throw new EncryptionException('Ciphertext is not valid hex-encoded data.');
+ }
- $secret = hash_hkdf($options['algo'], $options['key']);
- $iv = ($IVSize = openssl_cipher_iv_length($options['cipher'])) ? openssl_random_pseudo_bytes($IVSize) : null;
- $data = serialize($data);
+ if (\strlen($binary) < 2) {
+ throw new EncryptionException('Ciphertext is shorter than the 2-byte header.');
+ }
+
+ $version = \ord($binary[0]);
+ if ($version !== self::FORMAT_VERSION) {
+ throw new EncryptionException(\sprintf(
+ 'Unsupported ciphertext format version 0x%02x; expected 0x%02x. Ciphertexts produced by 1.x are not readable by 2.x.',
+ $version,
+ self::FORMAT_VERSION,
+ ));
+ }
+ $serializerFlag = \ord($binary[1]);
+
+ $secret = hash_hkdf($algo, $key);
+ $hmacLength = \strlen(hash_hmac($algo, '', '', true));
+ $ivLength = $this->ivLength($cipher);
+
+ $minLength = 2 + $hmacLength + $ivLength;
+ if (\strlen($binary) < $minLength) {
+ throw new EncryptionException(
+ 'Ciphertext is too short for the configured algorithm and cipher.',
+ );
+ }
- if(($data = openssl_encrypt($data, $options['cipher'], $secret, OPENSSL_RAW_DATA, $iv)) === FALSE){
- throw new \InitPHP\Encryption\Exceptions\EncryptionException('Encryption failed.');
+ $header = substr($binary, 0, 2);
+ $hmacGiven = substr($binary, 2, $hmacLength);
+ $iv = $ivLength > 0 ? substr($binary, 2 + $hmacLength, $ivLength) : '';
+ $ciphertext = substr($binary, 2 + $hmacLength + $ivLength);
+
+ $hmacExpected = hash_hmac($algo, $header . $iv . $ciphertext, $secret, true);
+ if (!hash_equals($hmacExpected, $hmacGiven)) {
+ throw new EncryptionException(
+ 'HMAC verification failed; ciphertext is corrupted or has been tampered with.',
+ );
+ }
+
+ $plain = openssl_decrypt($ciphertext, $cipher, $secret, OPENSSL_RAW_DATA, $iv);
+ if ($plain === false) {
+ throw new EncryptionException(
+ 'OpenSSL decryption failed: ' . (openssl_error_string() ?: 'unknown error'),
+ );
}
- $res = $iv . $data;
- $hmac = hash_hmac($options['algo'], $res, $secret, true);
- return bin2hex($hmac . $res);
+
+ return $this->unserializePayload($plain, $serializerFlag);
}
- public function decrypt($data, array $options = [])
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException
+ */
+ private function resolveCipher(array $options): string
{
- $options = $this->options($options);
- $data = hex2bin($data);
- $secret = hash_hkdf($options['algo'], $options['key']);
-
- $hmacLength = $this->substr($options['algo'], 3) / 8;
- $hmacKey = $this->substr($data, 0, $hmacLength);
- $data = $this->substr($data, $hmacLength);
- $hmacCalc = hash_hmac($options['algo'], $data, $secret, true);
- if(hash_equals($hmacKey, $hmacCalc) === FALSE){
- throw new \InitPHP\Encryption\Exceptions\EncryptionException('Decryption verification failed.');
+ $cipher = $options['cipher'] ?? '';
+ if (!\is_string($cipher) || $cipher === '') {
+ throw new EncryptionException('The "cipher" option is required and must be a non-empty string.');
}
- $iv = ($ivSize = openssl_cipher_iv_length($options['cipher'])) ? $this->substr($data, 0, $ivSize) : null;
- if($iv !== null){
- $data = $this->substr($data, $ivSize);
+
+ $cipherLower = strtolower($cipher);
+ foreach (openssl_get_cipher_methods() as $available) {
+ if (strtolower($available) === $cipherLower) {
+ return $cipher;
+ }
}
- $data = openssl_decrypt($data, $options['cipher'], $secret, OPENSSL_RAW_DATA, $iv);
- return unserialize($data);
+
+ throw new EncryptionException(\sprintf('Unknown OpenSSL cipher "%s".', $cipher));
}
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException
+ */
+ private function resolveAlgo(array $options): string
+ {
+ $algo = $options['algo'] ?? '';
+ if (!\is_string($algo) || $algo === '') {
+ throw new EncryptionException('The "algo" option is required and must be a non-empty string.');
+ }
+
+ $algoLower = strtolower($algo);
+ foreach (hash_hmac_algos() as $available) {
+ if (strtolower($available) === $algoLower) {
+ return $algo;
+ }
+ }
+
+ throw new EncryptionException(\sprintf('Unknown HMAC hashing algorithm "%s".', $algo));
+ }
+
+ /**
+ * @throws EncryptionException
+ */
+ private function ivLength(string $cipher): int
+ {
+ $length = openssl_cipher_iv_length($cipher);
+ if ($length === false) {
+ throw new EncryptionException(\sprintf('Unable to determine IV length for cipher "%s".', $cipher));
+ }
+
+ return $length;
+ }
}
diff --git a/src/Sodium.php b/src/Sodium.php
index d575a07..81b4819 100644
--- a/src/Sodium.php
+++ b/src/Sodium.php
@@ -1,92 +1,169 @@
- * @copyright Copyright © 2022 InitPHP
- * @license http://initphp.github.io/license.txt MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
declare(strict_types=1);
namespace InitPHP\Encryption;
-use \InitPHP\Encryption\Exceptions\EncryptionException;
-
-use const SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
-use const SODIUM_CRYPTO_SECRETBOX_MACBYTES;
-
-use function extension_loaded;
-use function bin2hex;
-use function hex2bin;
-use function serialize;
-use function unserialize;
-use function random_bytes;
-use function mb_strlen;
-use function sodium_pad;
-use function sodium_crypto_secretbox;
-use function sodium_memzero;
-use function sodium_crypto_secretbox_open;
-use function sodium_unpad;
-
-class Sodium extends BaseHandler implements HandlerInterface
-{
+use InitPHP\Encryption\Exceptions\EncryptionException;
+use SodiumException;
+/**
+ * Encrypts and decrypts arbitrary PHP values with libsodium's
+ * crypto_secretbox AEAD construction.
+ *
+ * Ciphertext layout (binary, then hex-encoded):
+ *
+ * [1B VERSION] [1B SERIALIZER] [24B NONCE] [... secretbox (MAC || ciphertext) ...]
+ *
+ * - VERSION is {@see BaseHandler::FORMAT_VERSION} (0x02).
+ * - SERIALIZER is the serializer flag chosen at encrypt time.
+ * - The user-supplied "key" option may be of any non-empty length; it is
+ * passed through {@see sodium_crypto_generichash()} to obtain a
+ * SODIUM_CRYPTO_SECRETBOX_KEYBYTES-byte derived key. The derived key never
+ * leaves this handler and is wiped from memory in a finally block.
+ *
+ * @phpstan-import-type EncryptionOptions from BaseHandler
+ */
+final class Sodium extends BaseHandler
+{
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If the sodium extension is not loaded.
+ */
public function __construct(array $options = [])
{
- if(extension_loaded('sodium') === FALSE){
- throw new \InitPHP\Encryption\Exceptions\EncryptionException('The "sodium" extension must be installed.');
+ if (!\extension_loaded('sodium')) {
+ throw new EncryptionException('The "sodium" extension is required by the Sodium handler.');
}
parent::__construct($options);
}
/**
- * @param $data
- * @param array $options
- * @return string
- * @throws \SodiumException
- * @throws \Exception
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If configuration is invalid or libsodium fails.
*/
- public function encrypt($data, array $options = []): string
+ public function encrypt(mixed $data, array $options = []): string
{
- $options = $this->options($options);
- $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
- $data = serialize($data);
- $data = sodium_pad($data, (int)$options['blocksize']);
- $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $options['key']);
- sodium_memzero($data);
- sodium_memzero($options['key']);
- return bin2hex($ciphertext);
+ $options = $this->resolveOptions($options);
+ $userKey = $this->requireKey($options);
+ $blocksize = $this->resolveBlocksize($options);
+ $serializerFlag = $this->serializerFlag($options);
+
+ $payload = $this->serializePayload($data, $serializerFlag);
+
+ $secret = $this->deriveKey($userKey);
+
+ try {
+ try {
+ $padded = sodium_pad($payload, $blocksize);
+ } catch (SodiumException $e) {
+ throw new EncryptionException('Sodium padding failed: ' . $e->getMessage(), 0, $e);
+ }
+
+ $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+ $box = sodium_crypto_secretbox($padded, $nonce, $secret);
+
+ $header = \chr(self::FORMAT_VERSION) . \chr($serializerFlag);
+
+ return bin2hex($header . $nonce . $box);
+ } finally {
+ sodium_memzero($secret);
+ }
}
/**
- * @param $data
- * @param array $options
- * @return mixed
- * @throws EncryptionException
- * @throws \SodiumException
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException If the ciphertext is malformed, the format
+ * version is unsupported, authentication fails,
+ * or unpadding/deserialization fails.
*/
- public function decrypt($data, array $options = [])
+ public function decrypt(string $data, array $options = []): mixed
{
- $options = $this->options($options);
- $data = hex2bin($data);
- if(mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)){
- throw new EncryptionException('Decryption failed!');
+ $options = $this->resolveOptions($options);
+ $userKey = $this->requireKey($options);
+ $blocksize = $this->resolveBlocksize($options);
+
+ $binary = @hex2bin($data);
+ if ($binary === false) {
+ throw new EncryptionException('Ciphertext is not valid hex-encoded data.');
+ }
+
+ $minLength = 2 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES;
+ if (\strlen($binary) < $minLength) {
+ throw new EncryptionException('Ciphertext is too short to contain a v2 Sodium payload.');
}
- $nonce = $this->substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
- $ciphertext = $this->substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
- if(($data = sodium_crypto_secretbox_open($ciphertext, $nonce, $options['key'])) === FALSE){
- throw new EncryptionException('Decryption failed!');
+ $version = \ord($binary[0]);
+ if ($version !== self::FORMAT_VERSION) {
+ throw new EncryptionException(\sprintf(
+ 'Unsupported ciphertext format version 0x%02x; expected 0x%02x. Ciphertexts produced by 1.x are not readable by 2.x.',
+ $version,
+ self::FORMAT_VERSION,
+ ));
}
- $data = sodium_unpad($data, $options['blocksize']);
- sodium_memzero($ciphertext);
- sodium_memzero($options['key']);
- return unserialize($data);
+ $serializerFlag = \ord($binary[1]);
+
+ $nonce = substr($binary, 2, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+ $box = substr($binary, 2 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+
+ $secret = $this->deriveKey($userKey);
+
+ try {
+ $padded = sodium_crypto_secretbox_open($box, $nonce, $secret);
+ if ($padded === false) {
+ throw new EncryptionException(
+ 'Sodium decryption failed; ciphertext is corrupted or has been tampered with.',
+ );
+ }
+
+ try {
+ $payload = sodium_unpad($padded, $blocksize);
+ } catch (SodiumException $e) {
+ throw new EncryptionException('Sodium unpadding failed: ' . $e->getMessage(), 0, $e);
+ }
+
+ return $this->unserializePayload($payload, $serializerFlag);
+ } finally {
+ sodium_memzero($secret);
+ }
+ }
+
+ /**
+ * Derive a {@see SODIUM_CRYPTO_SECRETBOX_KEYBYTES}-byte key from arbitrary
+ * user-supplied key material.
+ */
+ private function deriveKey(string $userKey): string
+ {
+ return sodium_crypto_generichash($userKey, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}
+ /**
+ * @param array $options
+ *
+ * @phpstan-param EncryptionOptions $options
+ *
+ * @throws EncryptionException
+ */
+ private function resolveBlocksize(array $options): int
+ {
+ $value = $options['blocksize'] ?? 16;
+ if (!\is_int($value) && !(\is_string($value) && ctype_digit($value))) {
+ throw new EncryptionException('The "blocksize" option must be a positive integer.');
+ }
+ $blocksize = (int) $value;
+ if ($blocksize < 1) {
+ throw new EncryptionException('The "blocksize" option must be a positive integer.');
+ }
+
+ return $blocksize;
+ }
}
diff --git a/tests/Fixtures/DummyHandler.php b/tests/Fixtures/DummyHandler.php
new file mode 100644
index 0000000..2a795e1
--- /dev/null
+++ b/tests/Fixtures/DummyHandler.php
@@ -0,0 +1,80 @@
+resolveOptions($options);
+ $flag = $this->serializerFlag($resolved);
+
+ return $this->serializePayload($data, $flag);
+ }
+
+ public function decrypt(string $data, array $options = []): mixed
+ {
+ $resolved = $this->resolveOptions($options);
+ $flag = $this->serializerFlag($resolved);
+
+ return $this->unserializePayload($data, $flag);
+ }
+
+ /**
+ * @param array $options
+ */
+ public function callRequireKey(array $options): string
+ {
+ return $this->requireKey($options);
+ }
+
+ /**
+ * @param array $options
+ */
+ public function callSerializerFlag(array $options): int
+ {
+ return $this->serializerFlag($options);
+ }
+
+ public function callSerializePayload(mixed $data, int $flag): string
+ {
+ return $this->serializePayload($data, $flag);
+ }
+
+ public function callUnserializePayload(string $data, int $flag): mixed
+ {
+ return $this->unserializePayload($data, $flag);
+ }
+
+ /**
+ * @param array $options
+ *
+ * @return array
+ */
+ public function callResolveOptions(array $options): array
+ {
+ return $this->resolveOptions($options);
+ }
+
+ public function jsonFlag(): int
+ {
+ return self::SERIALIZER_FLAG_JSON;
+ }
+
+ public function phpFlag(): int
+ {
+ return self::SERIALIZER_FLAG_PHP;
+ }
+}
diff --git a/tests/Fixtures/NotAHandler.php b/tests/Fixtures/NotAHandler.php
new file mode 100644
index 0000000..c13609a
--- /dev/null
+++ b/tests/Fixtures/NotAHandler.php
@@ -0,0 +1,20 @@
+ $options
+ */
+ public function __construct(public readonly array $options = [])
+ {
+ }
+}
diff --git a/tests/Integration/CrossHandlerTest.php b/tests/Integration/CrossHandlerTest.php
new file mode 100644
index 0000000..bce35bc
--- /dev/null
+++ b/tests/Integration/CrossHandlerTest.php
@@ -0,0 +1,45 @@
+ self::KEY]);
+ $openssl = new OpenSSL(['key' => self::KEY]);
+ $ciphertext = $sodium->encrypt(['payload' => 'cross']);
+
+ $this->expectException(EncryptionException::class);
+
+ $openssl->decrypt($ciphertext);
+ }
+
+ public function testSodiumCannotDecryptOpenSSLCiphertext(): void
+ {
+ $openssl = new OpenSSL(['key' => self::KEY]);
+ $sodium = new Sodium(['key' => self::KEY]);
+ $ciphertext = $openssl->encrypt(['payload' => 'cross']);
+
+ $this->expectException(EncryptionException::class);
+
+ $sodium->decrypt($ciphertext);
+ }
+}
diff --git a/tests/Integration/LegacyFormatRejectionTest.php b/tests/Integration/LegacyFormatRejectionTest.php
new file mode 100644
index 0000000..477b008
--- /dev/null
+++ b/tests/Integration/LegacyFormatRejectionTest.php
@@ -0,0 +1,72 @@
+ self::KEY]);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unsupported ciphertext format version');
+
+ $handler->decrypt($fake);
+ }
+
+ public function testSodiumRejectsV1ShapedCiphertext(): void
+ {
+ $fake = bin2hex(\chr(0x01) . random_bytes(64));
+
+ $handler = new Sodium(['key' => self::KEY]);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unsupported ciphertext format version');
+
+ $handler->decrypt($fake);
+ }
+
+ public function testErrorMessageGuidesUsersTowardsV2(): void
+ {
+ $fake = bin2hex(\chr(0x01) . random_bytes(64));
+ $handler = new OpenSSL(['key' => self::KEY]);
+
+ try {
+ $handler->decrypt($fake);
+ self::fail('expected EncryptionException for v1 input');
+ } catch (EncryptionException $e) {
+ $message = $e->getMessage();
+
+ self::assertStringContainsString(\sprintf('0x%02x', BaseHandler::FORMAT_VERSION), $message);
+ self::assertStringContainsString('1.x', $message);
+ }
+ }
+}
diff --git a/tests/Unit/BaseHandlerTest.php b/tests/Unit/BaseHandlerTest.php
new file mode 100644
index 0000000..61484d6
--- /dev/null
+++ b/tests/Unit/BaseHandlerTest.php
@@ -0,0 +1,279 @@
+ 'AES-128-CTR']);
+
+ self::assertSame('AES-128-CTR', $handler->getOption('cipher'));
+ self::assertSame('SHA256', $handler->getOption('algo'), 'default kept for keys not overridden');
+ }
+
+ public function testSetOptionsReturnsSameInstanceForFluentChaining(): void
+ {
+ $handler = new DummyHandler();
+ $result = $handler->setOptions(['key' => 'k']);
+
+ self::assertSame($handler, $result);
+ }
+
+ public function testSetOptionsReturnsStaticInstanceShortCircuitsOnEmpty(): void
+ {
+ $handler = new DummyHandler(['key' => 'k']);
+ $before = $handler->getOptions();
+
+ $handler->setOptions([]);
+
+ self::assertSame($before, $handler->getOptions());
+ }
+
+ public function testSetOptionLowercasesKey(): void
+ {
+ $handler = new DummyHandler();
+ $handler->setOption('CIPHER', 'AES-128-CBC');
+
+ self::assertSame('AES-128-CBC', $handler->getOption('cipher'));
+ self::assertSame('AES-128-CBC', $handler->getOption('CiPhEr'));
+ }
+
+ public function testSetOptionsIsCaseInsensitive(): void
+ {
+ $handler = new DummyHandler();
+ $handler->setOptions(['CIPHER' => 'AES-128-CBC', 'Algo' => 'sha512']);
+
+ self::assertSame('AES-128-CBC', $handler->getOption('cipher'));
+ self::assertSame('sha512', $handler->getOption('algo'));
+ }
+
+ public function testGetOptionReturnsProvidedDefaultForMissingKey(): void
+ {
+ $handler = new DummyHandler();
+
+ self::assertSame('fallback', $handler->getOption('nope', 'fallback'));
+ }
+
+ public function testGetOptionsExposesDefaults(): void
+ {
+ $handler = new DummyHandler();
+ $options = $handler->getOptions();
+
+ self::assertArrayHasKey('algo', $options);
+ self::assertArrayHasKey('cipher', $options);
+ self::assertArrayHasKey('key', $options);
+ self::assertArrayHasKey('blocksize', $options);
+ self::assertArrayHasKey('serializer', $options);
+ self::assertSame(BaseHandler::SERIALIZER_JSON, $handler->getOption('serializer'));
+ }
+
+ public function testResolveOptionsDoesNotMutateHandlerState(): void
+ {
+ $handler = new DummyHandler(['key' => 'persistent']);
+ $resolved = $handler->callResolveOptions(['key' => 'override', 'cipher' => 'AES-128-CTR']);
+
+ self::assertSame('override', $resolved['key']);
+ self::assertSame('AES-128-CTR', $resolved['cipher']);
+ self::assertSame('persistent', $handler->getOption('key'), 'handler state must be preserved');
+ }
+
+ public function testRequireKeyThrowsWhenKeyIsMissing(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('"key"');
+
+ $handler->callRequireKey($handler->getOptions());
+ }
+
+ public function testRequireKeyThrowsWhenKeyIsEmptyString(): void
+ {
+ $handler = new DummyHandler(['key' => '']);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('"key"');
+
+ $handler->callRequireKey($handler->getOptions());
+ }
+
+ public function testRequireKeyThrowsWhenKeyIsNotString(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+
+ // Bypass the property type-juggling by setting via setOption (which casts the name only).
+ $handler->callRequireKey(['key' => 12345]);
+ }
+
+ public function testRequireKeyReturnsNonEmptyString(): void
+ {
+ $handler = new DummyHandler(['key' => 'top-secret']);
+
+ self::assertSame('top-secret', $handler->callRequireKey($handler->getOptions()));
+ }
+
+ /**
+ * @return array
+ */
+ public static function serializerAliasProvider(): array
+ {
+ return [
+ 'json (canonical)' => [BaseHandler::SERIALIZER_JSON, 0x00],
+ 'json (uppercase)' => ['JSON', 0x00],
+ 'php_serialize (canonical)' => [BaseHandler::SERIALIZER_PHP, 0x01],
+ 'php (alias)' => ['php', 0x01],
+ 'serialize (alias)' => ['serialize', 0x01],
+ 'SERIALIZE (mixed case)' => ['SERIALIZE', 0x01],
+ ];
+ }
+
+ #[DataProvider('serializerAliasProvider')]
+ public function testSerializerFlagResolvesNamesAndAliases(string $name, int $expectedFlag): void
+ {
+ $handler = new DummyHandler();
+
+ self::assertSame($expectedFlag, $handler->callSerializerFlag(['serializer' => $name]));
+ }
+
+ public function testSerializerFlagDefaultsToJson(): void
+ {
+ $handler = new DummyHandler();
+
+ self::assertSame($handler->jsonFlag(), $handler->callSerializerFlag([]));
+ }
+
+ public function testSerializerFlagThrowsOnUnknownName(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unknown "serializer" option');
+
+ $handler->callSerializerFlag(['serializer' => 'msgpack']);
+ }
+
+ public function testSerializePayloadJson(): void
+ {
+ $handler = new DummyHandler();
+ $encoded = $handler->callSerializePayload(['a' => 1], $handler->jsonFlag());
+
+ self::assertSame('{"a":1}', $encoded);
+ }
+
+ public function testSerializePayloadJsonRespectsUnicode(): void
+ {
+ $handler = new DummyHandler();
+ $encoded = $handler->callSerializePayload(['msg' => 'çığ'], $handler->jsonFlag());
+
+ self::assertSame('{"msg":"çığ"}', $encoded);
+ }
+
+ public function testSerializePayloadJsonThrowsOnUnsupportedValue(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('JSON-encode');
+
+ // A non-UTF-8 byte sequence cannot be JSON-encoded.
+ $handler->callSerializePayload("\xB1\xC2\xD3", $handler->jsonFlag());
+ }
+
+ public function testSerializePayloadPhpHandlesObjects(): void
+ {
+ $handler = new DummyHandler();
+ $object = new stdClass();
+ $object->name = 'phpunit';
+
+ $encoded = $handler->callSerializePayload($object, $handler->phpFlag());
+
+ self::assertSame(serialize($object), $encoded);
+ }
+
+ public function testSerializePayloadThrowsOnUnknownFlag(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unknown serializer flag');
+
+ $handler->callSerializePayload('x', 0xFF);
+ }
+
+ public function testUnserializePayloadJson(): void
+ {
+ $handler = new DummyHandler();
+
+ self::assertSame(['a' => 1], $handler->callUnserializePayload('{"a":1}', $handler->jsonFlag()));
+ }
+
+ public function testUnserializePayloadJsonThrowsOnInvalidJson(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('JSON-decode');
+
+ $handler->callUnserializePayload('not-json', $handler->jsonFlag());
+ }
+
+ public function testUnserializePayloadPhpReturnsScalarsAndArrays(): void
+ {
+ $handler = new DummyHandler();
+ $payload = serialize(['x' => 1, 'y' => [2, 3]]);
+
+ self::assertSame(['x' => 1, 'y' => [2, 3]], $handler->callUnserializePayload($payload, $handler->phpFlag()));
+ }
+
+ public function testUnserializePayloadPhpReturnsFalseLiteral(): void
+ {
+ $handler = new DummyHandler();
+ $payload = serialize(false);
+
+ self::assertFalse($handler->callUnserializePayload($payload, $handler->phpFlag()));
+ }
+
+ public function testUnserializePayloadPhpRefusesCustomObjectsViaAllowedClassesFalse(): void
+ {
+ $handler = new DummyHandler();
+ $object = new stdClass();
+ $object->level = 42;
+ $payload = serialize($object);
+
+ $decoded = $handler->callUnserializePayload($payload, $handler->phpFlag());
+
+ self::assertInstanceOf('__PHP_Incomplete_Class', $decoded);
+ }
+
+ public function testUnserializePayloadPhpThrowsOnGarbledInput(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('unserialize');
+
+ $handler->callUnserializePayload('not-a-serialized-value', $handler->phpFlag());
+ }
+
+ public function testUnserializePayloadThrowsOnUnknownFlag(): void
+ {
+ $handler = new DummyHandler();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unknown serializer flag');
+
+ $handler->callUnserializePayload('x', 0xFF);
+ }
+}
diff --git a/tests/Unit/EncryptTest.php b/tests/Unit/EncryptTest.php
new file mode 100644
index 0000000..767b1ed
--- /dev/null
+++ b/tests/Unit/EncryptTest.php
@@ -0,0 +1,81 @@
+ 'secret']);
+
+ self::assertInstanceOf(OpenSSL::class, $handler);
+ self::assertSame('secret', $handler->getOption('key'));
+ }
+
+ public function testUseReturnsSameInstanceWhenPassedAHandler(): void
+ {
+ $original = new DummyHandler(['key' => 'persistent']);
+ $returned = Encrypt::use($original);
+
+ self::assertSame($original, $returned);
+ }
+
+ public function testUseMergesOptionsIntoExistingInstance(): void
+ {
+ $original = new DummyHandler(['key' => 'persistent']);
+ $returned = Encrypt::use($original, ['cipher' => 'AES-128-CBC']);
+
+ self::assertSame($original, $returned);
+ self::assertSame('AES-128-CBC', $returned->getOption('cipher'));
+ self::assertSame('persistent', $returned->getOption('key'));
+ }
+
+ public function testUseDoesNotCallSetOptionsWhenOptionsArrayIsEmpty(): void
+ {
+ $original = new DummyHandler(['key' => 'persistent']);
+ $snapshot = $original->getOptions();
+ $returned = Encrypt::use($original, []);
+
+ self::assertSame($snapshot, $returned->getOptions());
+ }
+
+ public function testUseThrowsOnMissingClassName(): void
+ {
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('does not exist');
+
+ /** @phpstan-var class-string $missing */
+ $missing = 'No\\Such\\Class\\Anywhere';
+ Encrypt::use($missing);
+ }
+
+ public function testUseThrowsWhenClassDoesNotImplementHandlerInterface(): void
+ {
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('must implement');
+ $this->expectExceptionMessage(HandlerInterface::class);
+
+ /** @phpstan-var class-string $notHandler */
+ $notHandler = NotAHandler::class;
+ Encrypt::use($notHandler);
+ }
+
+ public function testUsePreservesGenericReturnTypeForExistingInstance(): void
+ {
+ $handler = new DummyHandler(['key' => 'k']);
+ $returned = Encrypt::use($handler);
+
+ // Confirmed at runtime: the same concrete subtype is returned.
+ self::assertInstanceOf(DummyHandler::class, $returned);
+ }
+}
diff --git a/tests/Unit/EncryptionExceptionTest.php b/tests/Unit/EncryptionExceptionTest.php
new file mode 100644
index 0000000..de6392e
--- /dev/null
+++ b/tests/Unit/EncryptionExceptionTest.php
@@ -0,0 +1,44 @@
+expectException(RuntimeException::class);
+ $this->expectExceptionMessage('boom');
+
+ throw new EncryptionException('boom');
+ }
+
+ public function testIsCatchableAsBaseException(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('boom');
+
+ throw new EncryptionException('boom');
+ }
+
+ public function testCarriesPreviousException(): void
+ {
+ $previous = new RuntimeException('root cause');
+ $exception = new EncryptionException('wrapper', 0, $previous);
+
+ self::assertSame($previous, $exception->getPrevious());
+ }
+}
diff --git a/tests/Unit/OpenSSLTest.php b/tests/Unit/OpenSSLTest.php
new file mode 100644
index 0000000..75fb031
--- /dev/null
+++ b/tests/Unit/OpenSSLTest.php
@@ -0,0 +1,233 @@
+ $options
+ */
+ private function handler(array $options = []): OpenSSL
+ {
+ return new OpenSSL(['key' => self::KEY] + $options);
+ }
+
+ /**
+ * @return array
+ */
+ public static function jsonRoundTripProvider(): array
+ {
+ return [
+ 'string' => ['hello, world'],
+ 'utf8' => ['çığör — λ — 漢字'],
+ 'empty string' => [''],
+ 'int' => [42],
+ 'negative int' => [-1],
+ 'float' => [3.5],
+ 'true' => [true],
+ 'false' => [false],
+ 'null' => [null],
+ 'flat array' => [['a', 'b', 'c']],
+ 'nested assoc' => [['x' => 1, 'y' => ['z' => [1, 2]]]],
+ ];
+ }
+
+ #[DataProvider('jsonRoundTripProvider')]
+ public function testRoundTripUnderJsonSerializer(mixed $value): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_JSON]);
+ $ciphertext = $handler->encrypt($value);
+
+ self::assertSame($value, $handler->decrypt($ciphertext));
+ }
+
+ public function testRoundTripUnderPhpSerializer(): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_PHP]);
+ // php_serialize is 8-bit clean, so binary survives.
+ $value = "\x00\x01\x02\xff raw bytes";
+
+ self::assertSame($value, $handler->decrypt($handler->encrypt($value)));
+ }
+
+ public function testCiphertextDiffersAcrossCalls(): void
+ {
+ $handler = $this->handler();
+ $a = $handler->encrypt('same plaintext');
+ $b = $handler->encrypt('same plaintext');
+
+ self::assertNotSame($a, $b, 'random IV should produce different ciphertexts');
+ }
+
+ public function testCiphertextIsHexEncoded(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $ciphertext);
+ }
+
+ public function testCiphertextStartsWithFormatVersionByte(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+ $firstByte = hexdec(substr($ciphertext, 0, 2));
+
+ self::assertSame(BaseHandler::FORMAT_VERSION, $firstByte);
+ }
+
+ public function testPerCallOptionsDoNotMutateHandlerState(): void
+ {
+ $handler = $this->handler(['cipher' => 'AES-256-CTR']);
+ $handler->encrypt('x', ['cipher' => 'AES-128-CTR']);
+
+ self::assertSame('AES-256-CTR', $handler->getOption('cipher'));
+ }
+
+ public function testDecryptRejectsTamperedCiphertext(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ // Flip the last byte to corrupt the ciphertext.
+ $lastBytePos = \strlen($ciphertext) - 2;
+ $original = substr($ciphertext, $lastBytePos, 2);
+ $flipped = \sprintf('%02x', hexdec($original) ^ 0xff);
+ $tampered = substr_replace($ciphertext, $flipped, $lastBytePos, 2);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('HMAC verification failed');
+
+ $handler->decrypt($tampered);
+ }
+
+ public function testDecryptRejectsNonHex(): void
+ {
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('hex-encoded');
+
+ $this->handler()->decrypt('not-hex-at-all-zzz');
+ }
+
+ public function testDecryptRejectsTruncated(): void
+ {
+ $this->expectException(EncryptionException::class);
+
+ $this->handler()->decrypt('ab');
+ }
+
+ public function testDecryptRejectsWrongVersionByte(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ // Replace the first byte (version) with 0x01 (v1).
+ $forged = '01' . substr($ciphertext, 2);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unsupported ciphertext format version');
+
+ $handler->decrypt($forged);
+ }
+
+ public function testDecryptRejectsTooShortForCipher(): void
+ {
+ $handler = $this->handler();
+ // Valid header (2 bytes) but no HMAC/IV/ciphertext at all.
+ $tooShort = bin2hex(\chr(BaseHandler::FORMAT_VERSION) . \chr(0x00));
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('too short');
+
+ $handler->decrypt($tooShort);
+ }
+
+ public function testEncryptRejectsMissingKey(): void
+ {
+ $handler = new OpenSSL();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('"key"');
+
+ $handler->encrypt('hello');
+ }
+
+ public function testEncryptRejectsEmptyKey(): void
+ {
+ $handler = new OpenSSL(['key' => '']);
+
+ $this->expectException(EncryptionException::class);
+
+ $handler->encrypt('hello');
+ }
+
+ public function testEncryptRejectsUnknownCipher(): void
+ {
+ $handler = $this->handler(['cipher' => 'no-such-cipher-xyz']);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unknown OpenSSL cipher');
+
+ $handler->encrypt('hello');
+ }
+
+ public function testEncryptRejectsUnknownAlgo(): void
+ {
+ $handler = $this->handler(['algo' => 'no-such-algo']);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unknown HMAC hashing algorithm');
+
+ $handler->encrypt('hello');
+ }
+
+ public function testJsonIsTheDefaultSerializer(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+ $secondByte = hexdec(substr($ciphertext, 2, 2));
+
+ self::assertSame(0x00, $secondByte, 'second byte is the serializer flag, 0x00 = JSON');
+ }
+
+ public function testPhpSerializerIsOptIn(): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_PHP]);
+ $ciphertext = $handler->encrypt('hello');
+ $secondByte = hexdec(substr($ciphertext, 2, 2));
+
+ self::assertSame(0x01, $secondByte);
+ }
+
+ public function testDifferentAlgorithmsRoundTripCleanly(): void
+ {
+ $handler = $this->handler(['algo' => 'sha512', 'cipher' => 'AES-256-CBC']);
+
+ self::assertSame(['hello'], $handler->decrypt($handler->encrypt(['hello'])));
+ }
+
+ public function testWrongKeyCannotDecrypt(): void
+ {
+ $encrypt = $this->handler();
+ $ciphertext = $encrypt->encrypt('hello');
+ $decrypt = new OpenSSL(['key' => 'a-different-secret']);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('HMAC verification failed');
+
+ $decrypt->decrypt($ciphertext);
+ }
+}
diff --git a/tests/Unit/SodiumTest.php b/tests/Unit/SodiumTest.php
new file mode 100644
index 0000000..a7f66b9
--- /dev/null
+++ b/tests/Unit/SodiumTest.php
@@ -0,0 +1,248 @@
+ $options
+ */
+ private function handler(array $options = []): Sodium
+ {
+ return new Sodium(['key' => self::KEY] + $options);
+ }
+
+ /**
+ * @return array
+ */
+ public static function jsonRoundTripProvider(): array
+ {
+ return [
+ 'string' => ['hello'],
+ 'utf8' => ['çığör — λ — 漢字'],
+ 'empty string' => [''],
+ 'int' => [42],
+ 'float' => [3.5],
+ 'true' => [true],
+ 'false' => [false],
+ 'null' => [null],
+ 'flat array' => [['a', 'b', 'c']],
+ 'nested assoc' => [['x' => 1, 'y' => ['z' => [1, 2]]]],
+ ];
+ }
+
+ #[DataProvider('jsonRoundTripProvider')]
+ public function testRoundTripUnderJsonSerializer(mixed $value): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_JSON]);
+
+ self::assertSame($value, $handler->decrypt($handler->encrypt($value)));
+ }
+
+ public function testRoundTripUnderPhpSerializerCarriesBinaryPayloads(): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_PHP]);
+ $value = "\x00\x01\x02\xff raw bytes";
+
+ self::assertSame($value, $handler->decrypt($handler->encrypt($value)));
+ }
+
+ public function testCiphertextDiffersAcrossCalls(): void
+ {
+ $handler = $this->handler();
+ $a = $handler->encrypt('same plaintext');
+ $b = $handler->encrypt('same plaintext');
+
+ self::assertNotSame($a, $b, 'random nonce should produce different ciphertexts');
+ }
+
+ public function testCiphertextIsHexEncoded(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $ciphertext);
+ }
+
+ public function testCiphertextStartsWithFormatVersionByte(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ self::assertSame(BaseHandler::FORMAT_VERSION, hexdec(substr($ciphertext, 0, 2)));
+ }
+
+ public function testKeyDerivationAcceptsArbitraryUserKeyLength(): void
+ {
+ // A 14-byte key (which is shorter than SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
+ // 1.x silently failed here, 2.x must derive a valid 32-byte key.
+ $shortHandler = new Sodium(['key' => 'short-key-1234']);
+ $longHandler = new Sodium(['key' => str_repeat('long-key-material-', 8)]);
+
+ self::assertSame('hello', $shortHandler->decrypt($shortHandler->encrypt('hello')));
+ self::assertSame('hello', $longHandler->decrypt($longHandler->encrypt('hello')));
+ }
+
+ public function testKeyDerivationIsDeterministicForSameUserKey(): void
+ {
+ $a = new Sodium(['key' => 'same-user-key']);
+ $b = new Sodium(['key' => 'same-user-key']);
+ $ciphertext = $a->encrypt('shared secret');
+
+ self::assertSame('shared secret', $b->decrypt($ciphertext));
+ }
+
+ public function testDecryptRejectsTamperedCiphertext(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ // Flip the last byte.
+ $lastBytePos = \strlen($ciphertext) - 2;
+ $original = substr($ciphertext, $lastBytePos, 2);
+ $flipped = \sprintf('%02x', hexdec($original) ^ 0xff);
+ $tampered = substr_replace($ciphertext, $flipped, $lastBytePos, 2);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Sodium decryption failed');
+
+ $handler->decrypt($tampered);
+ }
+
+ public function testDecryptRejectsNonHex(): void
+ {
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('hex-encoded');
+
+ $this->handler()->decrypt('not-hex-at-all-zzz');
+ }
+
+ public function testDecryptRejectsTruncated(): void
+ {
+ $this->expectException(EncryptionException::class);
+
+ $this->handler()->decrypt(bin2hex(str_repeat("\x00", 10)));
+ }
+
+ public function testDecryptRejectsWrongVersionByte(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+ $forged = '01' . substr($ciphertext, 2);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Unsupported ciphertext format version');
+
+ $handler->decrypt($forged);
+ }
+
+ public function testWrongKeyCannotDecrypt(): void
+ {
+ $encrypt = $this->handler();
+ $ciphertext = $encrypt->encrypt('hello');
+ $decrypt = new Sodium(['key' => 'a-different-secret']);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('Sodium decryption failed');
+
+ $decrypt->decrypt($ciphertext);
+ }
+
+ public function testEncryptRejectsMissingKey(): void
+ {
+ $handler = new Sodium();
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('"key"');
+
+ $handler->encrypt('hello');
+ }
+
+ public function testEncryptRejectsEmptyKey(): void
+ {
+ $handler = new Sodium(['key' => '']);
+
+ $this->expectException(EncryptionException::class);
+
+ $handler->encrypt('hello');
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidBlocksizeProvider(): array
+ {
+ return [
+ 'zero' => [0],
+ 'negative' => [-1],
+ 'float' => [16.5],
+ 'array' => [[16]],
+ 'non-numeric string' => ['sixteen'],
+ ];
+ }
+
+ #[DataProvider('invalidBlocksizeProvider')]
+ public function testEncryptRejectsInvalidBlocksize(mixed $blocksize): void
+ {
+ $handler = $this->handler(['blocksize' => $blocksize]);
+
+ $this->expectException(EncryptionException::class);
+ $this->expectExceptionMessage('blocksize');
+
+ $handler->encrypt('hello');
+ }
+
+ public function testBlocksizeAcceptsStringDigits(): void
+ {
+ $handler = $this->handler(['blocksize' => '32']);
+
+ self::assertSame('hello', $handler->decrypt($handler->encrypt('hello')));
+ }
+
+ public function testExplicitNullBlocksizeFallsBackToDefault(): void
+ {
+ // null is treated as "use default" via the ?? operator. This is a
+ // deliberate PHP idiom; pass a real invalid value (0, -1, ...) if
+ // you want to surface a configuration error.
+ $handler = $this->handler(['blocksize' => null]);
+
+ self::assertSame('hello', $handler->decrypt($handler->encrypt('hello')));
+ }
+
+ public function testPerCallOptionsDoNotMutateHandlerState(): void
+ {
+ $handler = $this->handler(['blocksize' => 16]);
+ $handler->encrypt('x', ['blocksize' => 64]);
+
+ self::assertSame(16, $handler->getOption('blocksize'));
+ }
+
+ public function testJsonIsTheDefaultSerializer(): void
+ {
+ $handler = $this->handler();
+ $ciphertext = $handler->encrypt('hello');
+
+ self::assertSame(0x00, hexdec(substr($ciphertext, 2, 2)));
+ }
+
+ public function testPhpSerializerIsOptIn(): void
+ {
+ $handler = $this->handler(['serializer' => BaseHandler::SERIALIZER_PHP]);
+ $ciphertext = $handler->encrypt('hello');
+
+ self::assertSame(0x01, hexdec(substr($ciphertext, 2, 2)));
+ }
+}