Skip to content

Encryption Middleware #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ You can enable the following middleware using the "middlewares" config parameter
- "customization": Provides handlers for request and response customization
- "json": Support read/write of JSON strings as JSON objects/arrays
- "xml": Translates all input and output from JSON to XML
- "encryption": Encrypts specified columns in a table

The "middlewares" config parameter is a comma separated list of enabled middlewares.
You can tune the middleware behavior using middleware specific configuration parameters:
Expand Down Expand Up @@ -799,6 +800,9 @@ You can tune the middleware behavior using middleware specific configuration par
- "json.tables": Tables to process JSON strings for ("all")
- "json.columns": Columns to process JSON strings for ("all")
- "xml.types": JSON types that should be added to the XML type attribute ("null,array")
- "encryption.columns": CSV list of columns to encrypt/decrypt, in `table.column` format
- "encryption.keyVersions": JSON-encoded array of encryption keys.
- "encryption.activeVersion": Sets the key to be used for encryption. Must be a valid key from the keyVersions JSON-encoded array

If you don't specify these parameters in the configuration, then the default values (between brackets) are used.

Expand Down Expand Up @@ -1369,6 +1373,16 @@ Outputs:

This functionality is disabled by default and must be enabled using the "middlewares" configuration setting.

### Encryption Middleware
This middleware can be used to encrypt specific columns from specified tables.
Configuration
`encryption.columns`: A comma-separated list of columns to encrypt, using the format, `tablename.columnname`
Ex. `users.firstname,users.lastname,users.ssn`

`encryption.keyVersions`: A json_encoded associative array of encryption keys, where the array key (e.g. "v1", "v2") serves as a version identifier.
This version identifier is prefixed to each encrypted data to indicate which key to use for decryption.


### File uploads

