diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..fc360e5 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing + +This repository is a sub repository of [the JWT Framework](https://github.com/web-token/jwt-framework) project and is READ ONLY. +Please do not submit any Pull Requests here. It will be automatically closed. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d4ff96c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +Please do not submit any Pull Requests here. It will be automatically closed. + +You should submit it here: https://github.com/web-token/jwt-framework/pulls diff --git a/ECDHES.php b/ECDHES.php new file mode 100644 index 0000000..eddcaa3 --- /dev/null +++ b/ECDHES.php @@ -0,0 +1,390 @@ +has('d')) { + list($public_key, $private_key) = $this->getKeysFromPrivateKeyAndHeader($recipient_key, $complete_header); + } else { + list($public_key, $private_key) = $this->getKeysFromPublicKey($recipient_key, $additional_header_values); + } + + $agreed_key = $this->calculateAgreementKey($private_key, $public_key); + + $apu = array_key_exists('apu', $complete_header) ? $complete_header['apu'] : ''; + $apv = array_key_exists('apv', $complete_header) ? $complete_header['apv'] : ''; + + return ConcatKDF::generate($agreed_key, $algorithm, $encryption_key_length, $apu, $apv); + } + + /** + * @param JWK $recipient_key + * @param array $additional_header_values + * + * @return JWK[] + */ + private function getKeysFromPublicKey(JWK $recipient_key, array &$additional_header_values): array + { + $this->checkKey($recipient_key, false); + $public_key = $recipient_key; + switch ($public_key->get('crv')) { + case 'P-256': + case 'P-384': + case 'P-521': + $private_key = $this->createECKey($public_key->get('crv')); + + break; + case 'X25519': + $private_key = $this->createOKPKey('X25519'); + + break; + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported', $public_key->get('crv'))); + } + $epk = $private_key->toPublic()->all(); + $additional_header_values['epk'] = $epk; + + return [$public_key, $private_key]; + } + + /** + * @param JWK $recipient_key + * @param array $complete_header + * + * @return JWK[] + */ + private function getKeysFromPrivateKeyAndHeader(JWK $recipient_key, array $complete_header): array + { + $this->checkKey($recipient_key, true); + $private_key = $recipient_key; + $public_key = $this->getPublicKey($complete_header); + if ($private_key->get('crv') !== $public_key->get('crv')) { + throw new \InvalidArgumentException('Curves are different'); + } + + return [$public_key, $private_key]; + } + + /** + * @param JWK $private_key + * @param JWK $public_key + * + * @throws \InvalidArgumentException + * + * @return string + */ + public function calculateAgreementKey(JWK $private_key, JWK $public_key): string + { + switch ($public_key->get('crv')) { + case 'P-256': + case 'P-384': + case 'P-521': + $curve = $this->getCurve($public_key->get('crv')); + + $rec_x = $this->convertBase64ToGmp($public_key->get('x')); + $rec_y = $this->convertBase64ToGmp($public_key->get('y')); + $sen_d = $this->convertBase64ToGmp($private_key->get('d')); + + $priv_key = PrivateKey::create($sen_d); + $pub_key = $curve->getPublicKeyFrom($rec_x, $rec_y); + + return $this->convertDecToBin(EcDH::computeSharedKey($curve, $pub_key, $priv_key)); + case 'X25519': + $sKey = Base64Url::decode($private_key->get('d')); + $recipientPublickey = Base64Url::decode($public_key->get('x')); + + return sodium_crypto_scalarmult($sKey, $recipientPublickey); + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported', $public_key->get('crv'))); + } + } + + /** + * {@inheritdoc} + */ + public function name(): string + { + return 'ECDH-ES'; + } + + /** + * {@inheritdoc} + */ + public function getKeyManagementMode(): string + { + return self::MODE_AGREEMENT; + } + + /** + * @param array $complete_header + * + * @return JWK + */ + private function getPublicKey(array $complete_header) + { + if (!array_key_exists('epk', $complete_header)) { + throw new \InvalidArgumentException('The header parameter "epk" is missing'); + } + if (!is_array($complete_header['epk'])) { + throw new \InvalidArgumentException('The header parameter "epk" is not an array of parameter'); + } + + $public_key = JWK::create($complete_header['epk']); + $this->checkKey($public_key, false); + + return $public_key; + } + + /** + * @param JWK $key + * @param bool $is_private + */ + private function checkKey(JWK $key, $is_private) + { + if (!in_array($key->get('kty'), $this->allowedKeyTypes())) { + throw new \InvalidArgumentException('Wrong key type.'); + } + foreach (['x', 'crv'] as $k) { + if (!$key->has($k)) { + throw new \InvalidArgumentException(sprintf('The key parameter "%s" is missing.', $k)); + } + } + + switch ($key->get('crv')) { + case 'P-256': + case 'P-384': + case 'P-521': + if (!$key->has('y')) { + throw new \InvalidArgumentException('The key parameter "y" is missing.'); + } + + break; + case 'X25519': + break; + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported', $key->get('crv'))); + } + if (true === $is_private) { + if (!$key->has('d')) { + throw new \InvalidArgumentException('The key parameter "d" is missing.'); + } + } + } + + /** + * @param string $crv + * + * @throws \InvalidArgumentException + * + * @return Curve + */ + private function getCurve(string $crv): Curve + { + switch ($crv) { + case 'P-256': + return NistCurve::curve256(); + case 'P-384': + return NistCurve::curve384(); + case 'P-521': + return NistCurve::curve521(); + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported', $crv)); + } + } + + /** + * @param string $value + * + * @return \GMP + */ + private function convertBase64ToGmp(string $value): \GMP + { + $value = unpack('H*', Base64Url::decode($value)); + + return gmp_init($value[1], 16); + } + + /** + * @param \GMP $dec + * + * @return string + */ + private function convertDecToBin(\GMP $dec): string + { + if (gmp_cmp($dec, 0) < 0) { + throw new \InvalidArgumentException('Unable to convert negative integer to string'); + } + + $hex = gmp_strval($dec, 16); + + if (0 !== mb_strlen($hex, '8bit') % 2) { + $hex = '0'.$hex; + } + + return hex2bin($hex); + } + + /** + * @param string $crv The curve + * + * @return JWK + */ + public function createECKey(string $crv): JWK + { + try { + $jwk = self::createECKeyUsingOpenSSL($crv); + } catch (\Exception $e) { + $jwk = self::createECKeyUsingPurePhp($crv); + } + + return JWK::create($jwk); + } + + /** + * @param string $curve The curve + * + * @return JWK + */ + public static function createOKPKey(string $curve): JWK + { + switch ($curve) { + case 'X25519': + $keyPair = sodium_crypto_box_keypair(); + $d = sodium_crypto_box_secretkey($keyPair); + $x = sodium_crypto_box_publickey($keyPair); + + break; + case 'Ed25519': + $keyPair = sodium_crypto_sign_keypair(); + $d = sodium_crypto_sign_secretkey($keyPair); + $x = sodium_crypto_sign_publickey($keyPair); + + break; + default: + throw new \InvalidArgumentException(sprintf('Unsupported "%s" curve', $curve)); + } + + return JWK::create([ + 'kty' => 'OKP', + 'crv' => $curve, + 'x' => Base64Url::encode($x), + 'd' => Base64Url::encode($d), + ]); + } + + /** + * @param string $curve + * + * @return array + */ + private static function createECKeyUsingPurePhp(string $curve): array + { + switch ($curve) { + case 'P-256': + $nistCurve = NistCurve::curve256(); + + break; + case 'P-384': + $nistCurve = NistCurve::curve384(); + + break; + case 'P-521': + $nistCurve = NistCurve::curve521(); + + break; + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported.', $curve)); + } + + $privateKey = $nistCurve->createPrivateKey(); + $publicKey = $nistCurve->createPublicKey($privateKey); + + return [ + 'kty' => 'EC', + 'crv' => $curve, + 'd' => Base64Url::encode(gmp_export($privateKey->getSecret())), + 'x' => Base64Url::encode(gmp_export($publicKey->getPoint()->getX())), + 'y' => Base64Url::encode(gmp_export($publicKey->getPoint()->getY())), + ]; + } + + /** + * @param string $curve + * + * @return array + */ + private static function createECKeyUsingOpenSSL(string $curve): array + { + $key = openssl_pkey_new([ + 'curve_name' => self::getOpensslCurveName($curve), + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + $res = openssl_pkey_export($key, $out); + if (false === $res) { + throw new \RuntimeException('Unable to create the key'); + } + $res = openssl_pkey_get_private($out); + + $details = openssl_pkey_get_details($res); + + return [ + 'kty' => 'EC', + 'crv' => $curve, + 'd' => Base64Url::encode($details['ec']['d']), + 'x' => Base64Url::encode($details['ec']['x']), + 'y' => Base64Url::encode($details['ec']['y']), + ]; + } + + /** + * @param string $curve + * + * @return string + */ + private static function getOpensslCurveName(string $curve): string + { + switch ($curve) { + case 'P-256': + return 'prime256v1'; + case 'P-384': + return 'secp384r1'; + case 'P-521': + return 'secp521r1'; + default: + throw new \InvalidArgumentException(sprintf('The curve "%s" is not supported.', $curve)); + } + } +} diff --git a/ECDHESA128KW.php b/ECDHESA128KW.php new file mode 100644 index 0000000..6f698ad --- /dev/null +++ b/ECDHESA128KW.php @@ -0,0 +1,43 @@ +getAgreementKey($this->getKeyLength(), $this->name(), $receiver_key->toPublic(), $complete_header, $additional_header_values); + $wrapper = $this->getWrapper(); + + return $wrapper::wrap($agreement_key, $cek); + } + + /** + * {@inheritdoc} + */ + public function unwrapAgreementKey(JWK $receiver_key, string $encrypted_cek, int $encryption_key_length, array $complete_header): string + { + $ecdh_es = new ECDHES(); + $agreement_key = $ecdh_es->getAgreementKey($this->getKeyLength(), $this->name(), $receiver_key, $complete_header); + $wrapper = $this->getWrapper(); + + return $wrapper::unwrap($agreement_key, $encrypted_cek); + } + + /** + * {@inheritdoc} + */ + public function getKeyManagementMode(): string + { + return self::MODE_WRAP; + } + + /** + * @return \AESKW\A128KW|\AESKW\A192KW|\AESKW\A256KW + */ + abstract protected function getWrapper(); + + /** + * @return int + */ + abstract protected function getKeyLength(): int; +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a098645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2018 Spomky-Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca3b813 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +ECDH-ES Based Key Encryption Algorithms For JWT-Framework +========================================================= + +This repository is a sub repository of [the JWT Framework](https://github.com/web-token/jwt-framework) project and is READ ONLY. + +**Please do not submit any Pull Request here.** +You should go to [the main repository](https://github.com/web-token/jwt-framework) instead. + +# Documentation + +The official documentation is available as https://web-token.spomky-labs.com/ + +# Licence + +This software is release under [MIT licence](LICENSE). diff --git a/Tests/ECDHESKeyAgreementTest.php b/Tests/ECDHESKeyAgreementTest.php new file mode 100644 index 0000000..4491c09 --- /dev/null +++ b/Tests/ECDHESKeyAgreementTest.php @@ -0,0 +1,245 @@ + 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + ]); + + $header = [ + 'enc' => 'A128GCM', + 'apu' => 'QWxpY2U', + 'apv' => 'Qm9i', + ]; + $ecdh_es = new ECDHES(); + $additional_header_values = []; + + $ecdh_es->getAgreementKey(128, 'A128GCM', $receiver, $header, $additional_header_values); + self::assertTrue(array_key_exists('epk', $additional_header_values)); + self::assertTrue(array_key_exists('kty', $additional_header_values['epk'])); + self::assertTrue(array_key_exists('crv', $additional_header_values['epk'])); + self::assertTrue(array_key_exists('x', $additional_header_values['epk'])); + self::assertTrue(array_key_exists('y', $additional_header_values['epk'])); + } + + public function testGetAgreementKeyWithA128KeyWrap() + { + $header = ['enc' => 'A128GCM']; + + $public = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + ]); + + $private = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + 'd' => 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw', + ]); + + $cek = [4, 211, 31, 197, 84, 157, 252, 254, 11, 100, 157, 250, 63, 170, 106, 206, 107, 124, 212, 45, 111, 107, 9, 219, 200, 177, 0, 240, 143, 156, 44, 207]; + foreach ($cek as $key => $value) { + $cek[$key] = str_pad(dechex($value), 2, '0', STR_PAD_LEFT); + } + $cek = hex2bin(implode('', $cek)); + + $ecdh_es = new ECDHESA128KW(); + $encrypted_cek = $ecdh_es->wrapAgreementKey($public, $cek, 128, $header, $header); + self::assertTrue(array_key_exists('epk', $header)); + self::assertTrue(array_key_exists('crv', $header['epk'])); + self::assertTrue(array_key_exists('kty', $header['epk'])); + self::assertTrue(array_key_exists('x', $header['epk'])); + self::assertTrue(array_key_exists('y', $header['epk'])); + self::assertEquals('P-256', $header['epk']['crv']); + self::assertEquals('EC', $header['epk']['kty']); + self::assertEquals($cek, $ecdh_es->unwrapAgreementKey($private, $encrypted_cek, 128, $header)); + } + + public function testGetAgreementKeyWithA192KeyWrap() + { + $header = ['enc' => 'A192GCM']; + + $public = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + ]); + + $private = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + 'd' => 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw', + ]); + + $cek = [4, 211, 31, 197, 84, 157, 252, 254, 11, 100, 157, 250, 63, 170, 106, 206, 107, 124, 212, 45, 111, 107, 9, 219, 200, 177, 0, 240, 143, 156, 44, 207]; + foreach ($cek as $key => $value) { + $cek[$key] = str_pad(dechex($value), 2, '0', STR_PAD_LEFT); + } + $cek = hex2bin(implode('', $cek)); + + $ecdh_es = new ECDHESA192KW(); + $encrypted_cek = $ecdh_es->wrapAgreementKey($public, $cek, 192, $header, $header); + self::assertTrue(array_key_exists('epk', $header)); + self::assertTrue(array_key_exists('crv', $header['epk'])); + self::assertTrue(array_key_exists('kty', $header['epk'])); + self::assertTrue(array_key_exists('x', $header['epk'])); + self::assertTrue(array_key_exists('y', $header['epk'])); + self::assertEquals('P-256', $header['epk']['crv']); + self::assertEquals('EC', $header['epk']['kty']); + self::assertEquals($cek, $ecdh_es->unwrapAgreementKey($private, $encrypted_cek, 192, $header)); + } + + public function testGetAgreementKeyWithA256KeyWrap() + { + $header = ['enc' => 'A256GCM']; + + $public = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + ]); + + $private = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + 'd' => 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw', + ]); + + $cek = [4, 211, 31, 197, 84, 157, 252, 254, 11, 100, 157, 250, 63, 170, 106, 206, 107, 124, 212, 45, 111, 107, 9, 219, 200, 177, 0, 240, 143, 156, 44, 207]; + foreach ($cek as $key => $value) { + $cek[$key] = str_pad(dechex($value), 2, '0', STR_PAD_LEFT); + } + $cek = hex2bin(implode('', $cek)); + + $ecdh_es = new ECDHESA256KW(); + $encrypted_cek = $ecdh_es->wrapAgreementKey($public, $cek, 256, $header, $header); + self::assertTrue(array_key_exists('epk', $header)); + self::assertTrue(array_key_exists('crv', $header['epk'])); + self::assertTrue(array_key_exists('kty', $header['epk'])); + self::assertTrue(array_key_exists('x', $header['epk'])); + self::assertTrue(array_key_exists('y', $header['epk'])); + self::assertEquals('P-256', $header['epk']['crv']); + self::assertEquals('EC', $header['epk']['kty']); + self::assertEquals($cek, $ecdh_es->unwrapAgreementKey($private, $encrypted_cek, 256, $header)); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The header parameter "epk" is missing + */ + public function testEPKParameterAreMissing() + { + $sender = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', + 'y' => 'SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps', + 'd' => '0_NxaRPUMQoAJt50Gz8YiTr8gRTwyEaCumd-MToTmIo', + ]); + + $ecdh_es = new ECDHES(); + $ecdh_es->getAgreementKey(256, 'A128GCM', $sender); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The header parameter "epk" is not an array of parameter + */ + public function testBadEPKParameter() + { + $header = ['epk' => 'foo']; + $sender = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', + 'y' => 'SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps', + 'd' => '0_NxaRPUMQoAJt50Gz8YiTr8gRTwyEaCumd-MToTmIo', + ]); + + $ecdh_es = new ECDHES(); + $ecdh_es->getAgreementKey(256, 'A128GCM', $sender, $header); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The key parameter "x" is missing. + */ + public function testECKeyHasMissingParameters() + { + $receiver = JWK::create([ + 'kty' => 'EC', + 'dir' => Base64Url::encode('ABCD'), + ]); + + $ecdh_es = new ECDHES(); + $ecdh_es->getAgreementKey(256, 'A128GCM', $receiver); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage The curve "P-192" is not supported + */ + public function testUnsupportedCurve() + { + $header = [ + 'enc' => 'A128GCM', + 'apu' => 'QWxpY2U', + 'apv' => 'Qm9i', + ]; + + $receiver = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-192', + 'x' => 'm2Jmp98NRH83ramvp0VVIQJXK56ZEwuM', + 'y' => '84lz6hQtPJe9WFPPgEyOUwh3tuW2kOS_', + ]); + + $ecdh_es = new ECDHES(); + $ecdh_es->getAgreementKey(256, 'A128GCM', $receiver, $header); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3530a85 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "web-token/jwt-encryption-algorithm-ecdhes", + "description": "ECDH-ES Based Key Encryption Algorithms the JWT Framework.", + "type": "library", + "license": "MIT", + "keywords": ["JWS", "JWT", "JWE", "JWA", "JWK", "JWKSet", "Jot", "Jose", "RFC7515", "RFC7516", "RFC7517", "RFC7518", "RFC7519", "RFC7520", "Bundle", "Symfony"], + "homepage": "https://github.com/web-token", + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + },{ + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-core/contributors" + } + ], + "autoload": { + "psr-4": { + "Jose\\Component\\Encryption\\Algorithm\\KeyEncryption\\": "" + } + }, + "require": { + "web-token/jwt-encryption": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^6.0|^7.0" + }, + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c8b3143 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + ./Tests/ + + + + + + ./ + + ./vendor + ./Tests + ./src + + + +