This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
name: PHP Composer
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate composer.json and composer.lock
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Cache Composer packages
|
||||||
|
id: composer-cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: vendor
|
||||||
|
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-php-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress
|
||||||
|
|
||||||
|
- name: PHPStan
|
||||||
|
run: composer phpstan
|
||||||
|
|
||||||
|
- name: Code style check
|
||||||
|
run: composer cs-check
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: composer test
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/vendor/
|
||||||
|
/.php-cs-fixer.cache
|
||||||
|
/.phpunit.result.cache
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
|
||||||
|
$finder = Finder::create()
|
||||||
|
->in([
|
||||||
|
__DIR__ . '/src',
|
||||||
|
__DIR__ . '/tests',
|
||||||
|
])
|
||||||
|
->name('*.php')
|
||||||
|
->notName('*.blade.php')
|
||||||
|
->ignoreDotFiles(true)
|
||||||
|
->ignoreVCS(true);
|
||||||
|
|
||||||
|
return (new Config())
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setRules([
|
||||||
|
'@PER-CS2.0' => true,
|
||||||
|
'@PHP83Migration' => true,
|
||||||
|
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
|
||||||
|
'single_quote' => true,
|
||||||
|
'explicit_string_variable' => true,
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'trim_array_spaces' => true,
|
||||||
|
'no_whitespace_before_comma_in_array' => true,
|
||||||
|
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
|
||||||
|
'declare_strict_types' => true,
|
||||||
|
'strict_param' => true,
|
||||||
|
'strict_comparison' => true,
|
||||||
|
'phpdoc_align' => ['align' => 'left'],
|
||||||
|
'phpdoc_order' => true,
|
||||||
|
'phpdoc_trim' => true,
|
||||||
|
'phpdoc_scalar' => true,
|
||||||
|
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
|
||||||
|
'yoda_style' => false,
|
||||||
|
'no_useless_else' => true,
|
||||||
|
'no_useless_return' => true,
|
||||||
|
'concat_space' => ['spacing' => 'one'],
|
||||||
|
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
|
||||||
|
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
|
||||||
|
])
|
||||||
|
->setFinder($finder);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM php:8.3-cli-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache git unzip openssl
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
RUN mkdir -p /tmp/composer && chown 1000:1000 /tmp/composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
PHP = docker compose run --rm php
|
||||||
|
|
||||||
|
.PHONY: install test phpstan cs-fix cs-check audit shell build
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(PHP) composer install
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
|
||||||
|
|
||||||
|
phpstan:
|
||||||
|
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
|
||||||
|
|
||||||
|
cs-fix:
|
||||||
|
$(PHP) vendor/bin/php-cs-fixer fix --ansi
|
||||||
|
|
||||||
|
cs-check:
|
||||||
|
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
|
||||||
|
|
||||||
|
audit:
|
||||||
|
$(PHP) composer audit
|
||||||
|
|
||||||
|
ci: audit phpstan cs-check test
|
||||||
|
|
||||||
|
shell:
|
||||||
|
docker compose run --rm php sh
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# tez-utils-crypto
|
||||||
|
|
||||||
|
Authenticated AES-GCM encryption for PHP — supports AES-128/192/256-GCM with random nonces, tag verification, and a strict enum-based cipher allowlist. PHP 8.3+, requires `ext-openssl`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require tez/utils-crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `AesEncryptionService` | Encrypts and decrypts strings using authenticated AES-GCM |
|
||||||
|
| `AesCipher` | Enum allowlist of supported cipher variants with their parameters |
|
||||||
|
|
||||||
|
## Encrypted format
|
||||||
|
|
||||||
|
```
|
||||||
|
base64( nonce[12 bytes] || ciphertext || tag[16 bytes] )
|
||||||
|
```
|
||||||
|
|
||||||
|
A fresh random nonce is generated on every `encrypt()` call — identical plaintexts produce different ciphertexts. The GCM authentication tag is verified on `decrypt()`, so tampered data is always rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Crypto\AesEncryptionService;
|
||||||
|
use Tez\Utils\Crypto\AesCipher;
|
||||||
|
|
||||||
|
// Generate a 64-character hex key (32 bytes = AES-256)
|
||||||
|
$hexKey = bin2hex(random_bytes(32));
|
||||||
|
$service = new AesEncryptionService($hexKey);
|
||||||
|
|
||||||
|
$encrypted = $service->encrypt('super-secret-value');
|
||||||
|
$plaintext = $service->decrypt($encrypted);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AesEncryptionService
|
||||||
|
|
||||||
|
### Constructor
|
||||||
|
|
||||||
|
```php
|
||||||
|
new AesEncryptionService(string $hexKey, AesCipher $cipher = AesCipher::Aes256Gcm)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `$hexKey` | Hex-encoded key — length must match the chosen cipher (see table below) |
|
||||||
|
| `$cipher` | Cipher variant — defaults to `AesCipher::Aes256Gcm` |
|
||||||
|
|
||||||
|
Throws `InvalidArgumentException` when the key length does not match the cipher.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```php
|
||||||
|
$service->encrypt(string $plaintext): string // returns base64-encoded ciphertext
|
||||||
|
$service->decrypt(string $encoded): string // returns original plaintext
|
||||||
|
$service->getCipher(): AesCipher // returns the active cipher variant
|
||||||
|
```
|
||||||
|
|
||||||
|
`decrypt()` throws `RuntimeException` on invalid base64, too-short input, or authentication tag mismatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AesCipher
|
||||||
|
|
||||||
|
Enum acting as an explicit allowlist — only ciphers defined here may be used.
|
||||||
|
|
||||||
|
| Case | OpenSSL string | Key length | Hex key chars |
|
||||||
|
|------|---------------|------------|---------------|
|
||||||
|
| `AesCipher::Aes128Gcm` | `aes-128-gcm` | 16 bytes / 128 bit | 32 |
|
||||||
|
| `AesCipher::Aes192Gcm` | `aes-192-gcm` | 24 bytes / 192 bit | 48 |
|
||||||
|
| `AesCipher::Aes256Gcm` | `aes-256-gcm` | 32 bytes / 256 bit | **64** (recommended) |
|
||||||
|
|
||||||
|
All variants use a 12-byte nonce (NIST-recommended) and a 16-byte authentication tag.
|
||||||
|
|
||||||
|
```php
|
||||||
|
AesCipher::Aes256Gcm->requiredHexKeyLength(); // 64
|
||||||
|
AesCipher::Aes256Gcm->nonceLength(); // 12
|
||||||
|
AesCipher::Aes256Gcm->tagLength(); // 16
|
||||||
|
AesCipher::Aes256Gcm->label(); // 'AES-256-GCM (32 bytes / 256 bits)'
|
||||||
|
|
||||||
|
AesCipher::values(); // ['aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm']
|
||||||
|
AesCipher::hasValue('aes-256-gcm'); // true
|
||||||
|
AesCipher::find('aes-256-gcm'); // AesCipher::Aes256Gcm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Selecting a cipher variant
|
||||||
|
|
||||||
|
```php
|
||||||
|
// AES-256-GCM (default, recommended)
|
||||||
|
$key256 = bin2hex(random_bytes(32)); // 64 hex chars
|
||||||
|
$service = new AesEncryptionService($key256);
|
||||||
|
|
||||||
|
// AES-192-GCM
|
||||||
|
$key192 = bin2hex(random_bytes(24)); // 48 hex chars
|
||||||
|
$service = new AesEncryptionService($key192, AesCipher::Aes192Gcm);
|
||||||
|
|
||||||
|
// AES-128-GCM
|
||||||
|
$key128 = bin2hex(random_bytes(16)); // 32 hex chars
|
||||||
|
$service = new AesEncryptionService($key128, AesCipher::Aes128Gcm);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Wrong key length → InvalidArgumentException at construction time
|
||||||
|
new AesEncryptionService('tooshort', AesCipher::Aes256Gcm);
|
||||||
|
|
||||||
|
// Tampered ciphertext → RuntimeException
|
||||||
|
$service->decrypt(base64_encode(str_repeat('x', 64)));
|
||||||
|
|
||||||
|
// Invalid base64 → RuntimeException
|
||||||
|
$service->decrypt('not-base64!!!');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.3+
|
||||||
|
- `ext-openssl`
|
||||||
|
- `tez/utils-enum` ^1.0
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "tez/utils-crypto",
|
||||||
|
"type": "library",
|
||||||
|
"description": "Authenticated AES-GCM encryption for PHP — supports AES-128/192/256-GCM with random nonces, tag verification, and a strict enum-based cipher allowlist. PHP 8.3+, requires ext-openssl.",
|
||||||
|
"license": "MIT",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tez\\Utils\\Crypto\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tez\\Utils\\Tests\\Crypto\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.root-zone.info/tez/tez-utils-enum.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tezmanian/tez-utils-enum.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.3",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"tez/utils-enum": "^1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0",
|
||||||
|
"jetbrains/phpstorm-attributes": "^1.0",
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"cs-fix": "php-cs-fixer fix",
|
||||||
|
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||||
|
"phpstan": "phpstan analyse",
|
||||||
|
"test": "phpunit --testsuite Unit --colors=always"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- composer-cache:/tmp/composer
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
environment:
|
||||||
|
COMPOSER_HOME: /tmp/composer
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
composer-cache:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
parameters:
|
||||||
|
level: 9
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
|
treatPhpDocTypesAsCertain: false
|
||||||
|
parallel:
|
||||||
|
maximumNumberOfProcesses: 1
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tez\Utils\Crypto;
|
||||||
|
|
||||||
|
use Tez\Utils\Enum\BackedEnumTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowlist of supported AES-GCM cipher variants.
|
||||||
|
*
|
||||||
|
* Only ciphers defined here may be used with {@see AesEncryptionService}.
|
||||||
|
* To add a new cipher, add a case and update the match expressions in
|
||||||
|
* requiredHexKeyLength(), nonceLength(), and tagLength().
|
||||||
|
*
|
||||||
|
* BackedEnumTrait provides: is(), isOneOf(), values(), hasValue(),
|
||||||
|
* find(), fromName(), tryFromName(), toArray(), toValueArray(), ordinal(), …
|
||||||
|
*/
|
||||||
|
enum AesCipher: string
|
||||||
|
{
|
||||||
|
use BackedEnumTrait;
|
||||||
|
|
||||||
|
/** AES-128-GCM — 32-char hex key (16 bytes / 128 bits). */
|
||||||
|
case Aes128Gcm = 'aes-128-gcm';
|
||||||
|
|
||||||
|
/** AES-192-GCM — 48-char hex key (24 bytes / 192 bits). */
|
||||||
|
case Aes192Gcm = 'aes-192-gcm';
|
||||||
|
|
||||||
|
/** AES-256-GCM — 64-char hex key (32 bytes / 256 bits). Recommended default. */
|
||||||
|
case Aes256Gcm = 'aes-256-gcm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required key length in hex characters (2 hex chars = 1 byte).
|
||||||
|
*
|
||||||
|
* @return positive-int
|
||||||
|
*/
|
||||||
|
public function requiredHexKeyLength(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Aes128Gcm => 32,
|
||||||
|
self::Aes192Gcm => 48,
|
||||||
|
self::Aes256Gcm => 64,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nonce (IV) length in bytes.
|
||||||
|
*
|
||||||
|
* 12 bytes (96 bits) is the NIST-recommended GCM nonce size and
|
||||||
|
* the only length that avoids an extra GHASH step in OpenSSL.
|
||||||
|
*
|
||||||
|
* @return positive-int
|
||||||
|
*/
|
||||||
|
public function nonceLength(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Aes128Gcm, self::Aes192Gcm, self::Aes256Gcm => 12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication tag length in bytes.
|
||||||
|
*
|
||||||
|
* 16 bytes (128 bits) is the maximum GCM tag size and provides
|
||||||
|
* the strongest forgery resistance.
|
||||||
|
*
|
||||||
|
* @return positive-int
|
||||||
|
*/
|
||||||
|
public function tagLength(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Aes128Gcm, self::Aes192Gcm, self::Aes256Gcm => 16,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable label used in exception messages and UIs.
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Aes128Gcm => 'AES-128-GCM (16 bytes / 128 bits)',
|
||||||
|
self::Aes192Gcm => 'AES-192-GCM (24 bytes / 192 bits)',
|
||||||
|
self::Aes256Gcm => 'AES-256-GCM (32 bytes / 256 bits)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tez\Utils\Crypto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric encryption service using authenticated AES-GCM.
|
||||||
|
*
|
||||||
|
* The cipher variant is selected via the {@see AesCipher} enum, which acts as
|
||||||
|
* an explicit allowlist — only ciphers defined there may be used.
|
||||||
|
* Defaults to AES-256-GCM when no cipher is specified.
|
||||||
|
*
|
||||||
|
* Encrypted format (base64-encoded): nonce[N] || ciphertext || tag[T]
|
||||||
|
* where N = AesCipher::nonceLength() and T = AesCipher::tagLength().
|
||||||
|
*
|
||||||
|
* A fresh random nonce is generated for every encrypt() call, so identical
|
||||||
|
* plaintexts produce different ciphertexts. The GCM tag verifies integrity
|
||||||
|
* on decrypt() — tampered data is rejected with a RuntimeException.
|
||||||
|
*/
|
||||||
|
final class AesEncryptionService
|
||||||
|
{
|
||||||
|
private readonly string $key;
|
||||||
|
private readonly AesCipher $cipher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $hexKey A hex string whose length matches the cipher's key size.
|
||||||
|
* Use {@see AesCipher::requiredHexKeyLength()} to determine
|
||||||
|
* the expected length for a given cipher.
|
||||||
|
* @param AesCipher $cipher Cipher variant to use. Defaults to {@see AesCipher::Aes256Gcm}.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException when the key does not match the cipher's required length
|
||||||
|
*/
|
||||||
|
public function __construct(string $hexKey, AesCipher $cipher = AesCipher::Aes256Gcm)
|
||||||
|
{
|
||||||
|
$requiredLength = $cipher->requiredHexKeyLength();
|
||||||
|
|
||||||
|
if (!preg_match('/^[0-9a-fA-F]{' . $requiredLength . '}$/', $hexKey)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
'Encryption key for %s must be a %d-character hex string.',
|
||||||
|
$cipher->label(),
|
||||||
|
$requiredLength,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->key = (string) hex2bin($hexKey);
|
||||||
|
$this->cipher = $cipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt plaintext and return a base64-encoded ciphertext.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException on OpenSSL failure
|
||||||
|
*/
|
||||||
|
public function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
$nonce = random_bytes($this->cipher->nonceLength());
|
||||||
|
$tag = '';
|
||||||
|
|
||||||
|
$ciphertext = openssl_encrypt(
|
||||||
|
$plaintext,
|
||||||
|
$this->cipher->value,
|
||||||
|
$this->key,
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$nonce,
|
||||||
|
$tag,
|
||||||
|
'',
|
||||||
|
$this->cipher->tagLength(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ciphertext === false) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf('Encryption failed for cipher %s.', $this->cipher->value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64_encode($nonce . $ciphertext . $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a base64-encoded ciphertext produced by encrypt().
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException when the input is invalid, too short, or the authentication tag fails
|
||||||
|
*/
|
||||||
|
public function decrypt(string $encoded): string
|
||||||
|
{
|
||||||
|
$raw = base64_decode($encoded, strict: true);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new \RuntimeException('Decryption failed: invalid base64 input.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonceLength = $this->cipher->nonceLength();
|
||||||
|
$tagLength = $this->cipher->tagLength();
|
||||||
|
$minLength = $nonceLength + $tagLength;
|
||||||
|
|
||||||
|
if (strlen($raw) < $minLength) {
|
||||||
|
throw new \RuntimeException('Decryption failed: ciphertext is too short.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = substr($raw, 0, $nonceLength);
|
||||||
|
$tag = substr($raw, -$tagLength);
|
||||||
|
$ciphertext = substr($raw, $nonceLength, -$tagLength);
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
$ciphertext,
|
||||||
|
$this->cipher->value,
|
||||||
|
$this->key,
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$nonce,
|
||||||
|
$tag,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($plaintext === false) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Decryption failed: authentication tag mismatch (data may be tampered).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCipher(): AesCipher
|
||||||
|
{
|
||||||
|
return $this->cipher;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tez\Utils\Tests\Crypto;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tez\Utils\Crypto\AesCipher;
|
||||||
|
use Tez\Utils\Crypto\AesEncryptionService;
|
||||||
|
|
||||||
|
final class AesEncryptionServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
/** 64 hex chars — valid AES-256 key */
|
||||||
|
private const KEY_256 = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
||||||
|
|
||||||
|
/** 48 hex chars — valid AES-192 key */
|
||||||
|
private const KEY_192 = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6';
|
||||||
|
|
||||||
|
/** 32 hex chars — valid AES-128 key */
|
||||||
|
private const KEY_128 = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
|
||||||
|
|
||||||
|
private AesEncryptionService $service256;
|
||||||
|
private AesEncryptionService $service192;
|
||||||
|
private AesEncryptionService $service128;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service256 = new AesEncryptionService(self::KEY_256);
|
||||||
|
$this->service192 = new AesEncryptionService(self::KEY_192, AesCipher::Aes192Gcm);
|
||||||
|
$this->service128 = new AesEncryptionService(self::KEY_128, AesCipher::Aes128Gcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AesCipher enum — BackedEnumTrait helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testValuesReturnsAllOpenSslCipherStrings(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
['aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm'],
|
||||||
|
AesCipher::values(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasValueReturnsTrueForKnownCipher(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(AesCipher::hasValue('aes-256-gcm'));
|
||||||
|
$this->assertTrue(AesCipher::hasValue('aes-128-gcm'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasValueReturnsFalseForUnknownCipher(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(AesCipher::hasValue('aes-512-gcm'));
|
||||||
|
$this->assertFalse(AesCipher::hasValue('des-ede3-cbc'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindByCipherString(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(AesCipher::Aes256Gcm, AesCipher::find('aes-256-gcm'));
|
||||||
|
$this->assertNull(AesCipher::find('unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromNameReturnsCase(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(AesCipher::Aes256Gcm, AesCipher::fromName('Aes256Gcm'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsOneOf(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(AesCipher::Aes256Gcm->isOneOf([AesCipher::Aes128Gcm, AesCipher::Aes256Gcm]));
|
||||||
|
$this->assertFalse(AesCipher::Aes192Gcm->isOneOf([AesCipher::Aes128Gcm, AesCipher::Aes256Gcm]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AesCipher — crypto parameters
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testNonceLengthIs12ForAllVariants(): void
|
||||||
|
{
|
||||||
|
foreach (AesCipher::cases() as $cipher) {
|
||||||
|
$this->assertSame(12, $cipher->nonceLength(), $cipher->label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTagLengthIs16ForAllVariants(): void
|
||||||
|
{
|
||||||
|
foreach (AesCipher::cases() as $cipher) {
|
||||||
|
$this->assertSame(16, $cipher->tagLength(), $cipher->label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiredHexKeyLengths(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(32, AesCipher::Aes128Gcm->requiredHexKeyLength());
|
||||||
|
$this->assertSame(48, AesCipher::Aes192Gcm->requiredHexKeyLength());
|
||||||
|
$this->assertSame(64, AesCipher::Aes256Gcm->requiredHexKeyLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Default cipher (AES-256-GCM)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testDefaultCipherIsAes256Gcm(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(AesCipher::Aes256Gcm, $this->service256->getCipher());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptAndDecryptRoundTripAes256(): void
|
||||||
|
{
|
||||||
|
$plaintext = 'super-secret-client-secret-value';
|
||||||
|
$this->assertSame($plaintext, $this->service256->decrypt($this->service256->encrypt($plaintext)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptProducesDifferentOutputEachTime(): void
|
||||||
|
{
|
||||||
|
$this->assertNotSame(
|
||||||
|
$this->service256->encrypt('same-value'),
|
||||||
|
$this->service256->encrypt('same-value'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptedOutputIsBase64(): void
|
||||||
|
{
|
||||||
|
$this->assertNotFalse(base64_decode($this->service256->encrypt('test'), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptEmptyString(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', $this->service256->decrypt($this->service256->encrypt('')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptLargePayload(): void
|
||||||
|
{
|
||||||
|
$payload = str_repeat('a', 10_000);
|
||||||
|
$this->assertSame($payload, $this->service256->decrypt($this->service256->encrypt($payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AES-192-GCM
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testCipherIsAes192GcmWhenConfigured(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(AesCipher::Aes192Gcm, $this->service192->getCipher());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptAndDecryptRoundTripAes192(): void
|
||||||
|
{
|
||||||
|
$plaintext = 'secret-192';
|
||||||
|
$this->assertSame($plaintext, $this->service192->decrypt($this->service192->encrypt($plaintext)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AES-128-GCM
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testCipherIsAes128GcmWhenConfigured(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(AesCipher::Aes128Gcm, $this->service128->getCipher());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptAndDecryptRoundTripAes128(): void
|
||||||
|
{
|
||||||
|
$plaintext = 'another-secret';
|
||||||
|
$this->assertSame($plaintext, $this->service128->decrypt($this->service128->encrypt($plaintext)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Key-length validation per cipher
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testConstructorThrowsWhenKeyTooShortForAes256(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new AesEncryptionService('tooshort');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorThrowsWhenNonHexKeyGiven(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new AesEncryptionService(str_repeat('zz', 32)); // 64 chars but not hex
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorThrowsWhen256BitKeyUsedForAes128(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new AesEncryptionService(self::KEY_256, AesCipher::Aes128Gcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorThrowsWhen128BitKeyUsedForAes256(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new AesEncryptionService(self::KEY_128, AesCipher::Aes256Gcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructorThrowsWhen256BitKeyUsedForAes192(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new AesEncryptionService(self::KEY_256, AesCipher::Aes192Gcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tamper / error cases
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function testDecryptThrowsOnTamperedData(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service256->decrypt(base64_encode(str_repeat('x', 64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptThrowsOnInvalidBase64(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service256->decrypt('not-base64!!!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptThrowsOnTooShortInput(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->service256->decrypt(base64_encode(str_repeat('x', 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user