File uploads are supported through the [FileReader API](https://caniuse.com/#feat=filereader), check out the [example](https://github.com/mevdschee/php-crud-api/blob/master/examples/clients/upload/vanilla.html).
Expand Down
4 changes: 4 additions & 0 deletions src/Tqdev/PhpCrudApi/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Tqdev\PhpCrudApi\Middleware\CorsMiddleware;
use Tqdev\PhpCrudApi\Middleware\CustomizationMiddleware;
use Tqdev\PhpCrudApi\Middleware\DbAuthMiddleware;
use Tqdev\PhpCrudApi\Middleware\EncryptionMiddleware;
use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware;
use Tqdev\PhpCrudApi\Middleware\IpAddressMiddleware;
use Tqdev\PhpCrudApi\Middleware\JoinLimitsMiddleware;
Expand Down Expand Up @@ -135,6 +136,9 @@ public function __construct(Config $config)
case 'json':
new JsonMiddleware($router, $responder, $config, $middleware);
break;
case 'encryption':
ew EncryptionMiddleware($router, $responder, $config, $middleware, $reflection, $db);
break;
}
}
foreach ($config->getControllers() as $controller) {
Expand Down
162 changes: 162 additions & 0 deletions src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

namespace Tqdev\PhpCrudApi\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Tqdev\PhpCrudApi\Middleware\Base\Middleware;
use Tqdev\PhpCrudApi\RequestUtils;
use Tqdev\PhpCrudApi\ResponseFactory;
use Tqdev\PhpCrudApi\Column\ReflectionService;
use Tqdev\PhpCrudApi\Config\Config;
use Tqdev\PhpCrudApi\Controller\Responder;
use Tqdev\PhpCrudApi\Middleware\Router\Router;

class EncryptionMiddleware extends Middleware
{
private $reflection;
private $keyVersions;
private $activeVersion;

public function __construct(Router $router, Responder $responder, Config $config, string $middleware, ReflectionService $reflection)
{
parent::__construct($router, $responder, $config, $middleware);
$this->reflection = $reflection;

$keyJson = $this->getProperty('keyVersions', '{}');
$this->keyVersions = json_decode($keyJson, true);
$this->activeVersion = $this->getProperty('activeVersion', '');

if (!isset($this->keyVersions[$this->activeVersion])) {
throw new \RuntimeException("Active key version '{$this->activeVersion}' is not configured.");
}

foreach ($this->keyVersions as $v => $k) {
if (strlen($k) < 32) {
throw new \RuntimeException("Key for version '{$v}' must be at least 32 characters.");
}
}
}

private function getColumns(): array
{
$columns = $this->getProperty('columns', '');
return array_filter(array_map('trim', explode(',', $columns)));
}

private function encrypt(string $value): string
{
$key = $this->keyVersions[$this->activeVersion];
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
$encrypted = openssl_encrypt($value, 'aes-256-cbc', $key, 0, $iv);
return $this->activeVersion . '|' . base64_encode($iv . $encrypted);
}

private function decrypt(string $value): string
{
if (strpos($value, '|') === false) {
throw new \RuntimeException('Invalid encrypted format, missing key version prefix');
}

list($version, $encoded) = explode('|', $value, 2);
if (!isset($this->keyVersions[$version])) {
// throw new \RuntimeException("Unknown encryption key version: $version");
// error_log("WARNING: Key is missing for record ID: $id");
return $value;//return encrypted value if no key found
}
$key = $this->keyVersions[$version];

$data = base64_decode($encoded);
$ivLength = openssl_cipher_iv_length('aes-256-cbc');
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);

return openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
}

private function encryptRecord($record, array $columns)
{
foreach ($columns as $column) {
if (!is_string($column)) continue;
$col = strpos($column, '.') !== false ? explode('.', $column)[1] : $column;

if (is_array($record) && array_key_exists($col, $record) && is_string($record[$col])) {
$record[$col] = $this->encrypt($record[$col]);
} elseif (is_object($record) && property_exists($record, $col) && is_string($record->$col)) {
$record->$col = $this->encrypt($record->$col);
}
}
return $record;
}

private function decryptRecord($record, array $columns)
{
foreach ($columns as $column) {
if (!is_string($column)) continue;
$col = strpos($column, '.') !== false ? explode('.', $column)[1] : $column;

if (is_array($record) && array_key_exists($col, $record) && is_string($record[$col])) {
$record[$col] = $this->decrypt($record[$col]);
} elseif (is_object($record) && property_exists($record, $col) && is_string($record->$col)) {
$record->$col = $this->decrypt($record->$col);
}
}
return $record;
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface
{
$operation = RequestUtils::getOperation($request);
$tableName = RequestUtils::getPathSegment($request, 2);
$columns = $this->getColumns();

$tableColumns = array_filter($columns, fn($col) => strpos($col, $tableName . '.') === 0);
if (empty($tableColumns)) {
return $next->handle($request);
}

$tableColumns = array_values(array_filter($tableColumns, 'is_string'));

switch ($operation) {
case 'create':
case 'update':
$body = $request->getParsedBody();
if (
(is_array($body) && isset($body['records']) && is_array($body['records'])) ||
(is_object($body) && isset($body->records) && is_array($body->records))
) {
$records = is_array($body) ? $body['records'] : $body->records;
foreach ($records as &$record) {
$record = $this->encryptRecord($record, $tableColumns);
}
if (is_array($body)) {
$body['records'] = $records;
} else {
$body->records = $records;
}
} else {
$body = $this->encryptRecord($body, $tableColumns);
}
$request = $request->withParsedBody($body);
break;

case 'read':
case 'list':
$response = $next->handle($request);
$bodyStr = (string)$response->getBody();
$body = json_decode($bodyStr);

if (isset($body->records) && is_array($body->records)) {
foreach ($body->records as &$record) {
$record = $this->decryptRecord($record, $tableColumns);
}
} else {
$body = $this->decryptRecord($body, $tableColumns);
}
return ResponseFactory::fromObject($response->getStatusCode(), $body, JSON_UNESCAPED_UNICODE);
}

return $next->handle($request);
}
}