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