diff --git a/README.md b/README.md index 7d975f83..cad1dd64 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. @@ -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). 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) { diff --git a/src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/EncryptionMiddleware.php new file mode 100644 index 00000000..8431b805 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/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); + } +}