Inital commit after split from main project
PHP Composer / build (push) Successful in 32s

This commit is contained in:
René Halberstadt
2026-07-26 16:38:29 +02:00
commit 8a6b7cae06
14 changed files with 5404 additions and 0 deletions
+43
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
+47
View File
@@ -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);
+9
View File
@@ -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
+29
View File
@@ -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
+137
View File
@@ -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
+43
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -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>
+87
View File
@@ -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)',
};
}
}
+129
View File
@@ -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;
}
}
+223
View File
@@ -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)));
}
}