diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..f5b83f7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,40 @@ +name: CI Tests +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['5.6', '7.4'] + name: Testing PHP ${{ matrix.php-versions }} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + - name: Check PHP Version + run: php -v + - name: Install Dependencies for PHP ${{ matrix.php-versions }} + run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist + - name: Execute tests against PHP ${{ matrix.php-versions }} + run: composer test + typecheck: + runs-on: ubuntu-latest + name: Typechecks against PSALM + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Check PHP Version + run: php -v + - name: Install Dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist + - name: Downloading + run: wget https://github.com/vimeo/psalm/releases/download/3.12.1/psalm.phar + - name: Typechecking + run: php psalm.phar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cac762f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/.idea/ diff --git a/README.md b/README.md index 6a6cabb..2335133 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,105 @@ -# vaultPHP -A PHP library for vault +# PHP Hashicorp Vault Client + +PHP Client Library for the Hashicorp Vault Service. +This Client follows the Request and Response Data equal to the Hashicorp Vault Client Documentation. +- Authentication https://www.vaultproject.io/api-docs/auth +- Secret Engines https://www.vaultproject.io/api-docs/secret + +Feel free to open Pull Requests to add improvements or missing functionality. + +## Implemented Functionality: +- Auth + - User/Password + - Token + - Kubernetes +- Secret Engines + - Transit Engine + - Encrypt/Decrypt + - Update Key Config + - Create Key + - Delete Key + - List Keys + +## Basic Usage + +```php +// setting up independent http client +$httpClient = new Client(); + +// setting up vault auth provider +$auth = new Token('foo'); + +// creating the vault request client +$client = new VaultClient( + $httpClient, + $auth, + 'http://127.0.0.1:8200' +); + +// selecting the desired secret engine +// e.g. Transit Secret Engine +$api = new Transit($client); + +// calling specific endpoint +$response = $api->listKeys(); + +//reading results +var_dump($response->getKeys()); +//... +//... +//Profit... +``` + +#### VaultClient + +````php +public function __construct( + HttpClient $httpClient, + AuthenticationProviderInterface $authProvider, + string $apiHost +) +```` + +`HttpClient` takes every PSR-18 compliant HTTP Client Adapter like `"php-http/curl-client": "^1.7"` + +`AuthenticationProviderInterface` Authentication Provider from `/authentication/provider/*` + +`$apiHost` Hashicorp Vault REST Endpoint URL + +## Bulk Requests +Bulk Requests **will not** throw `InvalidDataExceptions`. Using Bulk Requests requires to iterate through the Response +and calling `hasErrors` within the `BasicMetaResponse`. + +## Exceptions +Calling library methods will throw exceptions, indicating where ever invalid data was provided +or HTTP errors occurred or Vault Generic Endpoint Errors are encountered. +___ + +`VaultException` + +Generic Root Exception where every exception in this library extends from. +___ + +`VaultHttpException` + +Exception will thrown when something inside the HTTP handling will cause an error. +___ + +`VaultAuthenticationException` + +Will be thrown when API Endpoint Authentication fails. +___ + +`VaultResponseException` + +Will be thrown on 5xx status code errors. +___ + +`InvalidRouteException` + +Calling an Invalid/Non Existing/Disabled Vault API Endpoint will throw this Exception. +___ + +`InvalidDataException` + +Exception indicates a failed server payload validation. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5006cd4 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "mittwald/vault-php", + "type": "library", + "license": "MIT", + "version": "1.0.0", + "homepage": "https://www.mittwald.de/", + "description": "PHP library for Vault", + "require": { + "ext-json": "*", + "guzzlehttp/psr7": ">=1.6", + "php": ">=5.6", + "php-http/httplug": ">=1.1.0" + }, + "suggest": { + "php-http/curl-client": "CURL Client Adapter" + }, + "require-dev": { + "phpunit/phpunit": ">=5.0.0" + }, + "authors": [ + { + "name": "Marco Rieger", + "email": "m.rieger@mittwald.de" + } + ], + "autoload": { + "psr-4": { + "VaultPHP\\": "src\\VaultPHP\\" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\VaultPHP\\": "tests\\VaultPHP\\" + } + }, + "scripts": { + "test": "php ./vendor/bin/phpunit --configuration ./phpunit.xml.dist" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5f642fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3" +services: + vault: + image: vault:latest + container_name: vault + restart: unless-stopped + ports: + - "8200:8200" + environment: + VAULT_ADDR: 'http://0.0.0.0:8200' + VAULT_DEV_ROOT_TOKEN_ID: 'test' + VAULT_TOKEN: 'test' + cap_add: + - IPC_LOCK + healthcheck: + retries: 5 + command: server -dev diff --git a/examples/BulkOperations.php b/examples/BulkOperations.php new file mode 100644 index 0000000..7a4d7bb --- /dev/null +++ b/examples/BulkOperations.php @@ -0,0 +1,83 @@ + './ssl.pem', + CURLOPT_SSLCERTTYPE => 'PEM', + CURLOPT_SSLCERTPASSWD => 'fooBar', +]); + +// provide hashicorp vault auth +$authenticationProvider = new Token('test'); + +// initalize the vault request client +$vaultClient = new VaultClient( + $httpClient, + $authenticationProvider, + 'https://127.0.0.1:8200' +); + +// choose your secret engine api +$transitApi = new Transit($vaultClient); + +// do fancy stuff +try { + // create key + $exampleKey = new CreateKeyRequest('exampleKeyName'); + $exampleKey->setType(EncryptionType::RSA_2048); + $transitApi->createKey($exampleKey); + + $encryptRequest = new EncryptDataBulkRequest('exampleKeyName'); + $encryptRequest->addBulkRequests([ + new EncryptData('cryptMeBabyOneMoreTime::1'), + new EncryptData('cryptMeBabyOneMoreTime::2'), + new EncryptData('cryptMeBabyOneMoreTime::3'), + new EncryptData('cryptMeBabyOneMoreTime::4'), + ]); + $encryptBulkResponse = $transitApi->encryptDataBulk($encryptRequest); + + foreach($encryptBulkResponse as $bulkResult) { + // BULK REQUEST WON'T THROW INVALID DATA EXCEPTIONS + // SO YOU ARE RESPONSABLE TO CHECK IF EVERY BULK WAS + // SUCCESSFULLY PROCESSED + if (!$bulkResult->getBasicMetaResponse()->hasErrors()) { + var_dump($bulkResult->getCiphertext()); + } + } + + // update key config and allow deletion + $keyConfigExample = new UpdateKeyConfigRequest('exampleKeyName'); + $keyConfigExample->setDeletionAllowed(true); + $transitApi->updateKeyConfig($keyConfigExample); + + // delete key + $transitApi->deleteKey('exampleKeyName'); + + // list keys + $listKeyResponse = $transitApi->listKeys(); + var_dump($listKeyResponse->getKeys()); + +} catch (VaultResponseException $exception) { + var_dump($exception->getMessage()); + var_dump($exception->getResponse()); + var_dump($exception->getRequest()); + +} catch (VaultException $exception) { + var_dump($exception->getMessage()); +} diff --git a/examples/TransitEncryption.php b/examples/TransitEncryption.php new file mode 100644 index 0000000..e245271 --- /dev/null +++ b/examples/TransitEncryption.php @@ -0,0 +1,81 @@ + './ssl.pem', + CURLOPT_SSLCERTTYPE => 'PEM', + CURLOPT_SSLCERTPASSWD => 'fooBar', +]); + +// provide hashicorp vault auth +$authenticationProvider = new Token('test'); + +// initalize the vault request client +$vaultClient = new VaultClient( + $httpClient, + $authenticationProvider, + 'https://127.0.0.1:8200' +); + +// choose your secret engine api +$transitApi = new Transit($vaultClient); + +// do fancy stuff +try { + // create key + $exampleKey = new CreateKeyRequest('exampleKeyName'); + $exampleKey->setType(EncryptionType::CHA_CHA_20_POLY_1305); + $transitApi->createKey($exampleKey); + + // list keys + $listKeyResponse = $transitApi->listKeys(); + var_dump($listKeyResponse->getKeys()); + + // encrypt data + $encryptExample = new EncryptDataRequest('exampleKeyName', 'encryptMe'); + $encryptResponse = $transitApi->encryptData($encryptExample); + + var_dump($encryptResponse->getCiphertext()); + + // decrypt data + $decryptExample = new DecryptDataRequest('exampleKeyName', $encryptResponse->getCiphertext()); + $decryptResponse = $transitApi->decryptData($decryptExample); + + var_dump($decryptResponse->getPlaintext()); + + // update key config and allow deletion + $keyConfigExample = new UpdateKeyConfigRequest('exampleKeyName'); + $keyConfigExample->setDeletionAllowed(true); + $transitApi->updateKeyConfig($keyConfigExample); + + // delete key + $transitApi->deleteKey('exampleKeyName'); + + // list keys + $listKeyResponse = $transitApi->listKeys(); + var_dump($listKeyResponse->getKeys()); + +} catch (VaultResponseException $exception) { + var_dump($exception->getMessage()); + var_dump($exception->getResponse()); + var_dump($exception->getRequest()); + +} catch (VaultException $exception) { + var_dump($exception->getMessage()); +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ae314b9 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + ./src/ + + + + + ./tests + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..4057dcb --- /dev/null +++ b/psalm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/src/VaultPHP/Authentication/AbstractAuthenticationProvider.php b/src/VaultPHP/Authentication/AbstractAuthenticationProvider.php new file mode 100644 index 0000000..04989d8 --- /dev/null +++ b/src/VaultPHP/Authentication/AbstractAuthenticationProvider.php @@ -0,0 +1,38 @@ +vaultClient = $VaultClient; + } + + /** + * @return VaultClient + * @throws VaultException + */ + public function getVaultClient() + { + if (!$this->vaultClient) { + throw new VaultException('Trying to request the VaultClient before initialization'); + } + + return $this->vaultClient; + } +} diff --git a/src/VaultPHP/Authentication/AuthenticationMetaData.php b/src/VaultPHP/Authentication/AuthenticationMetaData.php new file mode 100644 index 0000000..8f8b906 --- /dev/null +++ b/src/VaultPHP/Authentication/AuthenticationMetaData.php @@ -0,0 +1,39 @@ +token = $fromAuth->client_token; + } + } + + /** + * @return string + */ + public function getClientToken() { + return $this->token; + } + + /** + * @return bool + */ + public function isClientTokenPresent() { + return !!$this->token; + } +} diff --git a/src/VaultPHP/Authentication/AuthenticationProviderInterface.php b/src/VaultPHP/Authentication/AuthenticationProviderInterface.php new file mode 100644 index 0000000..b06619a --- /dev/null +++ b/src/VaultPHP/Authentication/AuthenticationProviderInterface.php @@ -0,0 +1,30 @@ +role = $role; + $this->jwt = $jwt; + } + + /** + * @return bool|AuthenticationMetaData + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultAuthenticationException + * @throws VaultException + * @throws VaultHttpException + */ + public function authenticate() + { + /** @var EndpointResponse $response */ + $response = $this->getVaultClient()->sendApiRequest( + 'POST', + $this->endpoint, + EndpointResponse::class, + [ + 'role' => $this->role, + 'jwt' => $this->jwt, + ], + false + ); + + if ($auth = $response->getBasicMetaResponse()->getAuth()) { + return new AuthenticationMetaData($auth); + } + + return false; + } +} diff --git a/src/VaultPHP/Authentication/Provider/Token.php b/src/VaultPHP/Authentication/Provider/Token.php new file mode 100644 index 0000000..552e213 --- /dev/null +++ b/src/VaultPHP/Authentication/Provider/Token.php @@ -0,0 +1,35 @@ +token = $token; + } + + /** + * @return AuthenticationMetaData + */ + public function authenticate() + { + return new AuthenticationMetaData((object) [ + 'client_token' => $this->token, + ]); + } +} diff --git a/src/VaultPHP/Authentication/Provider/UserPassword.php b/src/VaultPHP/Authentication/Provider/UserPassword.php new file mode 100644 index 0000000..4b4f056 --- /dev/null +++ b/src/VaultPHP/Authentication/Provider/UserPassword.php @@ -0,0 +1,73 @@ +username = $username; + $this->password = $password; + } + + /** + * @return bool|AuthenticationMetaData + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultAuthenticationException + * @throws VaultException + * @throws VaultHttpException + */ + public function authenticate() + { + /** @var EndpointResponse $response */ + $response = $this->getVaultClient()->sendApiRequest( + 'POST', + $this->getAuthUrl(), + EndpointResponse::class, + [ + 'password' => $this->password + ], + false + ); + + if ($auth = $response->getBasicMetaResponse()->getAuth()) { + return new AuthenticationMetaData($auth); + } + + return false; + } + + /** + * @return string + */ + private function getAuthUrl() + { + return sprintf($this->endpoint, urlencode($this->username)); + } +} diff --git a/src/VaultPHP/Exceptions/InvalidDataException.php b/src/VaultPHP/Exceptions/InvalidDataException.php new file mode 100644 index 0000000..2483bcb --- /dev/null +++ b/src/VaultPHP/Exceptions/InvalidDataException.php @@ -0,0 +1,11 @@ +response = $response; + $this->request = $request; + + $parsedResponse = EndpointResponse::fromResponse($response); + $returnedErrors = $parsedResponse->getBasicMetaResponse()->getErrors(); + $errors = implode(', ', is_array($returnedErrors) ? $returnedErrors : []); + + parent::__construct($errors, $response->getStatusCode(), $prevException); + } + + /** + * @return RequestInterface + */ + public function getRequest() + { + return $this->request; + } + + /** + * @return ResponseInterface + */ + public function getResponse() + { + return $this->response; + } +} diff --git a/src/VaultPHP/Response/BasicMetaResponse.php b/src/VaultPHP/Response/BasicMetaResponse.php new file mode 100644 index 0000000..8a9e697 --- /dev/null +++ b/src/VaultPHP/Response/BasicMetaResponse.php @@ -0,0 +1,131 @@ +populateData($data); + } + + /** + * @param array|object $data + * @return void + */ + private function populateData($data) + { + /** @var string $key */ + /** @var mixed $value */ + foreach ($data as $key => $value) { + if (property_exists(self::class, (string) $key)) { + $this->$key = $value; + } + } + } + + /** + * @return string|null + */ + public function getRequestId() + { + return $this->request_id; + } + + /** + * @return string|null + */ + public function getLeaseId() + { + return $this->lease_id; + } + + /** + * @return bool|null + */ + public function getRenewable() + { + return $this->renewable; + } + + /** + * @return int|null + */ + public function getLeaseDuration() + { + return $this->lease_duration; + } + + /** + * @return string|null + */ + public function getWrapInfo() + { + return $this->wrap_info; + } + + /** + * @return array|null + */ + public function getWarnings() + { + return $this->warnings; + } + + /** + * @return object|null + */ + public function getAuth() + { + return $this->auth; + } + + /** + * @return array|null + */ + public function getErrors() + { + return $this->errors; + } + + /** + * @return boolean + */ + public function hasErrors() + { + $errors = $this->getErrors(); + return $errors !== NULL && is_array($errors) && count($errors) >= 1; + } +} diff --git a/src/VaultPHP/Response/BasicMetaResponseInterface.php b/src/VaultPHP/Response/BasicMetaResponseInterface.php new file mode 100644 index 0000000..fb67f97 --- /dev/null +++ b/src/VaultPHP/Response/BasicMetaResponseInterface.php @@ -0,0 +1,61 @@ +basicMetaResponse = new BasicMetaResponse(); + $this->populateData($data); + } + + /** + * @param $response + * @return array + */ + private static function getResponseContent(ResponseInterface $response) { + $responseBody = $response->getBody(); + $responseBody->rewind(); + $responseBodyContents = $responseBody->getContents(); + + // cast to array because we only want the first root + // as array and not the complete response + return (array) json_decode($responseBodyContents); + } + + /** + * @param ResponseInterface $response + * @return static + */ + static function fromResponse(ResponseInterface $response) + { + $metaData = static::getResponseContent($response); + + /** @var object|array $domainData */ + $domainData = isset($metaData['data']) ? $metaData['data'] : []; + unset($metaData['data']); + + $responseDTO = new static($domainData); + $responseDTO->basicMetaResponse = new BasicMetaResponse($metaData); + + return $responseDTO; + } + + /** + * @param ResponseInterface $response + * @return static[] + */ + static function fromBulkResponse(ResponseInterface $response) + { + $resultArray = []; + $metaData = static::getResponseContent($response); + + /** @var object $domainData */ + $domainData = isset($metaData['data']) ? $metaData['data'] : []; + unset($metaData['data']); + + if ($domainData && is_array($domainData->batch_results)) { + /** @var object $batchResult */ + foreach($domainData->batch_results as $batchResult) { + /** @var array $batchMetaData */ + $batchMetaData = $metaData; + + if (isset($batchResult->error)) { + /** @var array $currentErrors */ + $currentErrors = isset($metaData['errors']) ? $metaData['errors'] : []; + array_push($currentErrors, $batchResult->error); + + $batchMetaData['errors'] = $currentErrors; + } + + $responseDTO = new static($batchResult); + $responseDTO->basicMetaResponse = new BasicMetaResponse($batchMetaData); + $resultArray[] = $responseDTO; + } + } + + return $resultArray; + } + + /** + * @param array|object $data + * @return void + */ + private function populateData($data) + { + /** @var string $key */ + /** @var mixed $value */ + foreach ($data as $key => $value) { + if (property_exists(static::class, (string) $key)) { + $this->$key = $value; + } + } + } + + /** + * @return BasicMetaResponse + */ + public function getBasicMetaResponse() + { + return $this->basicMetaResponse; + } +} diff --git a/src/VaultPHP/Response/EndpointResponseInterface.php b/src/VaultPHP/Response/EndpointResponseInterface.php new file mode 100644 index 0000000..25073cf --- /dev/null +++ b/src/VaultPHP/Response/EndpointResponseInterface.php @@ -0,0 +1,23 @@ +vaultClient = $VaultClient; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/EncryptionType.php b/src/VaultPHP/SecretEngines/Engines/Transit/EncryptionType.php new file mode 100644 index 0000000..e47d6d4 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/EncryptionType.php @@ -0,0 +1,21 @@ +setName($name); + } + + /** + * @param string $type + * @return void + */ + public function setType($type) + { + $this->type = $type; + } + + /** + * @return string|null + */ + public function getType() + { + return $this->type; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptData.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptData.php new file mode 100644 index 0000000..4e0ada4 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptData.php @@ -0,0 +1,88 @@ +setCiphertext($ciphertext); + $this->setContext($context); + $this->setNonce($nonce); + } + + /** + * @return string + */ + public function getCiphertext() + { + return $this->ciphertext; + } + + /** + * @param string $ciphertext + * @return void + */ + public function setCiphertext($ciphertext) + { + $this->ciphertext = $ciphertext; + } + + /** + * @return string|null + */ + public function getNonce() + { + return $this->nonce; + } + + /** + * @param string|null $nonce + * @return void + */ + public function setNonce($nonce) + { + $this->nonce = $nonce; + } + + /** + * @return string|null + */ + public function getContext() + { + return $this->context; + } + + /** + * @param string|null $context + * @return void + */ + public function setContext($context) + { + $this->context = $context; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataBulkRequest.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataBulkRequest.php new file mode 100644 index 0000000..5402169 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataBulkRequest.php @@ -0,0 +1,32 @@ +setName($name); + $this->addBulkRequests($batchRequests); + } + +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataRequest.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataRequest.php new file mode 100644 index 0000000..cb28f20 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/DecryptData/DecryptDataRequest.php @@ -0,0 +1,27 @@ +setName($name); + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptData.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptData.php new file mode 100644 index 0000000..e7811fe --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptData.php @@ -0,0 +1,88 @@ +setPlaintext($plaintext); + $this->setContext($context); + $this->setNonce($nonce); + } + + /** + * @return string + */ + public function getPlaintext() + { + return $this->plaintext; + } + + /** + * @param string $plaintext + * @return void + */ + public function setPlaintext($plaintext) + { + $this->plaintext = base64_encode($plaintext); + } + + /** + * @return string|null + */ + public function getNonce() + { + return $this->nonce; + } + + /** + * @param string|null $nonce + * @return void + */ + public function setNonce($nonce) + { + $this->nonce = $nonce; + } + + /** + * @return string|null + */ + public function getContext() + { + return $this->context; + } + + /** + * @param string|null $context + * @return void + */ + public function setContext($context) + { + $this->context = $context; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataBulkRequest.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataBulkRequest.php new file mode 100644 index 0000000..005087e --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataBulkRequest.php @@ -0,0 +1,27 @@ +setName($name); + $this->addBulkRequests($batchRequests); + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataRequest.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataRequest.php new file mode 100644 index 0000000..0035b94 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/EncryptData/EncryptDataRequest.php @@ -0,0 +1,27 @@ +setName($name); + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Request/UpdateKeyConfigRequest.php b/src/VaultPHP/SecretEngines/Engines/Transit/Request/UpdateKeyConfigRequest.php new file mode 100644 index 0000000..a47e077 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Request/UpdateKeyConfigRequest.php @@ -0,0 +1,127 @@ +setName($name); + } + + /** + * @param $allow boolean + * @return void + */ + public function setDeletionAllowed($allow) + { + $this->deletion_allowed = (boolean)$allow; + } + + /** + * @param int $min_decryption_version + * @return void + */ + public function setMinDecryptionVersion($min_decryption_version) + { + $this->min_decryption_version = (int)$min_decryption_version; + } + + /** + * @param int $min_encryption_version + * @return void + */ + public function setMinEncryptionVersion($min_encryption_version) + { + $this->min_encryption_version = (int)$min_encryption_version; + } + + /** + * @param bool $exportable + * @return void + */ + public function setExportable($exportable) + { + $this->exportable = (bool)$exportable; + } + + /** + * @param bool $allow_plaintext_backup + * @return void + */ + public function setAllowPlaintextBackup($allow_plaintext_backup) + { + $this->allow_plaintext_backup = (bool)$allow_plaintext_backup; + } + + /** + * @return int|null + */ + public function getMinDecryptionVersion() + { + return $this->min_decryption_version; + } + + /** + * @return int|null + */ + public function getMinEncryptionVersion() + { + return $this->min_encryption_version; + } + + /** + * @return bool|null + */ + public function getExportable() + { + return $this->exportable; + } + + /** + * @return bool|null + */ + public function getAllowPlaintextBackup() + { + return $this->allow_plaintext_backup; + } + + /** + * @return bool|null + */ + public function getDeletionAllowed() + { + return $this->deletion_allowed; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Response/CreateKeyResponse.php b/src/VaultPHP/SecretEngines/Engines/Transit/Response/CreateKeyResponse.php new file mode 100644 index 0000000..6761681 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Response/CreateKeyResponse.php @@ -0,0 +1,13 @@ +plaintext); + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Response/DeleteKeyResponse.php b/src/VaultPHP/SecretEngines/Engines/Transit/Response/DeleteKeyResponse.php new file mode 100644 index 0000000..ccf4215 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Response/DeleteKeyResponse.php @@ -0,0 +1,13 @@ +ciphertext; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Response/ListKeysResponse.php b/src/VaultPHP/SecretEngines/Engines/Transit/Response/ListKeysResponse.php new file mode 100644 index 0000000..099113a --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Response/ListKeysResponse.php @@ -0,0 +1,23 @@ +keys; + } +} diff --git a/src/VaultPHP/SecretEngines/Engines/Transit/Response/UpdateKeyConfigResponse.php b/src/VaultPHP/SecretEngines/Engines/Transit/Response/UpdateKeyConfigResponse.php new file mode 100644 index 0000000..5192874 --- /dev/null +++ b/src/VaultPHP/SecretEngines/Engines/Transit/Response/UpdateKeyConfigResponse.php @@ -0,0 +1,13 @@ +vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/keys/%s', urlencode($createKeyRequest->getName())), + CreateKeyResponse::class, + $createKeyRequest + ); + } + + /** + * @param EncryptDataRequest $encryptDataRequest + * @return EncryptDataResponse + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function encryptData(EncryptDataRequest $encryptDataRequest) + { + /** @var EncryptDataResponse */ + return $this->vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/encrypt/%s', urlencode($encryptDataRequest->getName())), + EncryptDataResponse::class, + $encryptDataRequest + ); + } + + /** + * @param EncryptDataBulkRequest $encryptDataBulkRequest + * @return EncryptDataResponse[] + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function encryptDataBulk(EncryptDataBulkRequest $encryptDataBulkRequest) + { + /** @var EncryptDataResponse[] */ + return $this->vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/encrypt/%s', urlencode($encryptDataBulkRequest->getName())), + EncryptDataResponse::class, + $encryptDataBulkRequest + ); + } + + /** + * @param DecryptDataRequest $decryptDataRequest + * @return DecryptDataResponse + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function decryptData(DecryptDataRequest $decryptDataRequest) + { + /** @var DecryptDataResponse */ + return $this->vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/decrypt/%s', urlencode($decryptDataRequest->getName())), + DecryptDataResponse::class, + $decryptDataRequest + ); + } + + /** + * @param DecryptDataBulkRequest $decryptDataBulkRequest + * @return EndpointResponse|EndpointResponse[] + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + * @throws VaultAuthenticationException + * @throws VaultHttpException + */ + public function decryptDataBulk(DecryptDataBulkRequest $decryptDataBulkRequest) + { + /** @var DecryptDataResponse[] */ + return $this->vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/decrypt/%s', urlencode($decryptDataBulkRequest->getName())), + DecryptDataResponse::class, + $decryptDataBulkRequest + ); + } + + /** + * @return ListKeysResponse + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function listKeys() + { + /** @var ListKeysResponse */ + return $this->vaultClient->sendApiRequest( + 'LIST', + '/v1/transit/keys', + ListKeysResponse::class, + [] + ); + } + + /** + * @param string $name + * @return EndpointResponse + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function deleteKey($name) + { + /** @var EndpointResponse */ + return $this->vaultClient->sendApiRequest( + 'DELETE', + sprintf('/v1/transit/keys/%s', urlencode($name)), + DeleteKeyResponse::class, + [] + ); + } + + /** + * @param UpdateKeyConfigRequest $updateKeyConfigRequest + * @return UpdateKeyConfigResponse + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultException + */ + public function updateKeyConfig(UpdateKeyConfigRequest $updateKeyConfigRequest) + { + /** @var UpdateKeyConfigResponse */ + return $this->vaultClient->sendApiRequest( + 'POST', + sprintf('/v1/transit/keys/%s/config', urlencode($updateKeyConfigRequest->getName())), + UpdateKeyConfigResponse::class, + $updateKeyConfigRequest + ); + } +} diff --git a/src/VaultPHP/SecretEngines/Interfaces/ArrayExportInterface.php b/src/VaultPHP/SecretEngines/Interfaces/ArrayExportInterface.php new file mode 100644 index 0000000..29ee56b --- /dev/null +++ b/src/VaultPHP/SecretEngines/Interfaces/ArrayExportInterface.php @@ -0,0 +1,15 @@ + $data) { + if (is_array($data)) { + $output[$key] = $this->array_map_r($callback, $data); + } else { + $output[$key] = $callback($data); + } + } + + return $output; + } + + /** + * @return array + */ + public function toArray() + { + $data = get_object_vars($this); + return $this->array_map_r( + /** @psalm-suppress MissingClosureParamType */ + function ($v) { + if ($v instanceof ArrayExportInterface) { + return $v->toArray(); + } + return $v; + }, + $data + ); + } +} diff --git a/src/VaultPHP/SecretEngines/Traits/BulkRequestTrait.php b/src/VaultPHP/SecretEngines/Traits/BulkRequestTrait.php new file mode 100644 index 0000000..ca743cf --- /dev/null +++ b/src/VaultPHP/SecretEngines/Traits/BulkRequestTrait.php @@ -0,0 +1,34 @@ +batch_input[] = $request; + } + + /** + * @param mixed[] $requests + * @return void + */ + public function addBulkRequests($requests) + { + /** @var mixed $request */ + foreach ($requests as $request) { + $this->addBulkRequest($request); + } + } +} diff --git a/src/VaultPHP/SecretEngines/Traits/NamedRequestTrait.php b/src/VaultPHP/SecretEngines/Traits/NamedRequestTrait.php new file mode 100644 index 0000000..c9de61f --- /dev/null +++ b/src/VaultPHP/SecretEngines/Traits/NamedRequestTrait.php @@ -0,0 +1,30 @@ +name; + } + + /** + * @param string $name + * @return void + */ + public function setName($name) + { + $this->name = $name; + } +} diff --git a/src/VaultPHP/VaultClient.php b/src/VaultPHP/VaultClient.php new file mode 100644 index 0000000..6231b98 --- /dev/null +++ b/src/VaultPHP/VaultClient.php @@ -0,0 +1,244 @@ +httpClient = $httpClient; + $this->apiHost = $apiHost; + + $this->authProvider = $authProvider; + $this->authProvider->setVaultClient($this); + } + + /** + * @return void + * @throws VaultAuthenticationException + */ + private function authenticate() + { + if (!$this->authenticationMetaData) { + try { + $metaData = $this->authProvider->authenticate(); + + if (!$metaData instanceof AuthenticationMetaData || !$metaData->isClientTokenPresent()) { + throw new VaultException('Client Token is missing'); + } + + $this->authenticationMetaData = $metaData; + } catch (\Exception $e) { + throw new VaultAuthenticationException( + sprintf('AuthProvider %s failed to fetch token', get_class($this->authProvider)), + 0, + $e + ); + } + } + } + + /** + * @param array|object $data + * @return string + */ + private function extractPayload($data) + { + if (is_object($data) && $data instanceof ArrayExportInterface) { + $data = $data->toArray(); + } + + return json_encode($data); + } + + /** + * @param string $method + * @param string $endpoint + * @param string $returnClass + * @param array|ResourceRequestInterface $data + * @param bool $authRequired + * @return mixed|mixed[] + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultAuthenticationException + * @throws VaultException + * @throws VaultHttpException + */ + public function sendApiRequest($method, $endpoint, $returnClass, $data = [], $authRequired = true) + { + if ($authRequired) { + $this->authenticate(); + } + + $extractedPayload = $this->extractPayload($data); + $request = new Request( + $method, + $endpoint, + [], + $extractedPayload + ); + + $response = $this->sendRequest($request); + return $this->parseResponse( + $request, + $response, + $returnClass, + $data instanceof BulkResourceRequestInterface + ); + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @param string $returnClass + * @param boolean $isBulkRequest + * @return mixed|mixed[] + * @throws InvalidDataException + * @throws InvalidRouteException + * @throws VaultAuthenticationException + * @throws VaultException + * @throws VaultResponseException + */ + private function parseResponse( + RequestInterface $request, + ResponseInterface $response, + $returnClass, + $isBulkRequest + ) + { + $status = $response->getStatusCode(); + + /** + * Looks like psalm can't handle the method exists with static functions + */ + if (!$isBulkRequest) { + /** @psalm-suppress ArgumentTypeCoercion */ + if (!$returnClass || !method_exists($returnClass, 'fromResponse')) { + throw new VaultException('Return Class declaration lacks static::fromResponse'); + } + + /** @var EndpointResponse $responseDataDTO */ + $responseDataDTO = $returnClass::fromResponse($response); + } else { + /** @psalm-suppress ArgumentTypeCoercion */ + if (!$returnClass || !method_exists($returnClass, 'fromBulkResponse')) { + throw new VaultException('Return Class declaration lacks static::fromBulkResponse'); + } + + /** @var EndpointResponse[] $responseDataDTO */ + $responseDataDTO = $returnClass::fromBulkResponse($response); + } + + if (!is_array($responseDataDTO) && !$responseDataDTO instanceof EndpointResponseInterface) { + throw new VaultException('Result from "fromResponse/fromBulkResponse" isn\'t an instance of EndpointResponse or Array'); + } + + if ($status >= 200 && $status < 300) { + return $responseDataDTO; + + } elseif ($status >= 400 && $status < 500) { + if ($status === 400) { + throw new InvalidDataException($response, $request); + } elseif ($status === 403) { + throw new VaultAuthenticationException('Authentication with provided Token failed'); + } elseif ($status === 404) { + // if 404 and no error this indicates no data for e.g. List + // makes no sense but hey - the vault rest is a magical unicorn + if (!is_array($responseDataDTO) && !$responseDataDTO->getBasicMetaResponse()->hasErrors()) { + return $responseDataDTO; + } + + // otherwise 404 and error object + // indicates a route that is not defined + throw new InvalidRouteException($response, $request); + } + } elseif ($status >= 500) { + throw new VaultResponseException($response, $request); + } + + throw new VaultException(sprintf("server responded with unhandled status code %s", $response->getStatusCode())); + } + + /** + * @param RequestInterface $request + * @return ResponseInterface + * @throws VaultException + * @throws VaultHttpException + */ + private function sendRequest(RequestInterface $request) + { + $requestWithDefaults = $this->getDefaultRequest($request); + try { + return $this->httpClient->sendRequest($requestWithDefaults); + } catch (\Exception $exception) { + throw new VaultHttpException($exception->getMessage(), 0, $exception); + } + } + + /** + * @param $request RequestInterface + * @return RequestInterface + * @throws VaultException + */ + private function getDefaultRequest(RequestInterface $request) + { + $token = $this->authenticationMetaData ? $this->authenticationMetaData->getClientToken() : ''; + $hostEndpoint = parse_url($this->apiHost); + + if (!is_array($hostEndpoint) || !isset($hostEndpoint['scheme']) || !isset($hostEndpoint['host']) || !isset($hostEndpoint['port'])) { + throw new VaultException('can\'t parse provided apiHost - malformed uri'); + } + + $uriWithHost = $request + ->getUri() + ->withScheme($hostEndpoint['scheme']) + ->withHost($hostEndpoint['host']) + ->withPort($hostEndpoint['port']); + + return $request + ->withUri($uriWithHost) + ->withAddedHeader('X-Vault-Request', '1') + ->withAddedHeader('X-Vault-Token', $token) + ->withAddedHeader('Content-Type', 'application/json'); + } +} diff --git a/tests/VaultPHP/Authentication/AuthenticationMetaDataTest.php b/tests/VaultPHP/Authentication/AuthenticationMetaDataTest.php new file mode 100644 index 0000000..6ba5166 --- /dev/null +++ b/tests/VaultPHP/Authentication/AuthenticationMetaDataTest.php @@ -0,0 +1,22 @@ +client_token = "foobar"; + + $meta = new AuthenticationMetaData($testStd); + $this->assertEquals($testStd->client_token, $meta->getClientToken()); + } +} diff --git a/tests/VaultPHP/Authentication/Provider/KubernetesTest.php b/tests/VaultPHP/Authentication/Provider/KubernetesTest.php new file mode 100644 index 0000000..0a7dc03 --- /dev/null +++ b/tests/VaultPHP/Authentication/Provider/KubernetesTest.php @@ -0,0 +1,71 @@ + [ + 'client_token' => 'fooToken', + ], + ])); + $returnResponseClass = EndpointResponse::fromResponse($apiResponse); + + $clientMock = $this->createMock(VaultClient::class); + $clientMock + ->expects($this->once()) + ->method('sendApiRequest') + ->with('POST', '/v1/auth/kubernetes/login', EndpointResponse::class, ['role' => 'foo', 'jwt' => 'bar'], false) + ->willReturn($returnResponseClass); + + $kubernetesAuth = new Kubernetes('foo', 'bar'); + $kubernetesAuth->setVaultClient($clientMock); + + $tokenMeta = $kubernetesAuth->authenticate(); + + $this->assertInstanceOf(AuthenticationMetaData::class, $tokenMeta); + $this->assertEquals('fooToken', $tokenMeta->getClientToken()); + } + + public function testWillReturnNothingWhenTokenReceiveFails() + { + $apiResponse = new Response(200, [], json_encode([])); + $returnResponseClass = EndpointResponse::fromResponse($apiResponse); + + $clientMock = $this->createMock(VaultClient::class); + $clientMock + ->expects($this->once()) + ->method('sendApiRequest') + ->willReturn($returnResponseClass); + + $userPasswordAuth = new Kubernetes('foo', 'bar'); + $userPasswordAuth->setVaultClient($clientMock); + + $tokenMeta = $userPasswordAuth->authenticate(); + + $this->assertFalse( $tokenMeta); + } + + public function testWillThrowWhenTryingToGetRequestClientBeforeInit() + { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('Trying to request the VaultClient before initialization'); + + $auth = new Kubernetes('foo', 'bar'); + $auth->getVaultClient(); + } +} diff --git a/tests/VaultPHP/Authentication/Provider/TokenTest.php b/tests/VaultPHP/Authentication/Provider/TokenTest.php new file mode 100644 index 0000000..1799867 --- /dev/null +++ b/tests/VaultPHP/Authentication/Provider/TokenTest.php @@ -0,0 +1,23 @@ +authenticate(); + + $this->assertInstanceOf(AuthenticationMetaData::class, $tokenMeta); + $this->assertEquals('foobar', $tokenMeta->getClientToken()); + } +} diff --git a/tests/VaultPHP/Authentication/Provider/UserPasswordTest.php b/tests/VaultPHP/Authentication/Provider/UserPasswordTest.php new file mode 100644 index 0000000..71eee00 --- /dev/null +++ b/tests/VaultPHP/Authentication/Provider/UserPasswordTest.php @@ -0,0 +1,71 @@ + [ + 'client_token' => 'fooToken', + ], + ])); + $returnResponseClass = EndpointResponse::fromResponse($apiResponse); + + $clientMock = $this->createMock(VaultClient::class); + $clientMock + ->expects($this->once()) + ->method('sendApiRequest') + ->with('POST', '/v1/auth/userpass/login/foo', EndpointResponse::class, ['password' => 'bar'], false) + ->willReturn($returnResponseClass); + + $userPasswordAuth = new UserPassword('foo', 'bar'); + $userPasswordAuth->setVaultClient($clientMock); + + $tokenMeta = $userPasswordAuth->authenticate(); + + $this->assertInstanceOf(AuthenticationMetaData::class, $tokenMeta); + $this->assertEquals('fooToken', $tokenMeta->getClientToken()); + } + + public function testWillReturnNothingWhenTokenReceiveFails() + { + $apiResponse = new Response(200, [], json_encode([])); + $returnResponseClass = EndpointResponse::fromResponse($apiResponse); + + $clientMock = $this->createMock(VaultClient::class); + $clientMock + ->expects($this->once()) + ->method('sendApiRequest') + ->willReturn($returnResponseClass); + + $userPasswordAuth = new UserPassword('foo', 'bar'); + $userPasswordAuth->setVaultClient($clientMock); + + $tokenMeta = $userPasswordAuth->authenticate(); + + $this->assertFalse($tokenMeta); + } + + public function testWillThrowWhenTryingToGetRequestClientBeforeInit() + { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('Trying to request the VaultClient before initialization'); + + $auth = new UserPassword('foo', 'bar'); + $auth->getVaultClient(); + } +} diff --git a/tests/VaultPHP/Mocks/EndpointResponseMock.php b/tests/VaultPHP/Mocks/EndpointResponseMock.php new file mode 100644 index 0000000..1c1c578 --- /dev/null +++ b/tests/VaultPHP/Mocks/EndpointResponseMock.php @@ -0,0 +1,23 @@ +getName(); + }, $reflectionClass->getProperties()); + + return array_combine( + $classPropertyNames, + array_map('md5', $classPropertyNames) + ); + } + + private function checkDtoData($testData, $basicMetaData) + { + $this->assertEquals($testData['errors'], $basicMetaData->getErrors()); + $this->assertEquals(false, $basicMetaData->hasErrors()); + $this->assertEquals($testData['lease_duration'], $basicMetaData->getLeaseDuration()); + $this->assertEquals($testData['auth'], $basicMetaData->getAuth()); + $this->assertEquals($testData['lease_id'], $basicMetaData->getLeaseId()); + $this->assertEquals($testData['renewable'], $basicMetaData->getRenewable()); + $this->assertEquals($testData['request_id'], $basicMetaData->getRequestId()); + $this->assertEquals($testData['warnings'], $basicMetaData->getWarnings()); + $this->assertEquals($testData['wrap_info'], $basicMetaData->getWrapInfo()); + } + + public function testCanPopulateArrayDataToSelf() + { + $testData = $this->createTestData(); + $basicMetaData = new BasicMetaResponse((array)$testData); + $this->checkDtoData($testData, $basicMetaData); + } + + public function testCanPopulateObjectDataToSelf() + { + $testData = $this->createTestData(); + $basicMetaData = new BasicMetaResponse((object)$testData); + $this->checkDtoData($testData, $basicMetaData); + } + + public function testCheckForErrors() + { + $error = ["foo"]; + $basicMetaData = new BasicMetaResponse(['errors' => $error]); + + $this->assertTrue($basicMetaData->hasErrors()); + $this->assertEquals($error, $basicMetaData->getErrors()); + + $basicMetaData = new BasicMetaResponse(['errors' => []]); + + $this->assertFalse($basicMetaData->hasErrors()); + $this->assertEquals([], $basicMetaData->getErrors()); + } +} diff --git a/tests/VaultPHP/Response/EndpointResponseTest.php b/tests/VaultPHP/Response/EndpointResponseTest.php new file mode 100644 index 0000000..158e1f8 --- /dev/null +++ b/tests/VaultPHP/Response/EndpointResponseTest.php @@ -0,0 +1,131 @@ + [ + 'metaDataError', + 'metaDataError2', + ], + ])); + $endpointResponse = EndpointResponse::fromResponse($response); + $basicMeta = $endpointResponse->getBasicMetaResponse(); + + $this->assertInstanceOf(EndpointResponse::class, $endpointResponse); + $this->assertInstanceOf(BasicMetaResponse::class, $basicMeta); + $this->assertEquals( + ['metaDataError', 'metaDataError2'], + $basicMeta->getErrors() + ); + } + + public function testCanGetPopulatePayloadDataFromResponse() + { + $response = new Response(200, [], json_encode([ + 'data' => [ + 'plaintext' => base64_encode('fooPlaintext'), + ], + ])); + $endpointResponse = DecryptDataResponse::fromResponse($response); + + $this->assertInstanceOf(EndpointResponse::class, $endpointResponse); + $this->assertEquals('fooPlaintext', $endpointResponse->getPlaintext()); + } + + public function testCanGetPopulateMetaDataFromBulkResponse() + { + $response = new Response(200, [], json_encode([ + 'errors' => [ + 'metaDataError', + 'metaDataError2', + ], + 'data' => [ + 'batch_results' => [ + [], + [], + ], + ], + ])); + $arrayEndpointResponse = EndpointResponse::fromBulkResponse($response); + $this->assertSame(2, count($arrayEndpointResponse)); + + foreach($arrayEndpointResponse as $response) { + $basicMeta = $response->getBasicMetaResponse(); + + $this->assertInstanceOf(EndpointResponse::class, $response); + $this->assertInstanceOf(BasicMetaResponse::class, $basicMeta); + $this->assertEquals( + ['metaDataError', 'metaDataError2'], + $basicMeta->getErrors() + ); + } + } + + public function testBulkErrorsWillBeMergedInMetaDataErrors() + { + $batchErrors = [ + ['error' => 'OH NO'], + ['error' => 'WHHAAT'], + [], + ]; + + $response = new Response(200, [], json_encode([ + 'errors' => [ + 'metaDataError', + 'metaDataError2', + ], + 'data' => [ + 'batch_results' => $batchErrors, + ], + ])); + + $arrayEndpointResponse = EndpointResponse::fromBulkResponse($response); + foreach($arrayEndpointResponse as $response) { + $basicMeta = $response->getBasicMetaResponse(); + + $this->assertEquals( + array_merge( + ['metaDataError', 'metaDataError2'], + array_values(current($batchErrors)) + ), + $basicMeta->getErrors() + ); + next($batchErrors); + } + } + + public function testBulkPayloadWillBePopulatedToResponseClass() + { + $batchResponse = [ + ['plaintext' => base64_encode('OH NO')], + ['plaintext' => base64_encode('WHHAAT')], + ]; + + $response = new Response(200, [], json_encode([ + 'data' => [ + 'batch_results' => $batchResponse, + ], + ])); + + $arrayEndpointResponse = DecryptDataResponse::fromBulkResponse($response); + foreach($arrayEndpointResponse as $bulkResponse) { + $expected = array_map('base64_decode', current($batchResponse)); + $this->assertEquals(current($expected), $bulkResponse->getPlaintext()); + next($batchResponse); + } + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/CreateKeyTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/CreateKeyTest.php new file mode 100644 index 0000000..208af2d --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/CreateKeyTest.php @@ -0,0 +1,37 @@ +setType(EncryptionType::CHA_CHA_20_POLY_1305); + + $client = $this->createApiClient( + 'POST', + '/v1/transit/keys/foobar', + $createKey->toArray(), + [] + ); + + $api = new Transit($client); + $response = $api->createKey($createKey); + + $this->assertInstanceOf(CreateKeyResponse::class, $response); + + $this->assertEquals('foobar', $createKey->getName()); + $this->assertEquals(EncryptionType::CHA_CHA_20_POLY_1305, $createKey->getType()); + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataBulkTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataBulkTest.php new file mode 100644 index 0000000..fdcca99 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataBulkTest.php @@ -0,0 +1,55 @@ +createApiClient( + 'POST', + '/v1/transit/decrypt/foobar', + $decryptDataRequest->toArray(), + [ + 'data' => [ + 'batch_results' => [ + ['plaintext' => base64_encode('plain')], + ['plaintext' => base64_encode('plain2')], + ] + ] + ] + ); + + $api = new Transit($client); + $response = $api->decryptDataBulk($decryptDataRequest); + + $this->assertEquals(count($response), 2); + + /** @var DecryptDataResponse $bulkResponseOne */ + $bulkResponseOne = $response[0]; + $this->assertEquals('plain', $bulkResponseOne->getPlaintext()); + + /** @var DecryptDataResponse $bulkResponseTwo */ + $bulkResponseTwo = $response[1]; + $this->assertEquals('plain2', $bulkResponseTwo->getPlaintext()); + + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataTest.php new file mode 100644 index 0000000..524e4be --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/DecryptDataTest.php @@ -0,0 +1,44 @@ +setNonce('fooNonce'); + $decryptDataRequest->setContext('fooContext'); + + $client = $this->createApiClient( + 'POST', + '/v1/transit/decrypt/fooName', + $decryptDataRequest->toArray(), + [ + 'data' => [ + 'plaintext' => base64_encode('fooBar'), + ] + ] + ); + + $api = new Transit($client); + $response = $api->decryptData($decryptDataRequest); + + $this->assertInstanceOf(DecryptDataResponse::class, $response); + $this->assertEquals('fooBar', $response->getPlaintext()); + + $this->assertEquals('fooName', $decryptDataRequest->getName()); + $this->assertEquals('fooContext', $decryptDataRequest->getContext()); + $this->assertEquals('fooNonce', $decryptDataRequest->getNonce()); + $this->assertEquals('fooCipher', $decryptDataRequest->getCiphertext()); + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/DeleteKeyTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/DeleteKeyTest.php new file mode 100644 index 0000000..be68a45 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/DeleteKeyTest.php @@ -0,0 +1,28 @@ +createApiClient( + 'DELETE', + '/v1/transit/keys/foobar', + [], + [] + ); + + $api = new Transit($client); + $response = $api->deleteKey('foobar'); + $this->assertInstanceOf(DeleteKeyResponse::class, $response); + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataBulkTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataBulkTest.php new file mode 100644 index 0000000..9630b69 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataBulkTest.php @@ -0,0 +1,55 @@ +createApiClient( + 'POST', + '/v1/transit/encrypt/foobar', + $encryptRequest->toArray(), + [ + 'data' => [ + 'batch_results' => [ + ['ciphertext' => 'foo1'], + ['ciphertext' => 'foo2'], + ] + ] + ] + ); + + $api = new Transit($client); + $response = $api->encryptDataBulk($encryptRequest); + + $this->assertEquals(count($response), 2); + + /** @var EncryptDataResponse $bulkResponseOne */ + $bulkResponseOne = $response[0]; + $this->assertEquals('foo1', $bulkResponseOne->getCiphertext()); + + /** @var EncryptDataResponse $bulkResponseTwo */ + $bulkResponseTwo = $response[1]; + $this->assertEquals('foo2', $bulkResponseTwo->getCiphertext()); + + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataTest.php new file mode 100644 index 0000000..7541650 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/EncryptDataTest.php @@ -0,0 +1,47 @@ +setContext('fooContext'); + $encryptDataRequest->setNonce('fooNonce'); + + $client = $this->createApiClient( + 'POST', + '/v1/transit/encrypt/foobar', + $encryptDataRequest->toArray(), + [ + 'data' => [ + 'ciphertext' => 'fooCipher' + ] + ] + ); + + $api = new Transit($client); + + $response = $api->encryptData($encryptDataRequest); + $this->assertInstanceOf(EncryptDataResponse::class, $response); + $this->assertEquals('fooCipher', $response->getCiphertext()); + + $this->assertEquals('foobar', $encryptDataRequest->getName()); + $this->assertEquals('fooNonce', $encryptDataRequest->getNonce()); + $this->assertEquals('fooContext', $encryptDataRequest->getContext()); + $this->assertEquals(base64_encode('encryptMe'), $encryptDataRequest->getPlaintext()); + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/ListKeyTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/ListKeyTest.php new file mode 100644 index 0000000..e3f9951 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/ListKeyTest.php @@ -0,0 +1,51 @@ +createApiClient( + 'LIST', + '/v1/transit/keys', + [], + [ + 'data' => [ + 'keys' => [ + 'key1', + 'key2', + ] + ] + ] + ); + $api = new Transit($client); + $response = $api->listKeys(); + + $this->assertInstanceOf(ListKeysResponse::class, $response); + $this->assertEquals(['key1', 'key2'], $response->getKeys()); + } + + public function testListKeyRequestHasNoData() + { + $client = $this->createApiClient( + 'LIST', + '/v1/transit/keys', + [], + [], + 404 + ); + $api = new Transit($client); + $response = $api->listKeys(); + + $this->assertInstanceOf(ListKeysResponse::class, $response); + } +} diff --git a/tests/VaultPHP/SecretEngines/Engines/Transit/UpdateKeyConfigTest.php b/tests/VaultPHP/SecretEngines/Engines/Transit/UpdateKeyConfigTest.php new file mode 100644 index 0000000..3c70220 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Engines/Transit/UpdateKeyConfigTest.php @@ -0,0 +1,51 @@ +setDeletionAllowed(true); + $request->setExportable(true); + $request->setAllowPlaintextBackup(true); + $request->setMinDecryptionVersion(1337); + $request->setMinEncryptionVersion(1338); + + $client = $this->createApiClient( + 'POST', + '/v1/transit/keys/foo/config', + $request->toArray(), + [ + 'data' => [ + 'keys' => [ + 'key1', + 'key2', + ] + ] + ] + ); + + $api = new Transit($client); + + $response = $api->updateKeyConfig($request); + $this->assertInstanceOf(UpdateKeyConfigResponse::class, $response); + + $this->assertEquals('foo', $request->getName()); + $this->assertTrue($request->getDeletionAllowed()); + $this->assertTrue($request->getExportable()); + $this->assertTrue($request->getAllowPlaintextBackup()); + $this->assertEquals(1337, $request->getMinDecryptionVersion()); + $this->assertEquals(1338, $request->getMinEncryptionVersion()); + } +} diff --git a/tests/VaultPHP/SecretEngines/SecretEngineTest.php b/tests/VaultPHP/SecretEngines/SecretEngineTest.php new file mode 100644 index 0000000..b33fd27 --- /dev/null +++ b/tests/VaultPHP/SecretEngines/SecretEngineTest.php @@ -0,0 +1,34 @@ +createMock(HttpClient::class); + $httpMock + ->expects($this->once()) + ->method('sendRequest') + ->with($this->callback(function(RequestInterface $request) use ($expectedMethod, $expectedPath, $expectedData) { + $this->assertEquals($request->getMethod(), $expectedMethod); + $this->assertEquals($request->getUri()->getPath(), $expectedPath); + $this->assertEquals($request->getBody()->getContents(), json_encode($expectedData)); + return true; + })) + ->willReturn(new Response($responseStatus, [], json_encode($responseData))); + + return new VaultClient($httpMock, new Token('foo'), 'http://iDontCare.de:443'); + } +} diff --git a/tests/VaultPHP/SecretEngines/Traits/TraitTest.php b/tests/VaultPHP/SecretEngines/Traits/TraitTest.php new file mode 100644 index 0000000..b519cbd --- /dev/null +++ b/tests/VaultPHP/SecretEngines/Traits/TraitTest.php @@ -0,0 +1,73 @@ +setType(EncryptionType::RSA_2048); + + $this->assertInstanceOf(ArrayExportInterface::class, $request); + + $expectedArray = [ + 'type' => 'rsa-2048', + 'name' => 'fooTest', + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } + + public function testNestedArrayExtractionFromRequest() + { + $request = new DecryptDataBulkRequest('fooTest'); + $request->addBulkRequests([ + new DecryptData('foo', 'fooContext', 'fooNonce'), + new DecryptData('foo2', 'fooContext2', 'fooNonce2'), + ]); + + $request->addBulkRequest( + new DecryptData('foo3', 'fooContext3', 'fooNonce3') + ); + + $this->assertInstanceOf(ArrayExportInterface::class, $request); + $this->assertInstanceOf(BulkResourceRequestInterface::class, $request); + $this->assertInstanceOf(NamedRequestInterface::class, $request); + + $expectedArray = [ + 'name' => 'fooTest', + 'batch_input' => [ + [ + 'ciphertext' => 'foo', + 'context' => 'fooContext', + 'nonce' => 'fooNonce', + ], + [ + 'ciphertext' => 'foo2', + 'context' => 'fooContext2', + 'nonce' => 'fooNonce2', + ], + [ + 'ciphertext' => 'foo3', + 'context' => 'fooContext3', + 'nonce' => 'fooNonce3', + ], + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + $this->assertEquals('fooTest', $request->getName()); + } +} diff --git a/tests/VaultPHP/VaultClientTest.php b/tests/VaultPHP/VaultClientTest.php new file mode 100644 index 0000000..9d164a6 --- /dev/null +++ b/tests/VaultPHP/VaultClientTest.php @@ -0,0 +1,289 @@ +createMock(HttpClient::class); + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + + $this->assertSame($client, $auth->getVaultClient()); + } + + public function testRequestWillExtendedWithDefaultVars() { + $auth = new Token('fooToken'); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->with($this->callback(function(RequestInterface $requestWithDefaults) { + // test if values from last request are preserved + $this->assertEquals('LOL', $requestWithDefaults->getMethod()); + $this->assertEquals('/i/should/be/preserved', $requestWithDefaults->getUri()->getPath()); + $this->assertEquals(json_encode(['dontReplaceMe']), $requestWithDefaults->getBody()->getContents()); + + // test default values that should be added + $this->assertEquals('http', $requestWithDefaults->getUri()->getScheme()); + $this->assertEquals('foo.bar', $requestWithDefaults->getUri()->getHost()); + $this->assertEquals(1337, $requestWithDefaults->getUri()->getPort()); + + $this->assertSame('1', $requestWithDefaults->getHeader('X-Vault-Request')[0]); + $this->assertSame('fooToken', $requestWithDefaults->getHeader('X-Vault-Token')[0]); + + return true; + })) + ->willReturn(new Response()); + + $client = new VaultClient($httpClient, $auth, "http://foo.bar:1337"); + $client->sendApiRequest('LOL', '/i/should/be/preserved', EndpointResponse::class, ['dontReplaceMe']); + } + + public function testWillThrowWhenApiHostMalformed() { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('can\'t parse provided apiHost - malformed uri'); + + $auth = new Token('fooToken'); + $httpClient = $this->createMock(HttpClient::class); + + $client = new VaultClient($httpClient, $auth, "imInvalidHost"); + $client->sendApiRequest('LOL', '/i/should/be/preserved', EndpointResponse::class, ['dontReplaceMe']); + } + + public function testAuthenticateWillThrowWhenNoTokenIsReturned() { + $this->expectException(VaultAuthenticationException::class); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willReturn(new Response(403)); + + $auth = new Token("fooBar"); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', EndpointResponse::class); + } + + public function testWillThrowWhenAPIReturns403() { + $this->expectException(VaultAuthenticationException::class); + + $httpClient = $this->createMock(HttpClient::class); + $auth = $this->createMock(AuthenticationProviderInterface::class); + $auth + ->expects($this->once()) + ->method('authenticate') + ->willReturn(null); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', EndpointResponse::class); + } + + public function testSendRequest() { + $request = new Request('GET', 'foo'); + $response = new Response(); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->with($this->callback(function ($request) { + $this->assertInstanceOf(RequestInterface::class, $request); + return true; + })) + ->willReturn($response); + + $auth = $this->createMock(AuthenticationProviderInterface::class); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', EndpointResponse::class, [], false); + } + + public function testSendRequestWillThrow() { + $this->expectException(VaultHttpException::class); + $this->expectExceptionMessage('foobarMessage'); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willThrowException(new \Exception('foobarMessage')); + + $auth = $this->createMock(AuthenticationProviderInterface::class); + $auth->expects($this->once()) + ->method('authenticate') + ->willReturn(new AuthenticationMetaData((object) [ + 'client_token' => 'foo', + ])); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', EndpointResponse::class); + } + + public function testWillThrowWhenReturnClassDeclarationIsInvalid() { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('Return Class declaration lacks static::fromResponse'); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willReturn(new Response(200)); + + $auth = $this->createMock(AuthenticationProviderInterface::class); + $auth->expects($this->once()) + ->method('authenticate') + ->willReturn(new AuthenticationMetaData((object) [ + 'client_token' => 'foo', + ])); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', [], BasicMetaResponse::class); + } + + public function testWillThrowWhenReturnClassDeclarationIsInvalidForBulk() { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('Return Class declaration lacks static::fromBulkResponse'); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willReturn(new Response(200)); + + $auth = $this->createMock(AuthenticationProviderInterface::class); + $auth->expects($this->once()) + ->method('authenticate') + ->willReturn(new AuthenticationMetaData((object) [ + 'client_token' => 'foo', + ])); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', BasicMetaResponse::class, new EncryptDataBulkRequest('foo')); + } + + public function testWillThrowWhenResultOfReturnClassDeclarationIsInvalid() { + $this->expectException(VaultException::class); + $this->expectExceptionMessage('Result from "fromResponse/fromBulkResponse" isn\'t an instance of EndpointResponse'); + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willReturn(new Response(200)); + + $auth = $this->createMock(AuthenticationProviderInterface::class); + $auth->expects($this->once()) + ->method('authenticate') + ->willReturn(new AuthenticationMetaData((object) [ + 'client_token' => 'foo', + ])); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + $client->sendApiRequest('GET', '/foo', EndpointResponseMock::class, []); + } + + private function simulateApiResponse($responseStatus, $responseBody = '', $responseHeader = []) { + $response = new Response($responseStatus, $responseHeader, $responseBody); + $auth = new Token('fooToken'); + + $httpClient = $this->createMock(HttpClient::class); + + $httpClient + ->expects($this->once()) + ->method('sendRequest') + ->willReturn($response); + + $client = new VaultClient($httpClient, $auth, TEST_VAULT_ENDPOINT); + return $client->sendApiRequest('GET', '/foo', EndpointResponse::class); + } + + public function testSuccessApiResponse() { + $response = $this->simulateApiResponse(200, ''); + $this->assertInstanceOf(EndpointResponse::class, $response); + } + + public function testInvalidDataResponse() { + $this->expectException(InvalidDataException::class); + $this->expectExceptionMessage('looks malformed'); + + $this->simulateApiResponse(400, json_encode([ + 'errors' => [ + 'looks malformed', + ] + ])); + } + + public function testInvalidDataResponseWillConcatErrorMessages() { + $this->expectException(InvalidDataException::class); + $this->expectExceptionMessage('looks malformed, oh no'); + + $this->simulateApiResponse(400, json_encode([ + 'errors' => [ + 'looks malformed', + 'oh no' + ] + ])); + } + + public function testInvalidRouteResponse() { + $this->expectException(InvalidRouteException::class); + $this->simulateApiResponse(404, json_encode([ + 'errors' => [ + 'no handler', + ] + ])); + } + + public function testEmptyResponse() { + $response = $this->simulateApiResponse(404); + $this->assertInstanceOf(EndpointResponse::class, $response); + } + + public function testServerErrorResponse() { + $this->expectException(VaultResponseException::class); + $this->simulateApiResponse(500); + } + + public function testNotHandledStatusCodeResponse() { + $this->expectException(VaultException::class); + $this->simulateApiResponse(100); + } + + public function testResponseExceptionHasRequestResponseMeta() { + try { + $this->simulateApiResponse(555); + } catch (VaultResponseException $e) { + $this->assertInstanceOf(RequestInterface::class, $e->getRequest()); + $this->assertInstanceOf(ResponseInterface::class, $e->getResponse()); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e5d91aa --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ += 70400) { + error_reporting(E_ALL ^ E_DEPRECATED); +}