From 928660705fbaa8d9c68d9b988f6a4aaa0e0d5886 Mon Sep 17 00:00:00 2001 From: jaleonardo <32501234+apps-caraga@users.noreply.github.com> Date: Thu, 3 Jul 2025 14:26:20 +0800 Subject: [PATCH 1/5] Encryption Middleware `EncryptionMiddleware` provides transparent, encryption and decryption of specified columns in your API requests and responses. **Note:** Encryption is global. All specified columns are encrypted and decrypted using the same set of keys and versioning, not per-user or per-tenant. ## Features - AES-256-CBC encryption for specified columns - Key versioning and rotation support - Works for both single and batch record operations - Transparent: encrypts on create/update, decrypts on read/list **Configuration** - You must provide the following properties: - `keyVersions`: JSON object mapping version names to encryption keys (each key must be at least 32 characters) - `activeVersion`: The version name to use for new encryptions - `columns`: Comma-separated list of columns to encrypt, in the format `table.column` --- EncryptionMiddleware.php | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 EncryptionMiddleware.php diff --git a/EncryptionMiddleware.php b/EncryptionMiddleware.php new file mode 100644 index 00000000..8431b805 --- /dev/null +++ b/EncryptionMiddleware.php @@ -0,0 +1,162 @@ +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); + } +} From 049023096800145c7cfd417c9797c4e97d7a97c3 Mon Sep 17 00:00:00 2001 From: jaleonardo <32501234+apps-caraga@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:25:29 +0800 Subject: [PATCH 2/5] Update Api.php Added loading of encryption middlewre --- src/Tqdev/PhpCrudApi/Api.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Tqdev/PhpCrudApi/Api.php b/src/Tqdev/PhpCrudApi/Api.php index 0f99d9da..ffc196cc 100644 --- a/src/Tqdev/PhpCrudApi/Api.php +++ b/src/Tqdev/PhpCrudApi/Api.php @@ -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; @@ -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) { From 2d5b0421dc6b1b33a5115905fb53fb0c98815a6f Mon Sep 17 00:00:00 2001 From: jaleonardo <32501234+apps-caraga@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:32:21 +0800 Subject: [PATCH 3/5] Rename EncryptionMiddleware.php to src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php --- .../Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename EncryptionMiddleware.php => src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php (100%) diff --git a/EncryptionMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php similarity index 100% rename from EncryptionMiddleware.php rename to src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php From 9f23f4edcd82b4c4657acaebf4ad77ac2acad66e Mon Sep 17 00:00:00 2001 From: jaleonardo <32501234+apps-caraga@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:39:49 +0800 Subject: [PATCH 4/5] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7d975f83..949efbff 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. From 85bc3bf4beab7c14d78b19ac4d327d12f14f8824 Mon Sep 17 00:00:00 2001 From: jaleonardo <32501234+apps-caraga@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:58:13 +0800 Subject: [PATCH 5/5] Update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 949efbff..cad1dd64 100644 --- a/README.md +++ b/README.md @@ -1373,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).