From ea86a7004f11211c91b222a1f3b1a9349d1792a8 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 3 May 2026 15:19:29 -0400 Subject: [PATCH] Update the library to PHP 8.2. Add final readonly classes where applicable. Add enums where applicable. Update tests. Add phpstan. --- .github/workflows/analysis.yml | 20 +- .github/workflows/build.yml | 11 +- .gitignore | 1 + CHANGELOG.md | 9 + UPGRADE-1.0.md | 39 ++ composer.json | 41 +- ecs.php | 30 ++ ecs.yml | 2 - phpstan.neon | 9 + phpstan.neon.dist | 6 - phpstan.tests.neon | 5 + phpunit.xml.dist | 34 +- ruleset.xml | 17 + .../Sasl/Challenge/AnonymousChallenge.php | 37 +- .../Sasl/Challenge/ChallengeInterface.php | 10 +- .../Sasl/Challenge/CramMD5Challenge.php | 84 +-- .../Sasl/Challenge/DigestMD5Challenge.php | 154 +++--- src/FreeDSx/Sasl/Challenge/PlainChallenge.php | 53 +- src/FreeDSx/Sasl/Challenge/ScramChallenge.php | 265 +++------- src/FreeDSx/Sasl/Encoder/AnonymousEncoder.php | 26 +- src/FreeDSx/Sasl/Encoder/CramMD5Encoder.php | 60 +-- src/FreeDSx/Sasl/Encoder/DigestMD5Encoder.php | 246 ++++----- src/FreeDSx/Sasl/Encoder/EncoderInterface.php | 12 +- src/FreeDSx/Sasl/Encoder/PlainEncoder.php | 36 +- src/FreeDSx/Sasl/Encoder/ScramEncoder.php | 31 +- .../Sasl/Exception/SaslBufferException.php | 4 +- .../Sasl/Exception/SaslEncodingException.php | 4 +- src/FreeDSx/Sasl/Exception/SaslException.php | 6 +- .../Sasl/Factory/DigestMD5MessageFactory.php | 181 ++++--- .../Sasl/Factory/DigestMD5MessageType.php | 26 + .../Sasl/Factory/MessageFactoryInterface.php | 14 +- src/FreeDSx/Sasl/Factory/NonceTrait.php | 13 +- .../Sasl/Mechanism/AnonymousMechanism.php | 32 +- .../Sasl/Mechanism/CramMD5Mechanism.php | 32 +- .../Sasl/Mechanism/DigestMD5Mechanism.php | 80 ++- src/FreeDSx/Sasl/Mechanism/HashAlgorithm.php | 29 ++ .../Sasl/Mechanism/MechanismInterface.php | 4 +- src/FreeDSx/Sasl/Mechanism/MechanismName.php | 71 +++ src/FreeDSx/Sasl/Mechanism/PlainMechanism.php | 34 +- src/FreeDSx/Sasl/Mechanism/ScramMechanism.php | 129 +---- src/FreeDSx/Sasl/MechanismSelector.php | 119 +++-- src/FreeDSx/Sasl/Message.php | 45 +- src/FreeDSx/Sasl/Sasl.php | 101 ++-- src/FreeDSx/Sasl/SaslBuffer.php | 4 +- src/FreeDSx/Sasl/SaslContext.php | 106 +--- src/FreeDSx/Sasl/SaslPrep.php | 4 +- src/FreeDSx/Sasl/Security/DigestMD5Cipher.php | 59 +++ .../Sasl/Security/DigestMD5SecurityLayer.php | 284 ++++++----- .../Sasl/Security/SecurityLayerInterface.php | 12 +- src/FreeDSx/Sasl/SecurityStrength.php | 44 +- .../unit/Challenge/AnonymousChallengeTest.php | 55 ++ tests/unit/Challenge/CramMD5ChallengeTest.php | 101 ++++ .../unit/Challenge/DigestMD5ChallengeTest.php | 132 +++++ tests/unit/Challenge/PlainChallengeTest.php | 59 +++ tests/unit/Challenge/ScramChallengeTest.php | 352 +++++++++++++ .../Encoder/AnonymousEncoderTest.php | 35 +- .../Sasl => }/Encoder/CramMD5EncoderTest.php | 41 +- .../Encoder/DigestMD5EncoderTest.php | 115 ++--- .../Sasl => }/Encoder/PlainEncoderTest.php | 48 +- tests/unit/Encoder/ScramEncoderTest.php | 127 +++++ .../Factory/DigestMD5MessageFactoryTest.php | 86 ++++ .../Sasl/Challenge/AnonymousChallengeTest.php | 57 --- .../Sasl/Challenge/CramMD5ChallengeTest.php | 127 ----- .../Sasl/Challenge/DigestMD5ChallengeTest.php | 162 ------ .../Sasl/Challenge/PlainChallengeTest.php | 64 --- .../Sasl/Challenge/ScramChallengeTest.php | 481 ------------------ .../FreeDSx/Sasl/Encoder/ScramEncoderTest.php | 192 ------- .../Factory/DigestMD5MessageFactoryTest.php | 69 --- .../Sasl/Mechanism/AnonymousMechanismTest.php | 59 --- .../Sasl/Mechanism/CramMD5MechanismTest.php | 58 --- .../Sasl/Mechanism/PlainMechanismTest.php | 56 -- .../Sasl/Mechanism/ScramMechanismTest.php | 109 ---- .../FreeDSx/Sasl/MechanismSelectorTest.php | 109 ---- tests/unit/FreeDSx/Sasl/MessageTest.php | 58 --- tests/unit/FreeDSx/Sasl/SaslContextTest.php | 98 ---- tests/unit/FreeDSx/Sasl/SaslTest.php | 83 --- .../Security/DigestMD5SecurityLayerTest.php | 250 --------- .../FreeDSx/Sasl/SecurityStrengthTest.php | 57 --- .../unit/Mechanism/AnonymousMechanismTest.php | 58 +++ tests/unit/Mechanism/CramMD5MechanismTest.php | 58 +++ .../Mechanism/DigestMD5MechanismTest.php | 67 +-- tests/unit/Mechanism/PlainMechanismTest.php | 58 +++ tests/unit/Mechanism/ScramMechanismTest.php | 100 ++++ tests/unit/MechanismSelectorTest.php | 114 +++++ tests/unit/MessageTest.php | 58 +++ .../{FreeDSx/Sasl => }/SaslBufferTest.php | 23 +- tests/unit/SaslContextTest.php | 98 ++++ .../unit/{FreeDSx/Sasl => }/SaslPrepTest.php | 48 +- tests/unit/SaslTest.php | 80 +++ .../Security/DigestMD5SecurityLayerTest.php | 190 +++++++ tests/unit/SecurityStrengthTest.php | 58 +++ 91 files changed, 3349 insertions(+), 3576 deletions(-) create mode 100644 UPGRADE-1.0.md create mode 100644 ecs.php delete mode 100644 ecs.yml create mode 100644 phpstan.neon delete mode 100644 phpstan.neon.dist create mode 100644 phpstan.tests.neon create mode 100644 ruleset.xml create mode 100644 src/FreeDSx/Sasl/Factory/DigestMD5MessageType.php create mode 100644 src/FreeDSx/Sasl/Mechanism/HashAlgorithm.php create mode 100644 src/FreeDSx/Sasl/Mechanism/MechanismName.php create mode 100644 src/FreeDSx/Sasl/Security/DigestMD5Cipher.php create mode 100644 tests/unit/Challenge/AnonymousChallengeTest.php create mode 100644 tests/unit/Challenge/CramMD5ChallengeTest.php create mode 100644 tests/unit/Challenge/DigestMD5ChallengeTest.php create mode 100644 tests/unit/Challenge/PlainChallengeTest.php create mode 100644 tests/unit/Challenge/ScramChallengeTest.php rename tests/unit/{FreeDSx/Sasl => }/Encoder/AnonymousEncoderTest.php (51%) rename tests/unit/{FreeDSx/Sasl => }/Encoder/CramMD5EncoderTest.php (56%) rename tests/unit/{FreeDSx/Sasl => }/Encoder/DigestMD5EncoderTest.php (60%) rename tests/unit/{FreeDSx/Sasl => }/Encoder/PlainEncoderTest.php (58%) create mode 100644 tests/unit/Encoder/ScramEncoderTest.php create mode 100644 tests/unit/Factory/DigestMD5MessageFactoryTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Challenge/AnonymousChallengeTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Challenge/CramMD5ChallengeTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Challenge/DigestMD5ChallengeTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Challenge/PlainChallengeTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Challenge/ScramChallengeTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Encoder/ScramEncoderTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Factory/DigestMD5MessageFactoryTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Mechanism/AnonymousMechanismTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Mechanism/CramMD5MechanismTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Mechanism/PlainMechanismTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Mechanism/ScramMechanismTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/MechanismSelectorTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/MessageTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/SaslContextTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/SaslTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/Security/DigestMD5SecurityLayerTest.php delete mode 100644 tests/unit/FreeDSx/Sasl/SecurityStrengthTest.php create mode 100644 tests/unit/Mechanism/AnonymousMechanismTest.php create mode 100644 tests/unit/Mechanism/CramMD5MechanismTest.php rename tests/unit/{FreeDSx/Sasl => }/Mechanism/DigestMD5MechanismTest.php (58%) create mode 100644 tests/unit/Mechanism/PlainMechanismTest.php create mode 100644 tests/unit/Mechanism/ScramMechanismTest.php create mode 100644 tests/unit/MechanismSelectorTest.php create mode 100644 tests/unit/MessageTest.php rename tests/unit/{FreeDSx/Sasl => }/SaslBufferTest.php (57%) create mode 100644 tests/unit/SaslContextTest.php rename tests/unit/{FreeDSx/Sasl => }/SaslPrepTest.php (75%) create mode 100644 tests/unit/SaslTest.php create mode 100644 tests/unit/Security/DigestMD5SecurityLayerTest.php create mode 100644 tests/unit/SecurityStrengthTest.php diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 04a03ca..46ae5c0 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -11,12 +11,14 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' - coverage: xdebug - tools: composer:v2.2 + php-version: '8.5' + extensions: openssl, mbstring + coverage: pcov + tools: composer:2.9 - name: Get Composer Cache Directory id: composer-cache + shell: bash run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Dependencies @@ -27,16 +29,18 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Install Composer Dependencies - run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Run Static Analysis + run: | + composer run-script analyse + composer run-script analyse-tests - name: Run Test Coverage run: composer run-script test-coverage - - name: Run Static Analysis - run: composer run-script analyse - - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 509cac5..1faad23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: fail-fast: false matrix: operating-system: [ubuntu-latest, windows-latest] - php-versions: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} steps: - name: Checkout @@ -17,8 +17,9 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - coverage: xdebug - tools: composer:v2.2 + extensions: openssl, mbstring + coverage: none + tools: composer:2.9 - name: Get Composer Cache Directory id: composer-cache @@ -33,7 +34,7 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Install Composer dependencies - run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader + run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Run Unit Tests - run: composer run-script test + run: composer run-script test-unit diff --git a/.gitignore b/.gitignore index f137394..738601a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ composer.lock composer.phar vendor/ +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index f8620a1..0aee3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +1.0.0 (2026-XX-XX) +------------------ +* Raise the minimum PHP version to 8.2. +* Introduce strict types, typed properties, and `final` across classes. +* Replace mechanism-name string constants with the `MechanismName` backed enum (see UPGRADE-1.0.md). +* Replace SCRAM hash-algorithm string constants with the `HashAlgorithm` enum. +* Replace DIGEST-MD5 message-type integer constants with the `DigestMD5MessageType` enum. +* Replace DIGEST-MD5 cipher string keys with the `DigestMD5Cipher` enum. + 0.2.1 (2026-03-22) ------------------ * Allow specifying server vs client mode when creating a challenge. diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md new file mode 100644 index 0000000..b6a16c5 --- /dev/null +++ b/UPGRADE-1.0.md @@ -0,0 +1,39 @@ +Upgrading from 0.x to 1.0 +========================= + +## Mechanism names are now an enum + +The `string` constants previously defined on each mechanism class have been replaced by the `FreeDSx\Sasl\Mechanism\MechanismName` backed enum. + +| Old | New | +|------------------------------------------|-------------------------------------------------------------------| +| `AnonymousMechanism::NAME` | `MechanismName::ANONYMOUS` | +| `PlainMechanism::NAME` | `MechanismName::PLAIN` | +| `CramMD5Mechanism::NAME` | `MechanismName::CRAM_MD5` | +| `DigestMD5Mechanism::NAME` | `MechanismName::DIGEST_MD5` | +| `ScramMechanism::SHA1` … `SHA3_512_PLUS` | `MechanismName::SCRAM_SHA1` … `SCRAM_SHA3_512_PLUS` | +| `ScramMechanism::VARIANTS` | `MechanismName::cases()` filtered with `MechanismName::isScram()` | + +## `Sasl` registry takes enum values + +```php +// before +$sasl->get('DIGEST-MD5'); +$sasl->supports('SCRAM-SHA-256'); +$sasl->remove('PLAIN'); +$sasl->select(['SCRAM-SHA-256', 'PLAIN']); +new Sasl(['supported' => ['DIGEST-MD5']]); + +// after +use FreeDSx\Sasl\Mechanism\MechanismName; + +$sasl->get(MechanismName::DIGEST_MD5); +$sasl->supports(MechanismName::SCRAM_SHA256); +$sasl->remove(MechanismName::PLAIN); +$sasl->select([MechanismName::SCRAM_SHA256, MechanismName::PLAIN]); +new Sasl(['supported' => [MechanismName::DIGEST_MD5]]); +``` + +## Concrete classes are `final` + +All concrete classes in the library are now marked `final`. If you were extending any of these, switch to composition instead. diff --git a/composer.json b/composer.json index 921e447..1d26197 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "keywords": [ "SASL", "DIGEST-MD5", - "CRAM-MD5" + "CRAM-MD5", + "SCRAM" ], "license": "MIT", "authors": [ @@ -15,13 +16,16 @@ } ], "require": { - "php": ">=7.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^7.0|^8.5|^9.5", - "phpstan/phpstan": "^0.11|^0.12", - "symplify/easy-coding-standard": ">=6.1", - "symfony/polyfill-php80": "^1.27" + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/extension-installer": "^1.4", + "symplify/easy-coding-standard": "^9.4", + "squizlabs/php_codesniffer": "^3.7", + "slevomat/coding-standard": "^7.2" }, "suggest": { "ext-openssl": "Needed for encryption for certain mechanisms." @@ -30,17 +34,32 @@ "psr-4": {"FreeDSx\\Sasl\\": "src/FreeDSx/Sasl"} }, "autoload-dev": { - "psr-4": {"unit\\FreeDSx\\Sasl\\": "tests/unit/FreeDSx/Sasl"} + "psr-4": { + "Tests\\Unit\\FreeDSx\\Sasl\\": "tests/unit" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true + } }, "scripts": { - "test": [ - "phpunit" + "test-unit": [ + "@php -d xdebug.mode=off vendor/bin/phpunit --testsuite unit" ], "test-coverage": [ - "phpunit --coverage-clover=coverage.xml" + "@php -d xdebug.mode=coverage vendor/bin/phpunit --testsuite unit --coverage-clover=coverage.xml" ], "analyse": [ - "phpstan analyse" + "phpstan --memory-limit=-1 analyse" + ], + "analyse-tests": [ + "phpstan --memory-limit=-1 analyse -c phpstan.tests.neon" + ], + "cs-fix": [ + "phpcbf --standard=ruleset.xml --extensions=php --tab-width=4 -sp src", + "ecs --fix" ] } } diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..8ccea1a --- /dev/null +++ b/ecs.php @@ -0,0 +1,30 @@ +parameters(); + $parameters->set(Option::PATHS, [ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + + $services = $containerConfigurator->services(); + $services->set(ArraySyntaxFixer::class) + ->call('configure', [[ + 'syntax' => 'short', + ]]); + $services->set(BlankLineAfterStrictTypesFixer::class); + $services->set(BlankLineAfterOpeningTagFixer::class); + $services->set(NoUnusedImportsFixer::class); + + $containerConfigurator->import(SetList::PSR_12); +}; diff --git a/ecs.yml b/ecs.yml deleted file mode 100644 index aadcef3..0000000 --- a/ecs.yml +++ /dev/null @@ -1,2 +0,0 @@ -imports: - - { resource: 'vendor/symplify/easy-coding-standard/config/set/psr12.yaml' } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..737524e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + # Level 6 for now. Bumping to 9 (matching LDAP-fork) is gated on the planned + # Options DTO refactor — most level 7-9 noise comes from the array + # accessors on Message and SaslContext. + level: 6 + paths: + - %currentWorkingDirectory%/src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 9198b58..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,6 +0,0 @@ -parameters: - level: max - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false - paths: - - src diff --git a/phpstan.tests.neon b/phpstan.tests.neon new file mode 100644 index 0000000..0987142 --- /dev/null +++ b/phpstan.tests.neon @@ -0,0 +1,5 @@ +parameters: + treatPhpDocTypesAsCertain: false + level: 10 + paths: + - %currentWorkingDirectory%/tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f4912de..66d405a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,15 @@ - - - - - src - - - - - ./tests/unit - - + + + + ./src + + + + + + + ./tests/unit + + diff --git a/ruleset.xml b/ruleset.xml new file mode 100644 index 0000000..c4e6410 --- /dev/null +++ b/ruleset.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/FreeDSx/Sasl/Challenge/AnonymousChallenge.php b/src/FreeDSx/Sasl/Challenge/AnonymousChallenge.php index 31aa2ae..eaf1414 100644 --- a/src/FreeDSx/Sasl/Challenge/AnonymousChallenge.php +++ b/src/FreeDSx/Sasl/Challenge/AnonymousChallenge.php @@ -1,5 +1,7 @@ */ -class AnonymousChallenge implements ChallengeInterface +final readonly class AnonymousChallenge implements ChallengeInterface { - /** - * @var SaslContext - */ - protected $context; + private readonly SaslContext $context; - /** - * @var AnonymousEncoder - */ - protected $encoder; + private readonly AnonymousEncoder $encoder; public function __construct(bool $isServerMode = false) { @@ -39,11 +35,10 @@ public function __construct(bool $isServerMode = false) $this->context->setIsServerMode($isServerMode); } - /** - * {@inheritDoc} - */ - public function challenge(?string $received = null, array $options = []): SaslContext - { + public function challenge( + ?string $received = null, + array $options = [], + ): SaslContext { if ($this->context->isServerMode()) { $this->processServer($received); } else { @@ -53,25 +48,27 @@ public function challenge(?string $received = null, array $options = []): SaslCo return $this->context; } - protected function processServer(?string $received): void + private function processServer(?string $received): void { if ($received === null) { return; } - $received = $this->encoder->decode($received, $this->context); + $message = $this->encoder->decode($received, $this->context); $this->context->setIsComplete(true); $this->context->setIsAuthenticated(true); - if ($received->has('trace')) { - $this->context->set('trace', $received->get('trace')); + if ($message->has('trace')) { + $this->context->set('trace', $message->get('trace')); } } - protected function processClient(array $options): void + /** + * @param array $options + */ + private function processClient(array $options): void { $data = []; - if (isset($options['username']) || isset($options['trace'])) { $data['trace'] = $options['trace'] ?? $options['username']; } diff --git a/src/FreeDSx/Sasl/Challenge/ChallengeInterface.php b/src/FreeDSx/Sasl/Challenge/ChallengeInterface.php index e055a3b..6082479 100644 --- a/src/FreeDSx/Sasl/Challenge/ChallengeInterface.php +++ b/src/FreeDSx/Sasl/Challenge/ChallengeInterface.php @@ -1,5 +1,7 @@ $options options for generating the next message + * * @throws SaslException */ - public function challenge(?string $received = null, array $options = []): SaslContext; + public function challenge( + ?string $received = null, + array $options = [], + ): SaslContext; } diff --git a/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php b/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php index d585373..4d9ddc8 100644 --- a/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php +++ b/src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php @@ -1,5 +1,7 @@ */ -class CramMD5Challenge implements ChallengeInterface +final readonly class CramMD5Challenge implements ChallengeInterface { use NonceTrait; - /** - * @var SaslContext - */ - protected $context; + private readonly SaslContext $context; - /** - * @var EncoderInterface - */ - protected $encoder; + private readonly CramMD5Encoder $encoder; public function __construct(bool $isServerMode = false) { @@ -44,60 +39,75 @@ public function __construct(bool $isServerMode = false) $this->context->setIsServerMode($isServerMode); } - /** - * {@inheritDoc} - */ - public function challenge(?string $received = null, array $options = []): SaslContext - { - $received = ($received === null) ? null : $this->encoder->decode($received, $this->context); + public function challenge( + ?string $received = null, + array $options = [], + ): SaslContext { + $message = $received === null ? null : $this->encoder->decode($received, $this->context); - if ($received === null) { - !$this->context->isServerMode() ? $this->context : $this->generateServerChallenge($options); + if ($message === null) { + if ($this->context->isServerMode()) { + $this->generateServerChallenge($options); + } return $this->context; } if ($this->context->isServerMode()) { - $this->validateClientResponse($received, $options); + $this->validateClientResponse($message, $options); } else { - $this->generateClientResponse($received, $options); + $this->generateClientResponse($message, $options); } return $this->context; } - protected function generateServerChallenge(array $options): SaslContext + /** + * @param array $options + */ + private function generateServerChallenge(array $options): void { - $nonce = $options['challenge'] ?? $this->generateNonce(32); + $nonce = (string) ($options['challenge'] ?? $this->generateNonce(32)); $challenge = new Message(['challenge' => $nonce]); $encoded = $this->encoder->encode($challenge, $this->context); $this->context->setResponse($encoded); - // Store the encoded challenge string (e.g. "") rather than the raw nonce. - // RFC 2195 requires the HMAC to be computed over the challenge exactly as the client - // received it, so the value passed to the password callable must match the encoded form. + # Store the encoded challenge string (e.g. "") rather than the raw nonce. RFC 2195 + # requires the HMAC to be computed over the challenge exactly as the client received it. $this->context->set('challenge', $encoded); - - return $this->context; } - protected function generateClientResponse(Message $received, array $options): void - { + /** + * @param array $options + * + * @throws SaslException + */ + private function generateClientResponse( + Message $received, + array $options, + ): void { if (!$received->has('challenge')) { throw new SaslException('Expected a server challenge to generate a client response.'); } - if (!(isset($options['username']) && isset($options['password']))) { + if (!isset($options['username'], $options['password'])) { throw new SaslException('A username and password is required for a client response.'); } $response = new Message([ 'username' => $options['username'], - 'digest' => $this->generateDigest($received->get('challenge'), $options['password']), + 'digest' => $this->generateDigest((string) $received->get('challenge'), (string) $options['password']), ]); $this->context->setResponse($this->encoder->encode($response, $this->context)); $this->context->setIsComplete(true); } - protected function validateClientResponse(Message $received, array $options): void - { + /** + * @param array $options + * + * @throws SaslException + */ + private function validateClientResponse( + Message $received, + array $options, + ): void { if (!$received->has('username')) { throw new SaslException('The client response must have a username.'); } @@ -120,12 +130,14 @@ protected function validateClientResponse(Message $received, array $options): vo $this->context->setIsComplete(true); } - protected function generateDigest(string $challenge, string $key): string - { + private function generateDigest( + string $challenge, + string $key, + ): string { return hash_hmac( 'md5', $challenge, - $key + $key, ); } } diff --git a/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php b/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php index 32bf0ab..14e1250 100644 --- a/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php +++ b/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php @@ -1,5 +1,7 @@ */ -class DigestMD5Challenge implements ChallengeInterface +final class DigestMD5Challenge implements ChallengeInterface { /** - * @var array + * @var array */ - protected const DEFAULTS = [ + private const DEFAULTS = [ 'use_integrity' => false, 'use_privacy' => false, 'service' => 'ldap', 'nonce_size' => null, ]; - /** - * @var SaslContext - */ - protected $context; + private readonly SaslContext $context; - /** - * @var DigestMD5MessageFactory - */ - protected $factory; + private readonly DigestMD5MessageFactory $factory; - /** - * @var DigestMD5Encoder - */ - protected $encoder; + private readonly DigestMD5Encoder $encoder; - /** - * @var null|Message - */ - protected $challenge; + private ?Message $challenge = null; public function __construct(bool $isServerMode = false) { @@ -63,29 +54,30 @@ public function __construct(bool $isServerMode = false) $this->context->setIsServerMode($isServerMode); } - /** - * {@inheritDoc} - */ - public function challenge(?string $received = null, array $options = []): SaslContext - { + public function challenge( + ?string $received = null, + array $options = [], + ): SaslContext { $options = $options + self::DEFAULTS; - $received = $received === null ? null : $this->encoder->decode($received, $this->context); - if ($this->context->isServerMode()) { - $response = $this->generateServerResponse($received, $options); - } else { - $response = $this->generateClientResponse($received, $options); - } + $message = $received === null ? null : $this->encoder->decode($received, $this->context); + $response = $this->context->isServerMode() + ? $this->generateServerResponse($message, $options) + : $this->generateClientResponse($message, $options); $this->context->setResponse($response); return $this->context; } /** + * @param array $options + * * @throws SaslException */ - protected function generateClientResponse(?Message $message, array $options): ?string - { + private function generateClientResponse( + ?Message $message, + array $options, + ): ?string { if ($message === null) { return null; } @@ -109,18 +101,23 @@ protected function generateClientResponse(?Message $message, array $options): ?s return null; } - protected function generateServerResponse(?Message $received, array $options): ?string - { - if ($received === null) { - $response = $this->generateServerChallenge($options); - } else { - $response = $this->generateServerVerification($received, $options); - } + /** + * @param array $options + * + * @throws SaslException + */ + private function generateServerResponse( + ?Message $received, + array $options, + ): ?string { + $response = $received === null + ? $this->generateServerChallenge($options) + : $this->generateServerVerification($received, $options); return $response === null ? null : $this->encoder->encode($response, $this->context); } - protected function isClientChallengeNeeded(Message $message): bool + private function isClientChallengeNeeded(Message $message): bool { if ($this->context->isServerMode()) { return false; @@ -130,26 +127,29 @@ protected function isClientChallengeNeeded(Message $message): bool } /** + * @param array $options + * * @throws SaslException */ - protected function createClientResponse(Message $message, array $options): string - { - $password = $options['password'] ?? ''; - - if ($options['use_privacy']) { - $this->context->set('qop', 'auth-conf'); - } elseif ($options['use_integrity']) { - $this->context->set('qop', 'auth-int'); - } else { - $this->context->set('qop', 'auth'); - } + private function createClientResponse( + Message $message, + array $options, + ): string { + $password = (string) ($options['password'] ?? ''); + + $qop = match (true) { + (bool) $options['use_privacy'] => 'auth-conf', + (bool) $options['use_integrity'] => 'auth-int', + default => 'auth', + }; + $this->context->set('qop', $qop); $messageOpts = [ 'username' => $options['username'] ?? null, 'digest-uri' => isset($options['host']) ? ($options['service'] . '/' . $options['host']) : null, - 'qop' => $this->context->get('qop'), + 'qop' => $qop, 'nonce_size' => $options['nonce_size'], - 'service' => $options['service'] + 'service' => $options['service'], ]; if (isset($options['cnonce'])) { $messageOpts['cnonce'] = $options['cnonce']; @@ -161,13 +161,15 @@ protected function createClientResponse(Message $message, array $options): strin $messageOpts['cipher'] = $options['cipher']; } $response = $this->factory->create( - DigestMD5MessageFactory::MESSAGE_CLIENT_RESPONSE, $messageOpts, $message + DigestMD5MessageType::CLIENT_RESPONSE, + $messageOpts, + $message, ); $response->set('response', DigestMD5Mechanism::computeResponse( $password, $message, $response, - $this->context->isServerMode() + $this->context->isServerMode(), )); # The verification is used to check the response value returned from the server for authentication. @@ -177,11 +179,11 @@ protected function createClientResponse(Message $message, array $options): strin $password, $message, $response, - !$this->context->isServerMode() - ) + !$this->context->isServerMode(), + ), ); - # Pre-compute some stuff in advance. The A1 / cipher value is used in the security layer. + # The A1 / cipher value is used in the security layer. if ($options['use_integrity'] || $options['use_privacy']) { $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $message, $response))); $this->context->set('cipher', $response->get('cipher')); @@ -190,8 +192,15 @@ protected function createClientResponse(Message $message, array $options): strin return $this->encoder->encode($response, $this->context); } - protected function generateServerVerification(Message $received, array $options): ?Message - { + /** + * @param array $options + * + * @throws SaslException + */ + private function generateServerVerification( + Message $received, + array $options, + ): ?Message { $this->context->setIsComplete(true); # The client sent a response without us sending a challenge... if ($this->challenge === null) { @@ -230,33 +239,32 @@ protected function generateServerVerification(Message $received, array $options) return null; } - $response = DigestMD5Mechanism::computeResponse($password, $this->challenge, $received, true); + $response = DigestMD5Mechanism::computeResponse((string) $password, $this->challenge, $received, true); $this->context->setIsAuthenticated(true); if ($qop === 'auth-int' || $qop === 'auth-conf') { $this->context->setHasSecurityLayer(true); - $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $this->challenge, $received))); + $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1((string) $password, $this->challenge, $received))); $this->context->set('cipher', $received->get('cipher')); $this->context->set('seqnumsnt', 0); $this->context->set('seqnumrcv', 0); } return $this->factory->create( - DigestMD5MessageFactory::MESSAGE_SERVER_RESPONSE, - ['rspauth' => $response] + DigestMD5MessageType::SERVER_RESPONSE, + ['rspauth' => $response], ); } - protected function generateServerChallenge(array $options): Message + /** + * @param array $options + * + * @throws SaslException + */ + private function generateServerChallenge(array $options): Message { - $messageOpts = []; - if (isset($options['nonce'])) { - $messageOpts['nonce'] = $options['nonce']; - } - if (isset($options['cipher'])) { - $messageOpts['cipher'] = $options['cipher']; - } $this->challenge = $this->factory->create( - DigestMD5MessageFactory::MESSAGE_SERVER_CHALLENGE, $options + DigestMD5MessageType::SERVER_CHALLENGE, + $options, ); return $this->challenge; diff --git a/src/FreeDSx/Sasl/Challenge/PlainChallenge.php b/src/FreeDSx/Sasl/Challenge/PlainChallenge.php index 8565e2f..47ab7bb 100644 --- a/src/FreeDSx/Sasl/Challenge/PlainChallenge.php +++ b/src/FreeDSx/Sasl/Challenge/PlainChallenge.php @@ -1,5 +1,7 @@ */ -class PlainChallenge implements ChallengeInterface +final readonly class PlainChallenge implements ChallengeInterface { - /** - * @var PlainEncoder - */ - protected $encoder; + private readonly PlainEncoder $encoder; - /** - * @var SaslContext - */ - protected $context; + private readonly SaslContext $context; public function __construct(bool $isServerMode = false) { @@ -40,26 +36,30 @@ public function __construct(bool $isServerMode = false) $this->context->setIsServerMode($isServerMode); } - /** - * {@inheritDoc} - */ - public function challenge(?string $received = null, array $options = []): SaslContext - { - $received = $received === null ? null : $this->encoder->decode($received, $this->context); + public function challenge( + ?string $received = null, + array $options = [], + ): SaslContext { + $message = $received === null ? null : $this->encoder->decode($received, $this->context); - if ($this->context->isServerMode()) { - return $this->serverProcess($received, $options); - } else { - return $this->clientProcess($options); - } + return $this->context->isServerMode() + ? $this->serverProcess($message, $options) + : $this->clientProcess($options); } - protected function serverProcess(?Message $message, array $options): SaslContext - { + /** + * @param array $options + * + * @throws SaslException + */ + private function serverProcess( + ?Message $message, + array $options, + ): SaslContext { if ($message === null) { return $this->context; } - if (!(isset($options['validate']) && is_callable($options['validate']))) { + if (!isset($options['validate']) || !is_callable($options['validate'])) { throw new SaslException('You must pass a callable validate option to the plain mechanism in server mode.'); } $authzId = $message->get('authzid'); @@ -72,7 +72,12 @@ protected function serverProcess(?Message $message, array $options): SaslContext return $this->context; } - protected function clientProcess(array $options): SaslContext + /** + * @param array $options + * + * @throws SaslException + */ + private function clientProcess(array $options): SaslContext { if (!isset($options['username'])) { throw new SaslException('You must supply a username for the PLAIN mechanism.'); diff --git a/src/FreeDSx/Sasl/Challenge/ScramChallenge.php b/src/FreeDSx/Sasl/Challenge/ScramChallenge.php index cd9db9c..a6ed865 100644 --- a/src/FreeDSx/Sasl/Challenge/ScramChallenge.php +++ b/src/FreeDSx/Sasl/Challenge/ScramChallenge.php @@ -1,5 +1,7 @@ client-first-message @@ -37,7 +38,7 @@ * * @author Chad Sikorra */ -class ScramChallenge implements ChallengeInterface +final readonly class ScramChallenge implements ChallengeInterface { use NonceTrait; @@ -46,75 +47,53 @@ class ScramChallenge implements ChallengeInterface */ private const NONCE_SIZE = 24; - private const ALGO_SHA1 = 'sha1'; - - private const ALGO_SHA224 = 'sha224'; - - private const ALGO_SHA256 = 'sha256'; - - private const ALGO_SHA384 = 'sha384'; - - private const ALGO_SHA512 = 'sha512'; - - private const ALGO_SHA3_512 = 'sha3-512'; - - /** - * PHP hash algorithm names supported by this class. - */ - private const SUPPORTED_ALGORITHMS = [ - self::ALGO_SHA1, - self::ALGO_SHA224, - self::ALGO_SHA256, - self::ALGO_SHA384, - self::ALGO_SHA512, - self::ALGO_SHA3_512, - ]; - /** - * Minimum PBKDF2 iteration count the client will accept from a server, keyed by PHP hash algorithm name. + * Minimum PBKDF2 iteration count the client will accept from a server, keyed by HashAlgorithm value. * Values follow RFC 5802 (SHA-1: 4096) and RFC 7677 (SHA-256: 4096). */ private const MIN_ITERATIONS = [ - self::ALGO_SHA1 => 4096, - self::ALGO_SHA224 => 4096, - self::ALGO_SHA256 => 4096, - self::ALGO_SHA384 => 4096, - self::ALGO_SHA512 => 4096, - self::ALGO_SHA3_512 => 4096, + 'sha1' => 4096, + 'sha224' => 4096, + 'sha256' => 4096, + 'sha384' => 4096, + 'sha512' => 4096, + 'sha3-512' => 4096, ]; /** - * Maximum PBKDF2 iteration count accepted from a server (or configured for a server). - * OWASP 2024 recommends 600,000 for SHA-256 as a minimum; values above 1,000,000 are - * unlikely to reflect a legitimate security policy and risk client-side DoS. + * Maximum PBKDF2 iteration count accepted from a server (or configured for a server). Values + * above 1,000,000 are unlikely to reflect a legitimate security policy and risk client-side DoS. */ private const MAX_ITERATIONS = 1000000; /** - * Default PBKDF2 iteration count used when the server generates a challenge, keyed by PHP hash - * algorithm name. Values are based on RFC 8265 guidance (≥15,000 for SHA-256) and scaled - * conservatively for stronger hash variants. + * Default PBKDF2 iteration count used when the server generates a challenge, keyed by + * HashAlgorithm value. Based on RFC 8265 guidance (≥15,000 for SHA-256), scaled conservatively + * for stronger hash variants. */ private const DEFAULT_ITERATIONS = [ - self::ALGO_SHA1 => 10000, - self::ALGO_SHA224 => 15000, - self::ALGO_SHA256 => 15000, - self::ALGO_SHA384 => 10000, - self::ALGO_SHA512 => 10000, - self::ALGO_SHA3_512 => 10000, + 'sha1' => 10000, + 'sha224' => 15000, + 'sha256' => 15000, + 'sha384' => 10000, + 'sha512' => 10000, + 'sha3-512' => 10000, ]; private const HMAC_CLIENT_KEY = 'Client Key'; private const HMAC_SERVER_KEY = 'Server Key'; + /** + * Context keys used to thread state between challenge invocations. + */ private const CTX_GS2_HEADER = 'scram-gs2-header'; private const CTX_CNONCE = 'scram-cnonce'; private const CTX_CLIENT_FIRST_BARE = 'scram-client-first-bare'; - private const CTX_SERVER_SIGNATURE = 'scram-server-signature'; + private const CTX_SERVER_SIGNATURE = 'scram-server-signature'; private const CTX_NONCE = 'scram-nonce'; @@ -126,69 +105,31 @@ class ScramChallenge implements ChallengeInterface private const INVALID_PROOF = 'e=invalid-proof'; - /** - * @var SaslContext - */ - private $context; - - /** - * @var ScramEncoder - */ - private $encoder; - - /** - * PHP hash algorithm name (e.g. 'sha256', 'sha1', 'sha3-512'). - * - * @var string - */ - private $hashAlgorithm; + private readonly SaslContext $context; - /** - * Whether this is a channel-binding (-PLUS) variant. - * - * @var bool - */ - private $isChannelBinding; + private readonly ScramEncoder $encoder; - /** - * @throws SaslException - */ public function __construct( bool $isServerMode = false, - string $hashAlgorithm = 'sha256', - bool $isChannelBinding = false + private readonly HashAlgorithm $hashAlgorithm = HashAlgorithm::SHA256, + private readonly bool $isChannelBinding = false, ) { - if (!in_array($hashAlgorithm, self::SUPPORTED_ALGORITHMS, true)) { - throw new SaslException(sprintf( - 'The hash algorithm "%s" is not supported. Supported algorithms: %s.', - $hashAlgorithm, - implode(', ', self::SUPPORTED_ALGORITHMS) - )); - } - - $this->hashAlgorithm = $hashAlgorithm; - $this->isChannelBinding = $isChannelBinding; $this->encoder = new ScramEncoder(); $this->context = new SaslContext(); $this->context->setIsServerMode($isServerMode); } - /** - * {@inheritDoc} - */ public function challenge( ?string $received = null, - array $options = [] + array $options = [], ): SaslContext { # Keep the raw received string for auth-message construction before decoding. $rawReceived = $received; - $received = $received !== null ? $this->encoder->decode($received, $this->context) : null; + $message = $received !== null ? $this->encoder->decode($received, $this->context) : null; - if ($this->context->isServerMode()) { - $response = $this->generateServerResponse($received, $rawReceived, $options); - } else { - $response = $this->generateClientResponse($received, $rawReceived, $options); - } + $response = $this->context->isServerMode() + ? $this->generateServerResponse($message, $rawReceived, $options) + : $this->generateClientResponse($message, $rawReceived, $options); $this->context->setResponse($response); @@ -196,12 +137,14 @@ public function challenge( } /** + * @param array $options + * * @throws SaslException */ private function generateClientResponse( ?Message $received, ?string $rawReceived, - array $options + array $options, ): ?string { # Step 1: No message yet — generate client-first-message. if ($received === null) { @@ -224,12 +167,14 @@ private function generateClientResponse( } /** + * @param array $options + * * @throws SaslException */ private function generateServerResponse( ?Message $received, ?string $rawReceived, - array $options + array $options, ): ?string { # SCRAM is client-initiated — the server has no opening message. if ($received === null) { @@ -243,21 +188,13 @@ private function generateServerResponse( # Step 2: Client-final-message received (contains channel binding 'c' and proof 'p'). if ($received->has('c') && $received->has('p')) { - return $this->generateServerFinal( - $received, - $options - ); + return $this->generateServerFinal($received, $options); } throw new SaslException('Unexpected SCRAM message received on the server.'); } /** - * Generates the client-first-message: GS2-header + client-first-message-bare. - * - * client-first-message = gs2-header client-first-message-bare - * client-first-message-bare = [reserved-mext ","] n=username "," r=nonce - * * @param array{username?: string, cnonce?: string, cbind_type?: string} $options * * @throws SaslException @@ -286,11 +223,7 @@ private function generateClientFirst(array $options): string $username = SaslPrep::prepare($username); # RFC 5802 section 5.1: '=' and ',' in the username must be encoded. - $username = str_replace( - ['=', ','], - ['=3D', '=2C'], - $username - ); + $username = str_replace(['=', ','], ['=3D', '=2C'], $username); $clientFirstBare = 'n=' . $username . ',r=' . $cnonce; @@ -302,11 +235,6 @@ private function generateClientFirst(array $options): string } /** - * Generates the client-final-message from the received server-first-message. - * - * client-final-message = client-final-message-without-proof "," p=client-proof - * client-final-message-without-proof = "c=" base64 "," r=nonce - * * @param array{password?: string, cbind_data?: string} $options * * @throws SaslException @@ -314,7 +242,7 @@ private function generateClientFirst(array $options): string private function generateClientFinal( Message $serverFirst, string $rawServerFirst, - array $options + array $options, ): string { $password = $options['password'] ?? null; if ($password === null) { @@ -325,7 +253,7 @@ private function generateClientFinal( $cnonce = $this->context->get(self::CTX_CNONCE); if ($cnonce === null) { throw new SaslException( - 'client-final called before client-first: no client nonce found in context.' + 'client-final called before client-first: no client nonce found in context.', ); } $cnonce = (string) $cnonce; @@ -340,37 +268,26 @@ private function generateClientFinal( $clientFinalWithoutProof = 'c=' . $channelBinding . ',r=' . $fullNonce; - # RFC 5802 section 3: AuthMessage = client-first-bare "," server-first "," client-final-without-proof + # RFC 5802 §3: AuthMessage = client-first-bare "," server-first "," client-final-without-proof $authMessage = $this->context->get(self::CTX_CLIENT_FIRST_BARE) . ',' . $rawServerFirst . ',' . $clientFinalWithoutProof; - $saltedPassword = $this->deriveSaltedPassword((string) $password, $salt, $iterations); - $clientProof = $this->makeClientProof( - $saltedPassword, - $authMessage - ); + $saltedPassword = $this->deriveSaltedPassword($password, $salt, $iterations); + $clientProof = $this->makeClientProof($saltedPassword, $authMessage); # Pre-compute the expected server signature so we can verify it in step 3. - $serverKey = $this->hmac( - $saltedPassword, - self::HMAC_SERVER_KEY - ); - $serverSignature = $this->hmac( - $serverKey, - $authMessage - ); + $serverKey = $this->hmac($saltedPassword, self::HMAC_SERVER_KEY); + $serverSignature = $this->hmac($serverKey, $authMessage); $this->context->set( self::CTX_SERVER_SIGNATURE, - base64_encode($serverSignature) + base64_encode($serverSignature), ); return $clientFinalWithoutProof . ',p=' . base64_encode($clientProof); } /** - * Verifies the server-final-message and marks authentication as complete. - * * @throws SaslException */ private function verifyServerFinal(Message $serverFinal): void @@ -380,7 +297,7 @@ private function verifyServerFinal(Message $serverFinal): void if ($serverFinal->has('e')) { throw new SaslException(sprintf( 'SCRAM authentication failed with server error: %s', - $serverFinal->get('e') + $serverFinal->get('e'), )); } @@ -395,10 +312,6 @@ private function verifyServerFinal(Message $serverFinal): void } /** - * Generates the server-first-message in response to the client-first-message. - * - * server-first-message = [reserved-mext ","] r=nonce "," s=salt "," i=iteration-count - * * @param array{nonce?: string, salt?: string, iterations?: int} $options * * @throws SaslException @@ -406,14 +319,14 @@ private function verifyServerFinal(Message $serverFinal): void private function generateServerFirst( Message $clientFirst, string $rawClientFirst, - array $options + array $options, ): string { $cnonce = (string) $clientFirst->get('r'); $snonce = $options['nonce'] ?? $this->generateNonce(self::NONCE_SIZE); $fullNonce = $cnonce . $snonce; $salt = $options['salt'] ?? random_bytes(16); - $iterations = (int) ($options['iterations'] ?? self::DEFAULT_ITERATIONS[$this->hashAlgorithm]); + $iterations = (int) ($options['iterations'] ?? self::DEFAULT_ITERATIONS[$this->hashAlgorithm->value]); if ($iterations < 1) { throw new SaslException('The iteration count must be greater than zero.'); } @@ -421,7 +334,7 @@ private function generateServerFirst( throw new SaslException(sprintf( 'The iteration count of %d exceeds the maximum allowed of %d.', $iterations, - self::MAX_ITERATIONS + self::MAX_ITERATIONS, )); } @@ -438,8 +351,6 @@ private function generateServerFirst( } /** - * Verifies the client-final-message and generates the server-final-message. - * * Returns 'v=' on success, or 'e=' on failure. * * @param array{password?: string} $options @@ -448,7 +359,7 @@ private function generateServerFirst( */ private function generateServerFinal( Message $clientFinal, - array $options + array $options, ): string { $this->context->setIsComplete(true); @@ -465,20 +376,15 @@ private function generateServerFinal( $salt = (string) $this->context->get(self::CTX_SALT); $iterations = (int) $this->context->get(self::CTX_ITERATIONS); - # Reconstruct client-final-without-proof for the auth message. - # RFC 5802: the order is fixed as c=...,r=...[,extensions] so we can rebuild it safely. + # RFC 5802: client-final-without-proof = c=...,r=...[,extensions] — fixed order, safely rebuildable. $clientFinalWithoutProof = 'c=' . $clientFinal->get('c') . ',r=' . $clientFinal->get('r'); $authMessage = $this->context->get(self::CTX_CLIENT_FIRST_BARE) . ',' . $this->context->get(self::CTX_SERVER_FIRST) . ',' . $clientFinalWithoutProof; try { - $saltedPassword = $this->deriveSaltedPassword( - (string) $password, - $salt, - $iterations - ); - } catch (SaslException $e) { + $saltedPassword = $this->deriveSaltedPassword($password, $salt, $iterations); + } catch (SaslException) { return self::INVALID_PROOF; } @@ -505,7 +411,7 @@ private function extractClientFirstBare(string $clientFirst): string { $pos = strpos( $clientFirst, - ',,' + ',,', ); if ($pos === false) { throw new SaslException('Unable to parse GS2 header from client-first-message.'); @@ -513,7 +419,7 @@ private function extractClientFirstBare(string $clientFirst): string return substr( $clientFirst, - $pos + 2 + $pos + 2, ); } @@ -526,7 +432,7 @@ private function extractClientFirstBare(string $clientFirst): string */ private function parseServerFirst( Message $serverFirst, - string $cnonce + string $cnonce, ): array { $fullNonce = (string) $serverFirst->get('r'); if (strncmp($fullNonce, $cnonce, strlen($cnonce)) !== 0) { @@ -535,27 +441,27 @@ private function parseServerFirst( $salt = base64_decode( (string) $serverFirst->get('s'), - true + true, ); if ($salt === false) { throw new SaslException('The server-provided salt is not valid base64.'); } $iterations = (int) $serverFirst->get('i'); - $minIterations = self::MIN_ITERATIONS[$this->hashAlgorithm]; + $minIterations = self::MIN_ITERATIONS[$this->hashAlgorithm->value]; if ($iterations < $minIterations) { throw new SaslException(sprintf( 'The server iteration count of %d is below the minimum of %d for %s.', $iterations, $minIterations, - $this->hashAlgorithm + $this->hashAlgorithm->value, )); } if ($iterations > self::MAX_ITERATIONS) { throw new SaslException(sprintf( 'The server iteration count of %d exceeds the maximum allowed of %d.', $iterations, - self::MAX_ITERATIONS + self::MAX_ITERATIONS, )); } @@ -574,12 +480,12 @@ private function parseServerFirst( private function deriveSaltedPassword( string $password, string $salt, - int $iterations + int $iterations, ): string { return $this->pbkdf2( SaslPrep::prepare($password), $salt, - $iterations + $iterations, ); } @@ -591,15 +497,15 @@ private function deriveSaltedPassword( private function isValidClientProof( Message $clientFinal, string $saltedPassword, - string $authMessage + string $authMessage, ): bool { $expectedProof = $this->makeClientProof( $saltedPassword, - $authMessage + $authMessage, ); $receivedProof = base64_decode( (string) $clientFinal->get('p'), - true + true, ); return $receivedProof !== false @@ -614,15 +520,15 @@ private function isValidClientProof( private function pbkdf2( string $password, string $salt, - int $iterations + int $iterations, ): string { return hash_pbkdf2( - $this->hashAlgorithm, + $this->hashAlgorithm->value, $password, $salt, $iterations, 0, - true + true, ); } @@ -631,13 +537,13 @@ private function pbkdf2( */ private function hmac( string $key, - string $data + string $data, ): string { return hash_hmac( - $this->hashAlgorithm, + $this->hashAlgorithm->value, $data, $key, - true + true, ); } @@ -647,20 +553,15 @@ private function hmac( private function hash(string $data): string { return hash( - $this->hashAlgorithm, + $this->hashAlgorithm->value, $data, - true + true, ); } - /** - * @param string $saltedPassword - * @param string $authMessage - * @return string - */ private function makeClientProof( string $saltedPassword, - string $authMessage + string $authMessage, ): string { $clientKey = $this->hmac( $saltedPassword, @@ -683,10 +584,10 @@ private function makeClientProof( */ private function validateCbindType(string $cbindType): void { - if (!preg_match('/^[A-Za-z0-9.\-]+$/', $cbindType)) { + if (preg_match('/^[A-Za-z0-9.\-]+$/', $cbindType) !== 1) { throw new SaslException( 'The channel binding type contains invalid characters. ' . - 'Only alphanumeric characters, hyphens, and dots are permitted.' + 'Only alphanumeric characters, hyphens, and dots are permitted.', ); } } diff --git a/src/FreeDSx/Sasl/Encoder/AnonymousEncoder.php b/src/FreeDSx/Sasl/Encoder/AnonymousEncoder.php index cba1c71..9e4ac45 100644 --- a/src/FreeDSx/Sasl/Encoder/AnonymousEncoder.php +++ b/src/FreeDSx/Sasl/Encoder/AnonymousEncoder.php @@ -1,5 +1,7 @@ */ -class AnonymousEncoder implements EncoderInterface +final readonly class AnonymousEncoder implements EncoderInterface { - /** - * {@inheritDoc} - */ - public function encode(Message $message, SaslContext $context): string - { + public function encode( + Message $message, + SaslContext $context, + ): string { if ($message->has('trace')) { - return $message->get('trace'); + return (string) $message->get('trace'); } return ''; } - /** - * {@inheritDoc} - */ - public function decode(string $data, SaslContext $context): Message - { + public function decode( + string $data, + SaslContext $context, + ): Message { $message = new Message(); - if($data !== '') { + if ($data !== '') { $message->set('trace', $data); } diff --git a/src/FreeDSx/Sasl/Encoder/CramMD5Encoder.php b/src/FreeDSx/Sasl/Encoder/CramMD5Encoder.php index d28c162..394ab2b 100644 --- a/src/FreeDSx/Sasl/Encoder/CramMD5Encoder.php +++ b/src/FreeDSx/Sasl/Encoder/CramMD5Encoder.php @@ -1,5 +1,7 @@ */ -class CramMD5Encoder implements EncoderInterface +final readonly class CramMD5Encoder implements EncoderInterface { - - /** - * {@inheritDoc} - */ - public function encode(Message $message, SaslContext $context): string - { - if ($context->isServerMode()) { - return $this->encodeServerChallenge($message); - } else { - return $this->encodeClientResponse($message); - } + public function encode( + Message $message, + SaslContext $context, + ): string { + return $context->isServerMode() + ? $this->encodeServerChallenge($message) + : $this->encodeClientResponse($message); } - /** - * {@inheritDoc} - */ - public function decode(string $data, SaslContext $context): Message - { - if ($context->isServerMode()) { - return $this->decodeClientResponse($data); - } else { - return $this->decodeServerChallenge($data); - } + public function decode( + string $data, + SaslContext $context, + ): Message { + return $context->isServerMode() + ? $this->decodeClientResponse($data) + : $this->decodeServerChallenge($data); } /** * @throws SaslEncodingException */ - protected function encodeServerChallenge(Message $message): string + private function encodeServerChallenge(Message $message): string { if (!$message->has('challenge')) { throw new SaslEncodingException('The server challenge message must contain a "challenge".'); } - $challenge = $message->get('challenge'); - return '<' . $challenge . '>'; + return '<' . $message->get('challenge') . '>'; } /** * @throws SaslEncodingException */ - protected function encodeClientResponse(Message $message): string + private function encodeClientResponse(Message $message): string { if (!$message->has('username')) { throw new SaslEncodingException('The client response must contain a username.'); @@ -71,10 +65,10 @@ protected function encodeClientResponse(Message $message): string if (!$message->has('digest')) { throw new SaslEncodingException('The client response must contain a digest.'); } - $username = $message->get('username'); - $digest = $message->get('digest'); + $username = (string) $message->get('username'); + $digest = (string) $message->get('digest'); - if (!preg_match('/^[0-9a-f]{32}$/', $digest)) { + if (preg_match('/^[0-9a-f]{32}$/', $digest) !== 1) { throw new SaslEncodingException('The client digest must be a 16 octet, lower-case, hexadecimal value'); } @@ -84,9 +78,9 @@ protected function encodeClientResponse(Message $message): string /** * @throws SaslEncodingException */ - protected function decodeServerChallenge(string $challenge): Message + private function decodeServerChallenge(string $challenge): Message { - if (!preg_match('/^<.*>$/', $challenge)) { + if (preg_match('/^<.*>$/', $challenge) !== 1) { throw new SaslEncodingException('The server challenge is malformed.'); } @@ -96,9 +90,9 @@ protected function decodeServerChallenge(string $challenge): Message /** * @throws SaslEncodingException */ - protected function decodeClientResponse(string $response): Message + private function decodeClientResponse(string $response): Message { - if (!preg_match('/(.*) ([0-9a-f]{32})$/', $response, $matches)) { + if (preg_match('/(.*) ([0-9a-f]{32})$/', $response, $matches) !== 1) { throw new SaslEncodingException('The client response is malformed.'); } diff --git a/src/FreeDSx/Sasl/Encoder/DigestMD5Encoder.php b/src/FreeDSx/Sasl/Encoder/DigestMD5Encoder.php index 64c0127..d0591ef 100644 --- a/src/FreeDSx/Sasl/Encoder/DigestMD5Encoder.php +++ b/src/FreeDSx/Sasl/Encoder/DigestMD5Encoder.php @@ -1,5 +1,7 @@ */ -class DigestMD5Encoder implements EncoderInterface +final class DigestMD5Encoder implements EncoderInterface { - protected const MATCH_KEY = '/(([a-zA-Z-]+)=)/'; + private const MATCH_KEY = '/(([a-zA-Z-]+)=)/'; - protected const MATCH_QD_STR_VAL = '/("((.*?)(? */ - protected $occurrences = []; + private array $occurrences = []; - /** - * {@inheritDoc} - */ - public function decode(string $data, SaslContext $context): Message - { + public function decode( + string $data, + SaslContext $context, + ): Message { return $this->parse($data, !$context->isServerMode()); } - /** - * {@inheritDoc} - */ - public function encode(Message $message, SaslContext $context): string - { + public function encode( + Message $message, + SaslContext $context, + ): string { $response = ''; - foreach ($message->toArray() as $key => $value) { if ($response !== '') { $response .= ','; } $response .= $key . '=' . $this->encodeOptValue( - $key, + (string) $key, $value, - $context->isServerMode() + $context->isServerMode(), ); } return $response; } - protected function startParsing(string $binary): void + private function startParsing(string $binary): void { $this->binary = $binary; $this->pos = 0; @@ -106,7 +93,7 @@ protected function startParsing(string $binary): void $this->occurrences = []; } - protected function endParsing(): void + private function endParsing(): void { $this->binary = ''; $this->pos = 0; @@ -117,14 +104,16 @@ protected function endParsing(): void /** * @throws SaslEncodingException */ - protected function parse(string $digest, bool $isServerMode): Message - { + private function parse( + string $digest, + bool $isServerMode, + ): Message { $this->startParsing($digest); $message = new Message(); while ($this->pos < $this->length) { $keyMatches = null; - if (!preg_match(self::MATCH_KEY, substr($this->binary, $this->pos), $keyMatches)) { + if (preg_match(self::MATCH_KEY, substr($this->binary, $this->pos), $keyMatches) !== 1) { throw new SaslEncodingException('The digest is malformed. Expected a key, but none was found.'); } $this->pos += strlen($keyMatches[1]); @@ -139,58 +128,35 @@ protected function parse(string $digest, bool $isServerMode): Message } /** - * @return mixed * @throws SaslEncodingException */ - protected function parseOptValue(string $opt, bool $isServerMode) - { - switch ($opt) { - case 'realm': - case 'nonce': - case 'username': - case 'cnonce': - case 'authzid': - case 'digest-uri': - $value = $this->parseQuotedValue(); - break; - case 'qop': - case 'cipher': - if ($isServerMode) { - $value = $this->parseQuotedCommaList(); - } else { - $value = $this->parseRegex(self::MATCH_ALPHA_NUMERIC, 'The value is malformed.'); - } - break; - case 'stale': - $value = $this->parseExact('true'); - break; - case 'maxbuf': - $value = $this->parseRegex(self::MATCH_DIGITS, 'Expected a series of digits for a key value.'); - break; - case 'algorithm': - $value = $this->parseExact('md5-sess'); - break; - case 'charset': - $value = $this->parseExact('utf-8'); - break; - case 'nc': - $value = $this->parseLHexValue(8); - break; - case 'response': - case 'rspauth': - $value = $this->parseLHexValue(32); - break; - default: - throw new SaslEncodingException(sprintf( - 'Digest option %s is not supported.', - $opt - )); - } + private function parseOptValue( + string $opt, + bool $isServerMode, + ): mixed { + $value = match ($opt) { + 'realm', 'nonce', 'username', 'cnonce', 'authzid', 'digest-uri' + => $this->parseQuotedValue(), + 'qop', 'cipher' + => $isServerMode + ? $this->parseQuotedCommaList() + : $this->parseRegex(self::MATCH_ALPHA_NUMERIC, 'The value is malformed.'), + 'stale' => $this->parseExact('true'), + 'maxbuf' => $this->parseRegex(self::MATCH_DIGITS, 'Expected a series of digits for a key value.'), + 'algorithm' => $this->parseExact('md5-sess'), + 'charset' => $this->parseExact('utf-8'), + 'nc' => $this->parseLHexValue(8), + 'response', 'rspauth' => $this->parseLHexValue(32), + default => throw new SaslEncodingException(sprintf( + 'Digest option %s is not supported.', + $opt, + )), + }; if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] !== ',') { throw new SaslEncodingException(sprintf( 'Expected a comma following digest value for %s.', - $opt + $opt, )); } if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] === ',') { @@ -199,74 +165,48 @@ protected function parseOptValue(string $opt, bool $isServerMode) if (isset($this->occurrences[$opt]) && in_array($opt, self::ONCE_ONLY, true)) { throw new SaslEncodingException(sprintf('The option "%s" may occur only once.', $opt)); - } elseif (isset($this->occurrences[$opt])) { - $this->occurrences[$opt]++; - } else { - $this->occurrences[$opt] = 1; } + $this->occurrences[$opt] = ($this->occurrences[$opt] ?? 0) + 1; return $value; } /** - * @param mixed $value - * @return string * @throws SaslEncodingException */ - protected function encodeOptValue(string $name, $value, bool $isServerMode) - { - switch ($name) { - case 'realm': - case 'nonce': - case 'username': - case 'cnonce': - case 'authzid': - case 'digest-uri': - $encoded = '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"'; - break; - case 'qop': - case 'cipher': - if ($isServerMode) { - $encoded = '"' . implode(',', (array) $value) . '"'; - } else { - $encoded = (string) $value; - } - break; - case 'stale': - $encoded = 'true'; - break; - case 'maxbuf': - case 'algorithm': - case 'charset': - $encoded = (string) $value; - break; - case 'nc': - $encoded = str_pad(dechex($value), 8, '0', STR_PAD_LEFT); - break; - case 'response': - case 'rspauth': - $encoded = $this->encodeLHexValue($value, 32); - break; - default: - throw new SaslEncodingException(sprintf( - 'Digest option %s is not supported.', - $name - )); - } - - return $encoded; + private function encodeOptValue( + string $name, + mixed $value, + bool $isServerMode, + ): string { + return match ($name) { + 'realm', 'nonce', 'username', 'cnonce', 'authzid', 'digest-uri' + => '"' . str_replace(['\\', '"'], ['\\\\', '\"'], (string) $value) . '"', + 'qop', 'cipher' + => $isServerMode + ? '"' . implode(',', (array) $value) . '"' + : (string) $value, + 'stale' => 'true', + 'maxbuf', 'algorithm', 'charset' => (string) $value, + 'nc' => str_pad(dechex((int) $value), 8, '0', STR_PAD_LEFT), + 'response', 'rspauth' => $this->encodeLHexValue((string) $value, 32), + default => throw new SaslEncodingException(sprintf( + 'Digest option %s is not supported.', + $name, + )), + }; } /** * @throws SaslEncodingException */ - protected function parseExact(string $expected): string + private function parseExact(string $expected): string { $length = strlen($expected); if (substr($this->binary, $this->pos, $length) !== $expected) { throw new SaslEncodingException(sprintf( 'Expected the directive value to be "%s", but it is not.', - $expected + $expected, )); } $this->pos += $length; @@ -277,9 +217,9 @@ protected function parseExact(string $expected): string /** * @throws SaslEncodingException */ - protected function parseQuotedValue(): string + private function parseQuotedValue(): string { - if (!preg_match(self::MATCH_QD_STR_VAL, substr($this->binary, $this->pos), $matches)) { + if (preg_match(self::MATCH_QD_STR_VAL, substr($this->binary, $this->pos), $matches) !== 1) { throw new SaslEncodingException('The value is malformed. Expected a qdstr-val.'); } $this->pos += strlen($matches[1]); @@ -288,21 +228,21 @@ protected function parseQuotedValue(): string } /** + * @return string[] + * * @throws SaslEncodingException */ - protected function parseQuotedCommaList(): array + private function parseQuotedCommaList(): array { - $value = $this->parseQuotedValue(); - - return explode(',', $value); + return explode(',', $this->parseQuotedValue()); } /** * @throws SaslEncodingException */ - protected function parseLHexValue(int $length): string + private function parseLHexValue(int $length): string { - if (!preg_match(self::MATCH_LHEX, substr($this->binary, $this->pos), $matches)) { + if (preg_match(self::MATCH_LHEX, substr($this->binary, $this->pos), $matches) !== 1) { throw new SaslEncodingException('Expected a hex value.'); } if (strlen($matches[1]) !== $length) { @@ -315,12 +255,12 @@ protected function parseLHexValue(int $length): string /** * @throws SaslEncodingException - * - * @return string */ - protected function parseRegex(string $regex, string $errorMessage) - { - if (!preg_match($regex, substr($this->binary, $this->pos), $matches)) { + private function parseRegex( + string $regex, + string $errorMessage, + ): string { + if (preg_match($regex, substr($this->binary, $this->pos), $matches) !== 1) { throw new SaslEncodingException($errorMessage); } $this->pos += strlen($matches[1]); @@ -331,8 +271,10 @@ protected function parseRegex(string $regex, string $errorMessage) /** * @throws SaslEncodingException */ - protected function encodeLHexValue(string $data, int $length): string - { + private function encodeLHexValue( + string $data, + int $length, + ): string { if (strlen($data) !== $length) { throw new SaslEncodingException(sprintf('Expected the encoded hex value to be %s characters long.', $length)); } diff --git a/src/FreeDSx/Sasl/Encoder/EncoderInterface.php b/src/FreeDSx/Sasl/Encoder/EncoderInterface.php index 66ddf68..47e44c6 100644 --- a/src/FreeDSx/Sasl/Encoder/EncoderInterface.php +++ b/src/FreeDSx/Sasl/Encoder/EncoderInterface.php @@ -1,5 +1,7 @@ */ -class PlainEncoder implements EncoderInterface +final readonly class PlainEncoder implements EncoderInterface { - /** - * {@inheritDoc} - */ - public function encode(Message $message, SaslContext $context): string - { + public function encode( + Message $message, + SaslContext $context, + ): string { if (!$message->has('authzid')) { throw new SaslEncodingException('The PLAIN message must contain a authzid.'); } if (!$message->has('authcid')) { - throw new SaslEncodingException('The PLAIN message must contain a authzid.'); + throw new SaslEncodingException('The PLAIN message must contain a authcid.'); } if (!$message->has('password')) { throw new SaslEncodingException('The PLAIN message must contain a password.'); } - $authzid = $this->validate($message->get('authzid')); - $authcid = $this->validate($message->get('authcid')); - $password = $this->validate($message->get('password')); + $authzid = $this->validate((string) $message->get('authzid')); + $authcid = $this->validate((string) $message->get('authcid')); + $password = $this->validate((string) $message->get('password')); return $authzid . "\x00" . $authcid . "\x00" . $password; } - /** - * {@inheritDoc} - */ - public function decode(string $data, SaslContext $context): Message - { + public function decode( + string $data, + SaslContext $context, + ): Message { if (preg_match('/^([^\x0]+)\x00([^\x0]+)\x00([^\x0]+)$/', $data, $matches) === 0) { throw new SaslEncodingException('The PLAIN message data is malformed.'); } @@ -59,9 +59,9 @@ public function decode(string $data, SaslContext $context): Message ]); } - protected function validate(string $data): string + private function validate(string $data): string { - if (strpos($data,"\x00") !== false) { + if (str_contains($data, "\x00")) { throw new SaslEncodingException('PLAIN mechanism data cannot contain a null character.'); } diff --git a/src/FreeDSx/Sasl/Encoder/ScramEncoder.php b/src/FreeDSx/Sasl/Encoder/ScramEncoder.php index 9fa89a6..5d5dba1 100644 --- a/src/FreeDSx/Sasl/Encoder/ScramEncoder.php +++ b/src/FreeDSx/Sasl/Encoder/ScramEncoder.php @@ -1,5 +1,7 @@ */ -class ScramEncoder implements EncoderInterface +final readonly class ScramEncoder implements EncoderInterface { /** * Matches the GS2 header at the start of a client-first-message. @@ -33,14 +35,13 @@ class ScramEncoder implements EncoderInterface private const MATCH_GS2_HEADER = '/^(n|y|p=[^,]*),,(.*)$/'; /** - * {@inheritDoc} - * * Encodes a Message into a SCRAM comma-separated attr=value string. */ - public function encode(Message $message, SaslContext $context): string - { + public function encode( + Message $message, + SaslContext $context, + ): string { $parts = []; - foreach ($message->toArray() as $attr => $value) { $parts[] = $attr . '=' . $value; } @@ -49,8 +50,6 @@ public function encode(Message $message, SaslContext $context): string } /** - * {@inheritDoc} - * * Decodes a SCRAM message string into a Message object. * * For client-first-messages the GS2 header is extracted and stored under the @@ -58,12 +57,14 @@ public function encode(Message $message, SaslContext $context): string * * @throws SaslEncodingException */ - public function decode(string $data, SaslContext $context): Message - { + public function decode( + string $data, + SaslContext $context, + ): Message { $message = new Message(); # Detect and strip the GS2 header present in client-first-messages. - if (preg_match(self::MATCH_GS2_HEADER, $data, $matches)) { + if (preg_match(self::MATCH_GS2_HEADER, $data, $matches) === 1) { $message->set('gs2-header', $matches[1] . ',,'); $data = $matches[2]; } @@ -75,7 +76,7 @@ public function decode(string $data, SaslContext $context): Message if ($eqPos === false) { throw new SaslEncodingException(sprintf( 'Malformed SCRAM message attribute (no "=" separator found): "%s".', - $part + $part, )); } diff --git a/src/FreeDSx/Sasl/Exception/SaslBufferException.php b/src/FreeDSx/Sasl/Exception/SaslBufferException.php index 5837881..132ea9d 100644 --- a/src/FreeDSx/Sasl/Exception/SaslBufferException.php +++ b/src/FreeDSx/Sasl/Exception/SaslBufferException.php @@ -1,5 +1,7 @@ */ -class SaslBufferException extends SaslException +final class SaslBufferException extends SaslException { } diff --git a/src/FreeDSx/Sasl/Exception/SaslEncodingException.php b/src/FreeDSx/Sasl/Exception/SaslEncodingException.php index c3b0828..fdb0241 100644 --- a/src/FreeDSx/Sasl/Exception/SaslEncodingException.php +++ b/src/FreeDSx/Sasl/Exception/SaslEncodingException.php @@ -1,5 +1,7 @@ */ -class SaslEncodingException extends SaslException +final class SaslEncodingException extends SaslException { } diff --git a/src/FreeDSx/Sasl/Exception/SaslException.php b/src/FreeDSx/Sasl/Exception/SaslException.php index 417eef5..71c30b3 100644 --- a/src/FreeDSx/Sasl/Exception/SaslException.php +++ b/src/FreeDSx/Sasl/Exception/SaslException.php @@ -1,5 +1,7 @@ */ -class SaslException extends \Exception +class SaslException extends Exception { } diff --git a/src/FreeDSx/Sasl/Factory/DigestMD5MessageFactory.php b/src/FreeDSx/Sasl/Factory/DigestMD5MessageFactory.php index 08391f8..27fa3d7 100644 --- a/src/FreeDSx/Sasl/Factory/DigestMD5MessageFactory.php +++ b/src/FreeDSx/Sasl/Factory/DigestMD5MessageFactory.php @@ -1,5 +1,7 @@ */ -class DigestMD5MessageFactory implements MessageFactoryInterface +final readonly class DigestMD5MessageFactory implements MessageFactoryInterface { use NonceTrait; - public const MESSAGE_CLIENT_RESPONSE = 1; - - public const MESSAGE_SERVER_CHALLENGE = 2; - - public const MESSAGE_SERVER_RESPONSE = 3; - - protected const CIPHER_LIST = [ + /** + * Maps the supported OpenSSL cipher names to the SASL cipher wire names. + */ + private const OPENSSL_TO_SASL_CIPHERS = [ 'rc4' => 'rc4', 'des-ede-cbc' => 'des', 'des-ede3-cbc' => '3des', @@ -45,53 +44,53 @@ class DigestMD5MessageFactory implements MessageFactoryInterface * * Byte length represented here. Bumping it up quite a bit from the recommendation. Can be controlled via an option. */ - protected const NONCE_SIZE = 32; - - /** - * @var bool - */ - protected $hasOpenSsl; - - public function __construct() - { - $this->hasOpenSsl = extension_loaded('openssl'); + private const NONCE_SIZE = 32; + + public function create( + DigestMD5MessageType $type, + array $options = [], + ?Message $received = null, + ): Message { + return match (true) { + $type === DigestMD5MessageType::CLIENT_RESPONSE && $received !== null + => $this->generateClientResponse($options, $received), + $type === DigestMD5MessageType::SERVER_RESPONSE + => $this->generateServerResponse($options), + $type === DigestMD5MessageType::SERVER_CHALLENGE + => $this->generateServerChallenge($options), + default => throw new SaslException( + 'Unable to generate message. Unrecognized message type / received message combination.', + ), + }; } /** - * {@inheritDoc} + * @param array $options + * + * @throws SaslException */ - public function create(int $type, array $options = [], ?Message $received = null): Message - { - if ($type === self::MESSAGE_CLIENT_RESPONSE && $received !== null) { - return $this->generateClientResponse($options, $received); - } elseif ($type === self::MESSAGE_SERVER_RESPONSE) { - return $this->generateServerResponse($options); - } elseif ($type === self::MESSAGE_SERVER_CHALLENGE) { - return $this->generateServerChallenge($options); - } else { - throw new SaslException( - 'Unable to generate message. Unrecognized message type / received message combination.' - ); - } - } - - protected function generateServerChallenge(array $options): Message + private function generateServerChallenge(array $options): Message { $challenge = new Message(); $challenge->set('algorithm', 'md5-sess'); - $challenge->set('nonce', $options['nonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE)); + $challenge->set('nonce', $options['nonce'] ?? $this->generateNonce((int) ($options['nonce_size'] ?? self::NONCE_SIZE))); $challenge->set('qop', $this->generateAvailableQops($options)); $challenge->set('realm', $options['realm'] ?? $_SERVER['USERDOMAIN'] ?? gethostname()); $challenge->set('maxbuf', $options['maxbuf'] ?? '65536'); $challenge->set('charset', 'utf-8'); - if (in_array('auth-conf', $challenge->get('qop'))) { + if (in_array('auth-conf', (array) $challenge->get('qop'), true)) { $challenge->set('cipher', $this->getAvailableCiphers($options)); } return $challenge; } - protected function generateServerResponse(array $options): Message + /** + * @param array $options + * + * @throws SaslException + */ + private function generateServerResponse(array $options): Message { $rspAuth = $options['rspauth'] ?? null; if ($rspAuth === null) { @@ -102,22 +101,26 @@ protected function generateServerResponse(array $options): Message } /** + * @param array $options + * * @throws SaslException */ - protected function generateClientResponse(array $options, Message $challenge): Message - { + private function generateClientResponse( + array $options, + Message $challenge, + ): Message { $response = new Message(); $qop = isset($options['qop']) ? (string) $options['qop'] : null; $response->set('algorithm', 'md5-sess'); $response->set('nonce', $challenge->get('nonce')); - $response->set('cnonce', $options['cnonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE)); + $response->set('cnonce', $options['cnonce'] ?? $this->generateNonce((int) ($options['nonce_size'] ?? self::NONCE_SIZE))); $response->set('nc', $options['nc'] ?? 1); $response->set('qop', $this->selectQopFromChallenge($challenge, $qop)); $response->set('username', $options['username'] ?? $this->getCurrentUser()); $response->set('realm', $options['realm'] ?? $this->getRealmFromChallenge($challenge)); - $response->set('digest-uri', $options['digest-uri'] ?? $this->getDigestUri($options, $response, $challenge)); - if ($response->get('qop') === 'auth-conf' && !$response->get('cipher')) { + $response->set('digest-uri', $options['digest-uri'] ?? $this->getDigestUri($options, $response)); + if ($response->get('qop') === 'auth-conf' && $response->get('cipher') === null) { $this->setCipherForChallenge($options, $response, $challenge); } @@ -125,10 +128,14 @@ protected function generateClientResponse(array $options, Message $challenge): M } /** + * @param array $options + * * @throws SaslException */ - protected function getDigestUri(array $options, Message $response, Message $challenge): string - { + private function getDigestUri( + array $options, + Message $response, + ): string { if (!isset($options['service'])) { throw new SaslException('If you do not supply a digest-uri, you must specify a service.'); } @@ -136,18 +143,23 @@ protected function getDigestUri(array $options, Message $response, Message $chal return sprintf( '%s/%s', $options['service'], - $response->get('realm') + $response->get('realm'), ); } - protected function generateAvailableQops(array $options): array + /** + * @param array $options + * + * @return string[] + */ + private function generateAvailableQops(array $options): array { $qop = ['auth']; - if (isset($options['use_integrity']) && $options['use_integrity'] === true) { + if (($options['use_integrity'] ?? false) === true) { $qop[] = 'auth-int'; } - if (isset($options['use_privacy']) && $options['use_privacy'] === true) { + if (($options['use_privacy'] ?? false) === true) { $qop[] = 'auth-conf'; } @@ -157,8 +169,10 @@ protected function generateAvailableQops(array $options): array /** * @throws SaslException */ - protected function selectQopFromChallenge(Message $challenge, ?string $qop): string - { + private function selectQopFromChallenge( + Message $challenge, + ?string $qop, + ): string { $available = (array) ($challenge->get('qop') ?? []); /* Per the RFC: This directive is optional; if not present it defaults to "auth". */ if (count($available) === 0) { @@ -174,20 +188,28 @@ protected function selectQopFromChallenge(Message $challenge, ?string $qop): str throw new SaslException(sprintf( 'None of the qop values are recognized, or the one you selected is not available. Available methods are: %s', - implode($available) + implode(', ', $available), )); } - protected function getAvailableCiphers(array $options): array + /** + * @param array $options + * + * @return string[] + * + * @throws SaslException + */ + private function getAvailableCiphers(array $options): array { - $cipherList = self::CIPHER_LIST; + $cipherList = self::OPENSSL_TO_SASL_CIPHERS; # If specific cipher(s) are already wanted, filter the list... if (isset($options['cipher'])) { $wanted = (array) $options['cipher']; - $cipherList = array_filter($cipherList, function ($name) use ($wanted) { - return in_array($name, $wanted, true); - }); + $cipherList = array_filter( + $cipherList, + static fn (string $name): bool => in_array($name, $wanted, true), + ); } # Now filter it based on what ciphers actually show as available in OpenSSL... @@ -198,7 +220,7 @@ protected function getAvailableCiphers(array $options): array } } - if (empty($cipherList)) { + if (count($cipherList) === 0) { throw new SaslException('There are no available ciphers for auth-conf.'); } @@ -206,16 +228,29 @@ protected function getAvailableCiphers(array $options): array } /** + * @param array $options + * * @throws SaslException */ - protected function setCipherForChallenge(array $options, Message $response, Message $challenge): void - { + private function setCipherForChallenge( + array $options, + Message $response, + Message $challenge, + ): void { if (!$challenge->has('cipher')) { throw new SaslException('The client requested auth-conf, but the challenge contains no ciphers.'); } - $ciphers = $challenge->get('cipher'); + $ciphers = (array) $challenge->get('cipher'); # If we are requesting a specific cipher, then only check that one... - $toCheck = isset($options['cipher']) ? (array) $options['cipher'] : ['3des', 'des', 'rc4', 'rc4-56', 'rc4-40', ]; + $toCheck = isset($options['cipher']) + ? (array) $options['cipher'] + : array_map(static fn (DigestMD5Cipher $c): string => $c->value, [ + DigestMD5Cipher::THREE_DES, + DigestMD5Cipher::DES, + DigestMD5Cipher::RC4, + DigestMD5Cipher::RC4_56, + DigestMD5Cipher::RC4_40, + ]); $selected = null; foreach ($toCheck as $selection) { @@ -227,19 +262,23 @@ protected function setCipherForChallenge(array $options, Message $response, Mess if ($selected === null) { throw new SaslException(sprintf( 'No recognized ciphers were offered in the challenge: %s', - implode(', ', $ciphers) + implode(', ', $ciphers), )); } $response->set('cipher', $selected); } - protected function getCurrentUser(): string + /** + * @throws SaslException + */ + private function getCurrentUser(): string { if (isset($_SERVER['USERNAME'])) { - return $_SERVER['USERNAME']; - } elseif (isset($_SERVER['USER'])) { - return $_SERVER['USER']; + return (string) $_SERVER['USERNAME']; + } + if (isset($_SERVER['USER'])) { + return (string) $_SERVER['USER']; } throw new SaslException('Unable to determine a username for the response. You must supply a username.'); @@ -247,8 +286,10 @@ protected function getCurrentUser(): string /** * Only populate if one realm is provided in the challenge. If more than one exists then the client must supply this. + * + * @throws SaslException */ - protected function getRealmFromChallenge(Message $challenge): string + private function getRealmFromChallenge(Message $challenge): string { if (!$challenge->has('realm')) { throw new SaslException('Unable to determine a realm for the response.'); @@ -256,6 +297,6 @@ protected function getRealmFromChallenge(Message $challenge): string $realms = (array) $challenge->get('realm'); $selected = array_pop($realms); - return $selected; + return (string) $selected; } } diff --git a/src/FreeDSx/Sasl/Factory/DigestMD5MessageType.php b/src/FreeDSx/Sasl/Factory/DigestMD5MessageType.php new file mode 100644 index 0000000..26da04e --- /dev/null +++ b/src/FreeDSx/Sasl/Factory/DigestMD5MessageType.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Factory; + +/** + * Message types produced by the DIGEST-MD5 message factory. + * + * @author Chad Sikorra + */ +enum DigestMD5MessageType: int +{ + case CLIENT_RESPONSE = 1; + case SERVER_CHALLENGE = 2; + case SERVER_RESPONSE = 3; +} diff --git a/src/FreeDSx/Sasl/Factory/MessageFactoryInterface.php b/src/FreeDSx/Sasl/Factory/MessageFactoryInterface.php index 4b9950d..7625bd6 100644 --- a/src/FreeDSx/Sasl/Factory/MessageFactoryInterface.php +++ b/src/FreeDSx/Sasl/Factory/MessageFactoryInterface.php @@ -1,5 +1,7 @@ $options + * * @throws SaslException */ - public function create(int $type, array $options = [], ?Message $received = null): Message; + public function create( + DigestMD5MessageType $type, + array $options = [], + ?Message $received = null, + ): Message; } diff --git a/src/FreeDSx/Sasl/Factory/NonceTrait.php b/src/FreeDSx/Sasl/Factory/NonceTrait.php index f021146..0d037a1 100644 --- a/src/FreeDSx/Sasl/Factory/NonceTrait.php +++ b/src/FreeDSx/Sasl/Factory/NonceTrait.php @@ -1,5 +1,7 @@ getMessage() - ), $e->getCode(), $e); + throw new SaslException( + sprintf('Unable to generate the nonce: %s', $e->getMessage()), + $e->getCode(), + $e, + ); } } -} \ No newline at end of file +} diff --git a/src/FreeDSx/Sasl/Mechanism/AnonymousMechanism.php b/src/FreeDSx/Sasl/Mechanism/AnonymousMechanism.php index 60b7a6b..36bc6d9 100644 --- a/src/FreeDSx/Sasl/Mechanism/AnonymousMechanism.php +++ b/src/FreeDSx/Sasl/Mechanism/AnonymousMechanism.php @@ -1,5 +1,7 @@ */ -class AnonymousMechanism implements MechanismInterface +final readonly class AnonymousMechanism implements MechanismInterface { - public const NAME = 'ANONYMOUS'; - - /** - * {@inheritDoc} - */ - public function getName(): string + public function getName(): MechanismName { - return self::NAME; + return MechanismName::ANONYMOUS; } - /** - * {@inheritDoc} - */ public function challenge(bool $serverMode = false): ChallengeInterface { return new AnonymousChallenge($serverMode); } - /** - * {@inheritDoc} - */ public function securityStrength(): SecurityStrength { return new SecurityStrength( - false, - false, - false, - false, - 0 + supportsIntegrity: false, + supportsPrivacy: false, + supportsAuth: false, + isPlainTextAuth: false, + maxKeySize: 0, ); } - /** - * {@inheritDoc} - */ public function securityLayer(): SecurityLayerInterface { throw new SaslException('The ANONYMOUS mechanism does not support a security layer.'); diff --git a/src/FreeDSx/Sasl/Mechanism/CramMD5Mechanism.php b/src/FreeDSx/Sasl/Mechanism/CramMD5Mechanism.php index b94340f..8345014 100644 --- a/src/FreeDSx/Sasl/Mechanism/CramMD5Mechanism.php +++ b/src/FreeDSx/Sasl/Mechanism/CramMD5Mechanism.php @@ -1,5 +1,7 @@ */ -class CramMD5Mechanism implements MechanismInterface +final readonly class CramMD5Mechanism implements MechanismInterface { - public const NAME = 'CRAM-MD5'; - - /** - * {@inheritDoc} - */ - public function getName(): string + public function getName(): MechanismName { - return self::NAME; + return MechanismName::CRAM_MD5; } - /** - * {@inheritDoc} - */ public function challenge(bool $serverMode = false): ChallengeInterface { return new CramMD5Challenge($serverMode); } - /** - * {@inheritDoc} - */ public function securityStrength(): SecurityStrength { return new SecurityStrength( - false, - false, - true, - false, - 0 + supportsIntegrity: false, + supportsPrivacy: false, + supportsAuth: true, + isPlainTextAuth: false, + maxKeySize: 0, ); } - /** - * {@inheritDoc} - */ public function securityLayer(): SecurityLayerInterface { throw new SaslException('CRAM-MD5 does not support a security layer.'); diff --git a/src/FreeDSx/Sasl/Mechanism/DigestMD5Mechanism.php b/src/FreeDSx/Sasl/Mechanism/DigestMD5Mechanism.php index e4b7a1e..e266946 100644 --- a/src/FreeDSx/Sasl/Mechanism/DigestMD5Mechanism.php +++ b/src/FreeDSx/Sasl/Mechanism/DigestMD5Mechanism.php @@ -1,5 +1,7 @@ */ -class DigestMD5Mechanism implements MechanismInterface +final readonly class DigestMD5Mechanism implements MechanismInterface { - public const NAME = 'DIGEST-MD5'; - - protected const A2_SERVER = ':'; + private const A2_SERVER = ':'; - protected const A2_CLIENT = 'AUTHENTICATE:'; + private const A2_CLIENT = 'AUTHENTICATE:'; - /** - * {@inheritDoc} - */ - public function getName(): string + public function getName(): MechanismName { - return self::NAME; + return MechanismName::DIGEST_MD5; } - /** - * {@inheritDoc} - */ public function challenge(bool $serverMode = false): ChallengeInterface { - $challenge = new DigestMD5Challenge(); - - return $challenge; + return new DigestMD5Challenge($serverMode); } - /** - * {@inheritDoc} - */ public function securityStrength(): SecurityStrength { return new SecurityStrength( - true, - true, - true, - false, - 128 + supportsIntegrity: true, + supportsPrivacy: true, + supportsAuth: true, + isPlainTextAuth: false, + maxKeySize: 128, ); } - /** - * {@inheritDoc} - */ public function securityLayer(): SecurityLayerInterface { return new DigestMD5SecurityLayer(); } - public function __toString() + public function __toString(): string { - return self::NAME; + return MechanismName::DIGEST_MD5->value; } /** @@ -97,31 +83,33 @@ public function __toString() * * @throws SaslException */ - public static function computeResponse(string $password, Message $challenge, Message $response, bool $useServerMode = false): string - { + public static function computeResponse( + string $password, + Message $challenge, + Message $response, + bool $useServerMode = false, + ): string { $a1 = self::computeA1($password, $challenge, $response); $qop = $response->get('qop'); $digestUri = $response->get('digest-uri'); $a2 = $useServerMode ? self::A2_SERVER : self::A2_CLIENT; - if ($qop === 'auth') { - $a2 .= $digestUri; - } elseif ($qop === 'auth-int' || $qop === 'auth-conf') { - $a2 .= $digestUri . ':00000000000000000000000000000000'; - } else { - throw new SaslException('The qop directive must be one of: auth, auth-conf, auth-int.'); - } + $a2 .= match ($qop) { + 'auth' => $digestUri, + 'auth-int', 'auth-conf' => $digestUri . ':00000000000000000000000000000000', + default => throw new SaslException('The qop directive must be one of: auth, auth-conf, auth-int.'), + }; $a2 = hash('md5', $a2); return hash('md5', sprintf( '%s:%s:%s:%s:%s:%s', $a1, $challenge->get('nonce'), - str_pad(dechex($response->get('nc')), 8, '0', STR_PAD_LEFT), + str_pad(dechex((int) $response->get('nc')), 8, '0', STR_PAD_LEFT), $response->get('cnonce'), $response->get('qop'), - $a2 + $a2, )); } @@ -135,21 +123,23 @@ public static function computeResponse(string $password, Message $challenge, Mes * * A1 = { H( { username-value, ":", realm-value, ":", passwd } ), * ":", nonce-value, ":", cnonce-value } - * */ - public static function computeA1(string $password, Message $challenge, Message $response): string - { + public static function computeA1( + string $password, + Message $challenge, + Message $response, + ): string { $a1 = hash('md5', sprintf( '%s:%s:%s', $response->get('username'), $response->get('realm'), - $password + $password, ), true); $a1 = sprintf( '%s:%s:%s', $a1, $challenge->get('nonce'), - $response->get('cnonce') + $response->get('cnonce'), ); if ($response->has('authzid')) { $a1 .= ':' . $response->get('authzid'); diff --git a/src/FreeDSx/Sasl/Mechanism/HashAlgorithm.php b/src/FreeDSx/Sasl/Mechanism/HashAlgorithm.php new file mode 100644 index 0000000..bb7845d --- /dev/null +++ b/src/FreeDSx/Sasl/Mechanism/HashAlgorithm.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Mechanism; + +/** + * Hash algorithms supported by SCRAM. + * + * @author Chad Sikorra + */ +enum HashAlgorithm: string +{ + case SHA1 = 'sha1'; + case SHA224 = 'sha224'; + case SHA256 = 'sha256'; + case SHA384 = 'sha384'; + case SHA512 = 'sha512'; + case SHA3_512 = 'sha3-512'; +} diff --git a/src/FreeDSx/Sasl/Mechanism/MechanismInterface.php b/src/FreeDSx/Sasl/Mechanism/MechanismInterface.php index 3dca62f..ae21359 100644 --- a/src/FreeDSx/Sasl/Mechanism/MechanismInterface.php +++ b/src/FreeDSx/Sasl/Mechanism/MechanismInterface.php @@ -1,5 +1,7 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Mechanism; + +/** + * SASL mechanism names. + * + * @author Chad Sikorra + */ +enum MechanismName: string +{ + case ANONYMOUS = 'ANONYMOUS'; + case PLAIN = 'PLAIN'; + case CRAM_MD5 = 'CRAM-MD5'; + case DIGEST_MD5 = 'DIGEST-MD5'; + case SCRAM_SHA1 = 'SCRAM-SHA-1'; + case SCRAM_SHA1_PLUS = 'SCRAM-SHA-1-PLUS'; + case SCRAM_SHA224 = 'SCRAM-SHA-224'; + case SCRAM_SHA224_PLUS = 'SCRAM-SHA-224-PLUS'; + case SCRAM_SHA256 = 'SCRAM-SHA-256'; + case SCRAM_SHA256_PLUS = 'SCRAM-SHA-256-PLUS'; + case SCRAM_SHA384 = 'SCRAM-SHA-384'; + case SCRAM_SHA384_PLUS = 'SCRAM-SHA-384-PLUS'; + case SCRAM_SHA512 = 'SCRAM-SHA-512'; + case SCRAM_SHA512_PLUS = 'SCRAM-SHA-512-PLUS'; + case SCRAM_SHA3_512 = 'SCRAM-SHA3-512'; + case SCRAM_SHA3_512_PLUS = 'SCRAM-SHA3-512-PLUS'; + + public function isScram(): bool + { + return str_starts_with( + $this->value, + 'SCRAM-', + ); + } + + public function isChannelBinding(): bool + { + return str_ends_with( + $this->value, + '-PLUS', + ); + } + + /** + * The hash algorithm backing this SCRAM variant, or null for non-SCRAM mechanisms. + */ + public function hashAlgorithm(): ?HashAlgorithm + { + return match ($this) { + self::SCRAM_SHA1, self::SCRAM_SHA1_PLUS => HashAlgorithm::SHA1, + self::SCRAM_SHA224, self::SCRAM_SHA224_PLUS => HashAlgorithm::SHA224, + self::SCRAM_SHA256, self::SCRAM_SHA256_PLUS => HashAlgorithm::SHA256, + self::SCRAM_SHA384, self::SCRAM_SHA384_PLUS => HashAlgorithm::SHA384, + self::SCRAM_SHA512, self::SCRAM_SHA512_PLUS => HashAlgorithm::SHA512, + self::SCRAM_SHA3_512, self::SCRAM_SHA3_512_PLUS => HashAlgorithm::SHA3_512, + default => null, + }; + } +} diff --git a/src/FreeDSx/Sasl/Mechanism/PlainMechanism.php b/src/FreeDSx/Sasl/Mechanism/PlainMechanism.php index b4edbe3..4a3afe6 100644 --- a/src/FreeDSx/Sasl/Mechanism/PlainMechanism.php +++ b/src/FreeDSx/Sasl/Mechanism/PlainMechanism.php @@ -1,5 +1,7 @@ */ -class PlainMechanism implements MechanismInterface +final readonly class PlainMechanism implements MechanismInterface { - public const NAME = 'PLAIN'; - - /** - * {@inheritDoc} - */ - public function getName(): string + public function getName(): MechanismName { - return self::NAME; + return MechanismName::PLAIN; } - /** - * {@inheritDoc} - */ public function challenge(bool $serverMode = false): ChallengeInterface { return new PlainChallenge($serverMode); } - /** - * {@inheritDoc} - */ public function securityStrength(): SecurityStrength { return new SecurityStrength( - false, - false, - true, - true, - 0 + supportsIntegrity: false, + supportsPrivacy: false, + supportsAuth: true, + isPlainTextAuth: true, + maxKeySize: 0, ); } - /** - * {@inheritDoc} - */ public function securityLayer(): SecurityLayerInterface { - throw new SaslException('The PLAIN mechanism does not support a security layer.'); + throw new SaslException('The PLAIN mechanism does not support a security layer.'); } } diff --git a/src/FreeDSx/Sasl/Mechanism/ScramMechanism.php b/src/FreeDSx/Sasl/Mechanism/ScramMechanism.php index 4dec710..7465fe3 100644 --- a/src/FreeDSx/Sasl/Mechanism/ScramMechanism.php +++ b/src/FreeDSx/Sasl/Mechanism/ScramMechanism.php @@ -1,5 +1,7 @@ */ -class ScramMechanism implements MechanismInterface +final readonly class ScramMechanism implements MechanismInterface { - public const SHA1 = 'SCRAM-SHA-1'; - public const SHA1_PLUS = 'SCRAM-SHA-1-PLUS'; - public const SHA224 = 'SCRAM-SHA-224'; - public const SHA224_PLUS = 'SCRAM-SHA-224-PLUS'; - public const SHA256 = 'SCRAM-SHA-256'; - public const SHA256_PLUS = 'SCRAM-SHA-256-PLUS'; - public const SHA384 = 'SCRAM-SHA-384'; - public const SHA384_PLUS = 'SCRAM-SHA-384-PLUS'; - public const SHA512 = 'SCRAM-SHA-512'; - public const SHA512_PLUS = 'SCRAM-SHA-512-PLUS'; - public const SHA3_512 = 'SCRAM-SHA3-512'; - public const SHA3_512_PLUS = 'SCRAM-SHA3-512-PLUS'; - - public const VARIANTS = [ - self::SHA1, - self::SHA1_PLUS, - self::SHA224, - self::SHA224_PLUS, - self::SHA256, - self::SHA256_PLUS, - self::SHA384, - self::SHA384_PLUS, - self::SHA512, - self::SHA512_PLUS, - self::SHA3_512, - self::SHA3_512_PLUS, - ]; - - /** - * Maps SASL mechanism name to [php_hash_algorithm, channel_binding]. - * - * The PHP algorithm names are used directly with hash(), hash_hmac(), and hash_pbkdf2(). - */ - private const MECHANISMS = [ - self::SHA1 => ['sha1', false], - self::SHA1_PLUS => ['sha1', true], - self::SHA224 => ['sha224', false], - self::SHA224_PLUS => ['sha224', true], - self::SHA256 => ['sha256', false], - self::SHA256_PLUS => ['sha256', true], - self::SHA384 => ['sha384', false], - self::SHA384_PLUS => ['sha384', true], - self::SHA512 => ['sha512', false], - self::SHA512_PLUS => ['sha512', true], - self::SHA3_512 => ['sha3-512', false], - self::SHA3_512_PLUS => ['sha3-512', true], - ]; - - /** - * @var string - */ - private $name; - - /** - * @var string - */ - private $hashAlgorithm; + private readonly HashAlgorithm $hashAlgorithm; /** - * @var bool + * @throws SaslException if the provided MechanismName is not a SCRAM variant. */ - private $channelBinding; - - /** - * @throws SaslException - */ - public function __construct(string $name) + public function __construct(private readonly MechanismName $mechanism) { - if (!isset(self::MECHANISMS[$name])) { + $hashAlgorithm = $mechanism->hashAlgorithm(); + if ($hashAlgorithm === null) { throw new SaslException(sprintf( - 'The SCRAM variant "%s" is not supported. Supported variants: %s.', - $name, - implode(', ', array_keys(self::MECHANISMS)) + 'The mechanism "%s" is not a SCRAM variant.', + $mechanism->value, )); } - - $this->name = $name; - [ - $this->hashAlgorithm, - $this->channelBinding, - ] = self::MECHANISMS[$name]; + $this->hashAlgorithm = $hashAlgorithm; } - /** - * {@inheritDoc} - */ - public function getName(): string + public function getName(): MechanismName { - return $this->name; + return $this->mechanism; } - /** - * {@inheritDoc} - */ public function challenge(bool $serverMode = false): ChallengeInterface { return new ScramChallenge( $serverMode, $this->hashAlgorithm, - $this->channelBinding + $this->mechanism->isChannelBinding(), ); } - /** - * {@inheritDoc} - */ public function securityStrength(): SecurityStrength { return new SecurityStrength( - false, - false, - true, - false, - 0 + supportsIntegrity: false, + supportsPrivacy: false, + supportsAuth: true, + isPlainTextAuth: false, + maxKeySize: 0, ); } /** - * {@inheritDoc} - * * SCRAM does not provide a post-authentication security layer. * * @throws SaslException @@ -160,12 +81,12 @@ public function securityLayer(): SecurityLayerInterface { throw new SaslException(sprintf( 'The %s mechanism does not provide a security layer.', - $this->name + $this->mechanism->value, )); } public function __toString(): string { - return $this->name; + return $this->mechanism->value; } } diff --git a/src/FreeDSx/Sasl/MechanismSelector.php b/src/FreeDSx/Sasl/MechanismSelector.php index 31f6ec2..f2c6f6c 100644 --- a/src/FreeDSx/Sasl/MechanismSelector.php +++ b/src/FreeDSx/Sasl/MechanismSelector.php @@ -1,5 +1,7 @@ */ -class MechanismSelector +final readonly class MechanismSelector { - /** - * @var MechanismInterface[] - */ - protected $mechanisms; - /** * @param MechanismInterface[] $mechanisms */ - public function __construct(array $mechanisms) + public function __construct(private readonly array $mechanisms) { - $this->mechanisms = $mechanisms; } /** + * @param MechanismName[] $choices + * @param array $options + * * @throws SaslException */ - public function select(array $choices = [], array $options = []): MechanismInterface - { - $mechs = $this->getAvailableMechsFromChoices($choices, $options); + public function select( + array $choices = [], + array $options = [], + ): MechanismInterface { + $available = $this->getAvailableMechsFromChoices($choices, $options); - return $this->selectMech($mechs); + return $this->selectMech($available); } /** @@ -59,10 +61,10 @@ public function select(array $choices = [], array $options = []): MechanismInter * some vendors have different ways of calculating it, making it somewhat less meaningful. * * @param MechanismInterface[] $available - * @return MechanismInterface + * * @throws SaslException */ - protected function selectMech(array $available): MechanismInterface + private function selectMech(array $available): MechanismInterface { # We sort the mechanisms by: # 1. Key size first. @@ -70,17 +72,20 @@ protected function selectMech(array $available): MechanismInterface # 3. Integrity / signing support. # 4. Authentication support (anonymous should be at the bottom...) # 5. Authentication that is not plain text. - usort($available, function (MechanismInterface $mechA, MechanismInterface $mechB) { - $strengthA = $mechA->securityStrength(); - $strengthB = $mechB->securityStrength(); - - # We need to invert the boolean checks, expect for plain text (logic is already inverted). - return (int)!$strengthA->supportsPrivacy() <=> (int)!$strengthB->supportsPrivacy() - ?: (int)!$strengthA->supportsIntegrity() <=> (int)!$strengthB->supportsIntegrity() - ?: (int)$strengthA->maxKeySize() <=> (int)$strengthB->maxKeySize() - ?: (int)!$strengthA->supportsAuth() <=> (int)!$strengthB->supportsAuth() - ?: (int)$strengthA->isPlainTextAuth() <=> (int)$strengthB->isPlainTextAuth(); - }); + usort( + $available, + static function (MechanismInterface $a, MechanismInterface $b): int { + $sa = $a->securityStrength(); + $sb = $b->securityStrength(); + + # We need to invert the boolean checks, expect for plain text (logic is already inverted). + return (int) !$sa->supportsPrivacy() <=> (int) !$sb->supportsPrivacy() + ?: (int) !$sa->supportsIntegrity() <=> (int) !$sb->supportsIntegrity() + ?: $sa->maxKeySize() <=> $sb->maxKeySize() + ?: (int) !$sa->supportsAuth() <=> (int) !$sb->supportsAuth() + ?: (int) $sa->isPlainTextAuth() <=> (int) $sb->isPlainTextAuth(); + }, + ); $first = array_shift($available); if (!$first instanceof MechanismInterface) { @@ -91,13 +96,17 @@ protected function selectMech(array $available): MechanismInterface } /** - * @param string[] $choices - * @param array $options + * @param MechanismName[] $choices + * @param array $options + * * @return MechanismInterface[] + * * @throws SaslException */ - protected function getAvailableMechsFromChoices(array $choices, array $options): array - { + private function getAvailableMechsFromChoices( + array $choices, + array $options, + ): array { $available = $this->filterFromChoices($choices); if (count($available) === 0) { $this->throwException($choices); @@ -112,19 +121,20 @@ protected function getAvailableMechsFromChoices(array $choices, array $options): } /** - * @param string[] $choices + * @param MechanismName[] $choices + * * @return MechanismInterface[] */ - protected function filterFromChoices(array $choices): array + private function filterFromChoices(array $choices): array { if (count($choices) === 0) { return $this->mechanisms; } $filtered = []; - foreach ($this->mechanisms as $choice) { - if (in_array($choice->getName(), $choices, true)) { - $filtered[] = $choice; + foreach ($this->mechanisms as $mech) { + if (in_array($mech->getName(), $choices, true)) { + $filtered[] = $mech; } } @@ -133,45 +143,54 @@ protected function filterFromChoices(array $choices): array /** * @param MechanismInterface[] $available - * @param array $options + * @param array $options + * * @return MechanismInterface[] */ - protected function filterOptions(array $available, array $options): array - { - $useIntegrity = $options['use_integrity'] ?? false; - $usePrivacy = $options['use_privacy'] ?? false; + private function filterOptions( + array $available, + array $options, + ): array { + $useIntegrity = (bool) ($options['use_integrity'] ?? false); + $usePrivacy = (bool) ($options['use_privacy'] ?? false); # Don't need to worry whether it supports integrity or privacy... - if ($usePrivacy === false && $useIntegrity === false) { + if (!$useIntegrity && !$usePrivacy) { return $available; } $supportsInt = []; $supportsPriv = []; # Filter to those only those supporting integrity... - if ($useIntegrity === true) { - $supportsInt = array_filter($available, function (MechanismInterface $mech) use ($useIntegrity) { - return $mech->securityStrength()->supportsIntegrity() === $useIntegrity; - }); + if ($useIntegrity) { + $supportsInt = array_filter( + $available, + static fn (MechanismInterface $mech): bool => $mech->securityStrength()->supportsIntegrity(), + ); } # Filter to those only supporting privacy... - if ($usePrivacy === true) { - $supportsPriv = array_filter($available, function (MechanismInterface $mech) use ($usePrivacy) { - return $mech->securityStrength()->supportsPrivacy() === $usePrivacy; - }); + if ($usePrivacy) { + $supportsPriv = array_filter( + $available, + static fn (MechanismInterface $mech): bool => $mech->securityStrength()->supportsPrivacy(), + ); } return array_unique(array_merge($supportsInt, $supportsPriv), SORT_REGULAR); } /** + * @param MechanismName[] $choices + * * @throws SaslException */ - protected function throwException(array $choices = []): void + private function throwException(array $choices = []): void { + $names = array_map(static fn (MechanismName $name): string => $name->value, $choices); + throw new SaslException(sprintf( 'No supported SASL mechanisms could be found from the provided choices: %s', - implode(', ', $choices) + implode(', ', $names), )); } } diff --git a/src/FreeDSx/Sasl/Message.php b/src/FreeDSx/Sasl/Message.php index d151897..4793538 100644 --- a/src/FreeDSx/Sasl/Message.php +++ b/src/FreeDSx/Sasl/Message.php @@ -1,5 +1,7 @@ */ -class Message implements Countable, IteratorAggregate +/** + * @implements IteratorAggregate + */ +final class Message implements Countable, IteratorAggregate { /** - * @var array + * @param array $data */ - protected $data = []; - - public function __construct(array $data = []) + public function __construct(private array $data = []) { - $this->data = $data; } - /** - * @return mixed - */ - public function get(string $name) + public function get(string $name): mixed { return $this->data[$name] ?? null; } @@ -44,12 +45,10 @@ public function has(string $name): bool return array_key_exists($name, $this->data); } - /** - * @param mixed $value - * @return Message - */ - public function set(string $name, $value): self - { + public function set( + string $name, + mixed $value, + ): self { $this->data[$name] = $value; return $this; @@ -60,13 +59,19 @@ public function count(): int return count($this->data); } + /** + * @return array + */ public function toArray(): array { return $this->data; } - public function getIterator(): \Traversable + /** + * @return Traversable + */ + public function getIterator(): Traversable { - return new \ArrayIterator($this->data); + return new ArrayIterator($this->data); } } diff --git a/src/FreeDSx/Sasl/Sasl.php b/src/FreeDSx/Sasl/Sasl.php index 5cfe9f3..3e00014 100644 --- a/src/FreeDSx/Sasl/Sasl.php +++ b/src/FreeDSx/Sasl/Sasl.php @@ -1,5 +1,7 @@ */ -class Sasl +final class Sasl { /** - * @var MechanismInterface[] + * @var array keyed by MechanismName->value */ - protected $mechanisms = []; + private array $mechanisms = []; /** - * @var array + * @var array{supported: MechanismName[]} */ - protected $options = [ - 'supported' => [] - ]; + private array $options; + /** + * @param array{supported?: MechanismName[]} $options + */ public function __construct(array $options = []) { - $this->options = $options + $this->options; + $this->options = $options + ['supported' => []]; $this->initMechs(); } /** * Get a mechanism object by its name. + * + * @throws SaslException */ - public function get(string $mechanism): MechanismInterface + public function get(MechanismName $mechanism): MechanismInterface { - $mech = $this->mechanisms[$mechanism] ?? null; - + $mech = $this->mechanisms[$mechanism->value] ?? null; if ($mech === null) { - throw new SaslException('The mechanism "%s" is not supported.'); + throw new SaslException(sprintf( + 'The mechanism "%s" is not supported.', + $mechanism->value, + )); } return $mech; @@ -61,33 +69,27 @@ public function get(string $mechanism): MechanismInterface /** * Whether or not the mechanism is supported. */ - public function supports(string $mechanism): bool + public function supports(MechanismName $mechanism): bool { - return isset($this->mechanisms()[$mechanism]); + return isset($this->mechanisms[$mechanism->value]); } /** * Add a mechanism object. - * - * @return Sasl */ public function add(MechanismInterface $mechanism): self { - $this->mechanisms[$mechanism->getName()] = $mechanism; + $this->mechanisms[$mechanism->getName()->value] = $mechanism; return $this; } /** * Remove a mechanism by its name. - * - * @return Sasl */ - public function remove(string $mechanism): self + public function remove(MechanismName $mechanism): self { - if (isset($this->mechanisms[$mechanism])) { - unset($this->mechanisms[$mechanism]); - } + unset($this->mechanisms[$mechanism->value]); return $this; } @@ -95,44 +97,53 @@ public function remove(string $mechanism): self /** * Given an array of mechanism names, and optional options, select the best supported mechanism available. * - * @param string[] $choices array of mechanisms by their name - * @param array $options array of options (ie. ['use_integrity' => true]) - * @return MechanismInterface the mechanism selected. - * @throws SaslException if no supported mechanism could be found. + * @param MechanismName[] $choices array of mechanisms by their name + * @param array $options array of options (ie. ['use_integrity' => true]) + * + * @throws SaslException */ - public function select(array $choices = [], array $options = []): MechanismInterface - { - $selector = new MechanismSelector($this->mechanisms()); - - return $selector->select($choices, $options); + public function select( + array $choices = [], + array $options = [], + ): MechanismInterface { + return (new MechanismSelector($this->mechanisms)) + ->select($choices, $options); } /** - * @return MechanismInterface[] + * @return array */ public function mechanisms(): array { return $this->mechanisms; } - protected function initMechs(): void + private function initMechs(): void { $this->mechanisms = [ - DigestMD5Mechanism::NAME => new DigestMD5Mechanism(), - CramMD5Mechanism::NAME => new CramMD5Mechanism(), - PlainMechanism::NAME => new PlainMechanism(), - AnonymousMechanism::NAME => new AnonymousMechanism(), + MechanismName::DIGEST_MD5->value => new DigestMD5Mechanism(), + MechanismName::CRAM_MD5->value => new CramMD5Mechanism(), + MechanismName::PLAIN->value => new PlainMechanism(), + MechanismName::ANONYMOUS->value => new AnonymousMechanism(), ]; - foreach (ScramMechanism::VARIANTS as $variant) { - $this->mechanisms[$variant] = new ScramMechanism($variant); + foreach (MechanismName::cases() as $case) { + if ($case->isScram()) { + $this->mechanisms[$case->value] = new ScramMechanism($case); + } + } + + if (count($this->options['supported']) === 0) { + return; } - if (is_array($this->options['supported']) && !empty($this->options['supported'])) { - foreach (array_keys($this->mechanisms) as $mechName) { - if (!in_array($mechName, $this->options['supported'], true)) { - unset($this->mechanisms[$mechName]); - } + $allowed = array_map( + static fn (MechanismName $name): string => $name->value, + $this->options['supported'], + ); + foreach (array_keys($this->mechanisms) as $key) { + if (!in_array($key, $allowed, true)) { + unset($this->mechanisms[$key]); } } } diff --git a/src/FreeDSx/Sasl/SaslBuffer.php b/src/FreeDSx/Sasl/SaslBuffer.php index 709a054..e3e7c16 100644 --- a/src/FreeDSx/Sasl/SaslBuffer.php +++ b/src/FreeDSx/Sasl/SaslBuffer.php @@ -1,5 +1,7 @@ */ -class SaslBuffer +final class SaslBuffer { /** * Wraps the buffer by pre-pending the data length. diff --git a/src/FreeDSx/Sasl/SaslContext.php b/src/FreeDSx/Sasl/SaslContext.php index 526c088..99e5eb6 100644 --- a/src/FreeDSx/Sasl/SaslContext.php +++ b/src/FreeDSx/Sasl/SaslContext.php @@ -1,5 +1,7 @@ */ -class SaslContext +final class SaslContext { - /** - * @var bool - */ - protected $isAuthenticated = false; + private bool $isAuthenticated = false; - /** - * @var bool - */ - protected $isComplete = false; + private bool $isComplete = false; - /** - * @var bool - */ - protected $hasSecurityLayer = false; + private bool $hasSecurityLayer = false; - /** - * @var bool - */ - protected $isServerMode = false; + private bool $isServerMode = false; - /** - * @var array - */ - protected $data = []; + private ?string $response = null; /** - * @var string|null + * @param array $data */ - protected $response; - - public function __construct(array $data = []) + public function __construct(private array $data = []) { - $this->data = $data; } - /** - * @param bool $isComplete - * @return $this - */ - public function setIsComplete(bool $isComplete) + public function setIsComplete(bool $isComplete): self { $this->isComplete = $isComplete; return $this; } - /** - * Whether or not the challenge sequence is complete. - */ public function isComplete(): bool { return $this->isComplete; } - /** - * @param bool $isServerMode - * @return $this - */ - public function setIsServerMode(bool $isServerMode) + public function setIsServerMode(bool $isServerMode): self { $this->isServerMode = $isServerMode; return $this; } - /** - * Whether or not we are in the context of server mode for the exchange. - */ public function isServerMode(): bool { return $this->isServerMode; } - /** - * Whether or not the message exchange has resulted is being successfully authenticated. - */ public function isAuthenticated(): bool { return $this->isAuthenticated; } - /** - * Set whether or not the current context has authenticated. - * - * @return $this - */ public function setIsAuthenticated(bool $isAuthenticated): self { $this->isAuthenticated = $isAuthenticated; @@ -111,17 +73,11 @@ public function setIsAuthenticated(bool $isAuthenticated): self return $this; } - /** - * Whether or not a security layer was negotiated as part of the message exchange. - */ public function hasSecurityLayer(): bool { return $this->hasSecurityLayer; } - /** - * Set whether or not the current context has negotiated a security layer. - */ public function setHasSecurityLayer(bool $hasSecurityLayer): self { $this->hasSecurityLayer = $hasSecurityLayer; @@ -129,19 +85,12 @@ public function setHasSecurityLayer(bool $hasSecurityLayer): self return $this; } - /** - * The next response, if any, to send in the challenge. - */ public function getResponse(): ?string { return $this->response; } - /** - * @param string|null $response - * @return $this - */ - public function setResponse(?string $response) + public function setResponse(?string $response): self { $this->response = $response; @@ -149,7 +98,7 @@ public function setResponse(?string $response) } /** - * Get any mechanism specific data that needs to be stored as part of the message exchange. + * @return array */ public function getData(): array { @@ -157,42 +106,29 @@ public function getData(): array } /** - * @param array $data - * @return $this + * @param array $data */ - public function setData(array $data) + public function setData(array $data): self { $this->data = $data; return $this; } - /** - * Check if a SASL specific data piece exists. - */ public function has(string $key): bool { return isset($this->data[$key]); } - /** - * Get a SASL specific data piece. f - * - * @return mixed - */ - public function get(string $key) + public function get(string $key): mixed { return $this->data[$key] ?? null; } - /** - * Set the value of a SASL specific data piece. - * - * @param mixed $value - * @return $this - */ - public function set(string $key, $value): self - { + public function set( + string $key, + mixed $value, + ): self { $this->data[$key] = $value; return $this; diff --git a/src/FreeDSx/Sasl/SaslPrep.php b/src/FreeDSx/Sasl/SaslPrep.php index 186fdb3..bde46e0 100644 --- a/src/FreeDSx/Sasl/SaslPrep.php +++ b/src/FreeDSx/Sasl/SaslPrep.php @@ -1,5 +1,7 @@ */ -class SaslPrep +final class SaslPrep { /** * Maps named capture group names (from PROHIBITED_PATTERN) to their exception messages. diff --git a/src/FreeDSx/Sasl/Security/DigestMD5Cipher.php b/src/FreeDSx/Sasl/Security/DigestMD5Cipher.php new file mode 100644 index 0000000..d6dc08c --- /dev/null +++ b/src/FreeDSx/Sasl/Security/DigestMD5Cipher.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Security; + +/** + * DIGEST-MD5 ciphers. + * + * @author Chad Sikorra + */ +enum DigestMD5Cipher: string +{ + case THREE_DES = '3des'; + case DES = 'des'; + case RC4 = 'rc4'; + case RC4_40 = 'rc4-40'; + case RC4_56 = 'rc4-56'; + + public function opensslName(): string + { + return match ($this) { + self::THREE_DES => 'des-ede3-cbc', + self::DES => 'des-ede-cbc', + self::RC4 => 'rc4', + self::RC4_40 => 'rc4-40', + self::RC4_56 => 'rc4-56', + }; + } + + public function blockSize(): int + { + return match ($this) { + self::THREE_DES, self::DES => 8, + self::RC4, self::RC4_40, self::RC4_56 => 1, + }; + } + + /** + * The "n" key length used to derive the encryption key from H(A1). + */ + public function keyLength(): int + { + return match ($this) { + self::THREE_DES, self::DES, self::RC4 => 16, + self::RC4_40 => 5, + self::RC4_56 => 7, + }; + } +} diff --git a/src/FreeDSx/Sasl/Security/DigestMD5SecurityLayer.php b/src/FreeDSx/Sasl/Security/DigestMD5SecurityLayer.php index 5375b17..ee4d35b 100644 --- a/src/FreeDSx/Sasl/Security/DigestMD5SecurityLayer.php +++ b/src/FreeDSx/Sasl/Security/DigestMD5SecurityLayer.php @@ -1,5 +1,7 @@ */ -class DigestMD5SecurityLayer implements SecurityLayerInterface +final readonly class DigestMD5SecurityLayer implements SecurityLayerInterface { - protected const MAXBUF = 65536; + private const MAXBUF = 65536; - protected const KCC_MC = 'Digest H(A1) to client-to-server sealing key magic constant'; + private const KCC_MC = 'Digest H(A1) to client-to-server sealing key magic constant'; - protected const KCS_MC = 'Digest H(A1) to server-to-client sealing key magic constant'; + private const KCS_MC = 'Digest H(A1) to server-to-client sealing key magic constant'; - protected const KIC_MC = 'Digest session key to client-to-server signing key magic constant'; + private const KIC_MC = 'Digest session key to client-to-server signing key magic constant'; - protected const KIS_MC = 'Digest session key to server-to-client signing key magic constant'; + private const KIS_MC = 'Digest session key to server-to-client signing key magic constant'; /** * RFC2831 Section 2.3 / 2.4 */ - protected const MESSAGE_TYPE = 1; + private const MESSAGE_TYPE = 1; - /** - * Cipher specific details related to the encryption / decryption process. - */ - protected const CIPHERS = [ - '3des' => [ - 'block_size' => 8, - 'kcn' => 16, - 'cipher' => 'des-ede3-cbc', - ], - 'des' => [ - 'block_size' => 8, - 'kcn' => 16, - 'cipher' => 'des-ede-cbc' - ], - 'rc4' => [ - 'block_size' => 1, - 'kcn' => 16, - 'cipher' => 'rc4', - ], - 'rc4-40' => [ - 'block_size' => 1, - 'kcn' => 5, - 'cipher' => 'rc4-40', - ], - 'rc4-56' => [ - 'block_size' => 1, - 'kcn' => 7, - 'cipher' => 'rc4-56', - ], - ]; - - /** - * {@inheritDoc} - */ - public function wrap(string $data, SaslContext $context): string - { + public function wrap( + string $data, + SaslContext $context, + ): string { $qop = $context->get('qop'); - if ($qop === 'auth-conf') { - $wrapped = $this->encrypt($data, $context); - } elseif ($qop === 'auth-int') { - $wrapped = $this->sign($data, $context); - } else { - throw new SaslException(sprintf('The qop option "%s" is not recognized as a security layer.', $qop)); - } + $wrapped = match ($qop) { + 'auth-conf' => $this->encrypt($data, $context), + 'auth-int' => $this->sign($data, $context), + default => throw new SaslException(sprintf( + 'The qop option "%s" is not recognized as a security layer.', + (string) $qop, + )), + }; $this->validateBufferLength($wrapped, $context); - $context->set('seqnumsnt', $context->get('seqnumsnt') + 1); + $context->set('seqnumsnt', ((int) $context->get('seqnumsnt')) + 1); return $wrapped; } - /** - * {@inheritDoc} - */ - public function unwrap(string $data, SaslContext $context): string - { + public function unwrap( + string $data, + SaslContext $context, + ): string { $qop = $context->get('qop'); $this->validateBufferLength($data, $context); - if ($qop === 'auth-conf') { - $unwrapped = $this->decrypt($data, $context); - } elseif ($qop === 'auth-int') { - $unwrapped = $this->verify($data, $context); - } else { - throw new SaslException(sprintf('The qop option "%s" is not recognized as a security layer.', $qop)); - } - $context->set('seqnumrcv', $context->get('seqnumrcv') + 1); + $unwrapped = match ($qop) { + 'auth-conf' => $this->decrypt($data, $context), + 'auth-int' => $this->verify($data, $context), + default => throw new SaslException(sprintf( + 'The qop option "%s" is not recognized as a security layer.', + (string) $qop, + )), + }; + $context->set('seqnumrcv', ((int) $context->get('seqnumrcv')) + 1); return $unwrapped; } @@ -110,8 +81,10 @@ public function unwrap(string $data, SaslContext $context): string /** * @throws SaslException */ - protected function decrypt(string $data, SaslContext $context): string - { + private function decrypt( + string $data, + SaslContext $context, + ): string { # At the very least we are expect 16 bytes. 10 for the actual MAC, 4 for the seqnum, 2 for the msgtype. if (strlen($data) < 16) { throw new SaslException('The data to decrypt must be at least 16 bytes.'); @@ -121,38 +94,43 @@ protected function decrypt(string $data, SaslContext $context): string if (self::MESSAGE_TYPE !== $receivedMsgType) { throw new SaslException(sprintf( 'The received message type of "%s" was unexpected.', - $receivedMsgType + $receivedMsgType, )); } $seqnum = $context->get('seqnumrcv'); if (!is_int($seqnum) || $seqnum !== $receivedSeqNum) { throw new SaslException(sprintf( 'The received sequence number was unexpected. Expected %s, but got %s.', - $seqnum, - $receivedSeqNum + (string) $seqnum, + (string) $receivedSeqNum, )); } - $cipher = $context->get('cipher'); - $a1 = $context->get('a1'); + $cipher = $this->resolveCipher($context); + $a1 = (string) $context->get('a1'); $isServerMode = $context->isServerMode(); - $this->validateCipher($cipher); $encrypted = substr($data, 0, -6); # Inverted selection of constants here and for $mcKi, as this would be the receiving end. $mcKc = $isServerMode ? self::KCC_MC : self::KCS_MC; $kc = $this->generateKeyKc($a1, $cipher, $mcKc); [$iv, $key] = $this->generateKeyAndIV($cipher, $kc); - $data = openssl_decrypt($encrypted, self::CIPHERS[$cipher]['cipher'], $key, OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, $iv); - if ($data === false) { + $decrypted = openssl_decrypt( + $encrypted, + $cipher->opensslName(), + $key, + OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, + $iv, + ); + if ($decrypted === false) { throw new SaslException('Failed the decrypt the message.'); } - $message = substr($data, 0, -10); - if (self::CIPHERS[$cipher]['block_size'] > 1) { - $message = $this->removePadding($message, self::CIPHERS[$cipher]['block_size']); + $message = substr($decrypted, 0, -10); + if ($cipher->blockSize() > 1) { + $message = $this->removePadding($message, $cipher->blockSize()); } - $receivedMac = substr($data, -10); + $receivedMac = substr($decrypted, -10); $mcKi = $isServerMode ? self::KIC_MC : self::KIS_MC; $ki = $this->generateKeyKi($a1, $mcKi); $expectedMac = substr($this->generateMACBlock($ki, $message, $seqnum), 0, 10); @@ -169,13 +147,14 @@ protected function decrypt(string $data, SaslContext $context): string * * @throws SaslException */ - protected function encrypt(string $data, SaslContext $context): string - { - $cipher = $context->get('cipher'); - $a1 = $context->get('a1'); + private function encrypt( + string $data, + SaslContext $context, + ): string { + $cipher = $this->resolveCipher($context); + $a1 = (string) $context->get('a1'); $isServerMode = $context->isServerMode(); - $seqnum = $context->get('seqnumsnt'); - $this->validateCipher($cipher); + $seqnum = (int) $context->get('seqnumsnt'); $mcKc = $isServerMode ? self::KCS_MC : self::KCC_MC; $kc = $this->generateKeyKc($a1, $cipher, $mcKc); @@ -188,9 +167,18 @@ protected function encrypt(string $data, SaslContext $context): string $ending = substr($macBlock, 10); $macBlock = substr($macBlock, 0, 10); - $padding = $this->generatePadding($data, self::CIPHERS[$cipher]['block_size']); + $padding = $this->generatePadding($data, $cipher->blockSize()); [$iv, $key] = $this->generateKeyAndIV($cipher, $kc); - $encrypted = openssl_encrypt($data . $padding . $macBlock, self::CIPHERS[$cipher]['cipher'], $key, OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, $iv); + $encrypted = openssl_encrypt( + $data . $padding . $macBlock, + $cipher->opensslName(), + $key, + OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, + $iv, + ); + if ($encrypted === false) { + throw new SaslException('Failed to encrypt the message.'); + } return $encrypted . $ending; } @@ -198,8 +186,10 @@ protected function encrypt(string $data, SaslContext $context): string /** * @throws SaslException */ - protected function removePadding(string $message, int $blockSize): string - { + private function removePadding( + string $message, + int $blockSize, + ): string { $padOrd = isset($message[-1]) ? ord($message[-1]) : 0; $padRaw = $message[-1] ?? ''; @@ -215,30 +205,36 @@ protected function removePadding(string $message, int $blockSize): string } } - return substr($message, 0, strlen($message) - $padOrd); + return substr($message, 0, strlen($message) - $padOrd); } /** * @throws SaslException */ - protected function validateCipher(string $cipher): void + private function resolveCipher(SaslContext $context): DigestMD5Cipher { - if (!isset(self::CIPHERS[$cipher])) { - throw new SaslException(sprintf( - 'The cipher "%s" is not supported.', - $cipher - )); + $cipher = $context->get('cipher'); + if (!is_string($cipher)) { + throw new SaslException('A cipher value is required for auth-conf.'); } + $resolved = DigestMD5Cipher::tryFrom($cipher); + if ($resolved === null) { + throw new SaslException(sprintf('The cipher "%s" is not supported.', $cipher)); + } + + return $resolved; } /** * Append a signed MAC to the message. */ - protected function sign(string $message, SaslContext $context): string - { - $seqnum = $context->get('seqnumsnt'); + private function sign( + string $message, + SaslContext $context, + ): string { + $seqnum = (int) $context->get('seqnumsnt'); $mc = $context->isServerMode() ? self::KIS_MC : self::KIC_MC; - $ki = $this->generateKeyKi($context->get('a1'), $mc); + $ki = $this->generateKeyKi((string) $context->get('a1'), $mc); $macBlock = $this->generateMACBlock($ki, $message, $seqnum); return $message . $macBlock; @@ -249,18 +245,20 @@ protected function sign(string $message, SaslContext $context): string * * @throws SaslException */ - protected function verify(string $data, SaslContext $context): string - { + private function verify( + string $data, + SaslContext $context, + ): string { $receivedMac = substr($data, -16); if (strlen($receivedMac) !== 16) { throw new SaslException('Expected at least 16 bytes of data for the MAC.'); } - $seqnum = $context->get('seqnumrcv'); + $seqnum = (int) $context->get('seqnumrcv'); $message = substr($data, 0, -16); # Inverted selection of constant here, as this would be the receiving end. $mc = $context->isServerMode() ? self::KIC_MC : self::KIS_MC; - $ki = $this->generateKeyKi($context->get('a1'), $mc); + $ki = $this->generateKeyKi((string) $context->get('a1'), $mc); $expectedMac = $this->generateMACBlock($ki, $message, $seqnum); if ($receivedMac !== $expectedMac) { @@ -277,8 +275,10 @@ protected function verify(string $data, SaslContext $context): string * number of padding bytes, such that total length of the encrypted part of the message is a multiple of the * blocksize. */ - protected function generatePadding(string $data, int $blockSize): string - { + private function generatePadding( + string $data, + int $blockSize, + ): string { if ($blockSize === 1) { return ''; } @@ -296,14 +296,17 @@ protected function generatePadding(string $data, int $blockSize): string * * MAC(Ki, SeqNum, msg) = (HMAC(Ki, {SeqNum, msg})[0..9], 0x0001, SeqNum) */ - protected function generateMACBlock(string $key, string $message, int $seqNum): string - { + private function generateMACBlock( + string $key, + string $message, + int $seqNum, + ): string { /** 4-byte sequence number in network byte order. */ - $seqNum = pack('N', $seqNum); - $macBlock = substr(hash_hmac('md5', $seqNum . $message, $key, true), 0, 10); + $packedSeq = pack('N', $seqNum); + $macBlock = substr(hash_hmac('md5', $packedSeq . $message, $key, true), 0, 10); /** a 2-byte message type number in network byte order with value 1 */ $macBlock .= "\x00\x01"; - $macBlock .= $seqNum; + $macBlock .= $packedSeq; return $macBlock; } @@ -313,10 +316,11 @@ protected function generateMACBlock(string $key, string $message, int $seqNum): * * Kic = MD5({H(A1), "Digest session key to client-to-server signing key magic constant"}) * Kis = MD5({H(A1), "Digest session key to server-to-client signing key magic constant"}) - * */ - protected function generateKeyKi(string $a1, string $mc): string - { + private function generateKeyKi( + string $a1, + string $mc, + ): string { return hash('md5', $a1 . $mc, true); } @@ -328,24 +332,32 @@ protected function generateKeyKi(string $a1, string $mc): string * * Where the key size is determined by "n" above. */ - protected function generateKeyKc(string $a1, string $cipher, string $mc): string - { + private function generateKeyKc( + string $a1, + DigestMD5Cipher $cipher, + string $mc, + ): string { return hash( 'md5', - substr($a1, 0, self::CIPHERS[$cipher]['kcn']) . $mc, - true + substr($a1, 0, $cipher->keyLength()) . $mc, + true, ); } - protected function generateKeyAndIV(string $cipher, string $kc): array - { + /** + * @return array{0: string, 1: string} IV and key + */ + private function generateKeyAndIV( + DigestMD5Cipher $cipher, + string $kc, + ): array { # No IV and all of the kc for the key with RC4 types - if ($cipher === 'rc4' || $cipher === 'rc4-40' || $cipher === 'rc4-56') { + if ($cipher === DigestMD5Cipher::RC4 || $cipher === DigestMD5Cipher::RC4_40 || $cipher === DigestMD5Cipher::RC4_56) { return ['', $kc]; } $iv = substr($kc, 8, 8); - if ($cipher === 'des') { + if ($cipher === DigestMD5Cipher::DES) { $key = $this->expandDesKey(substr($kc, 0, 7)); } else { $key1 = substr($kc, 0, 7); @@ -364,35 +376,35 @@ protected function generateKeyAndIV(string $cipher, string $kc): array * We need to manually expand the 7-byte DES keys to 8-bytes. This shifts the first 7 bytes into the high seven bits. * This also ignores parity, as it should not be strictly necessary and just adds additional complexity here. */ - protected function expandDesKey(string $key): string + private function expandDesKey(string $key): string { $bytes = []; - for ($i = 0; $i < 7; $i++) { $bytes[$i] = ord($key[$i]); } - return - chr($bytes[0] & 0xfe) . - chr(($bytes[0] << 7) | ($bytes[1] >> 1)) . - chr(($bytes[1] << 6) | ($bytes[2] >> 2)) . - chr(($bytes[2] << 5) | ($bytes[3] >> 3)) . - chr(($bytes[3] << 4) | ($bytes[4] >> 4)) . - chr(($bytes[4] << 3) | ($bytes[5] >> 5)) . - chr(($bytes[5] << 2) | ($bytes[6] >> 6)) . - chr($bytes[6] << 1); + return chr($bytes[0] & 0xfe) + . chr(($bytes[0] << 7) | ($bytes[1] >> 1)) + . chr(($bytes[1] << 6) | ($bytes[2] >> 2)) + . chr(($bytes[2] << 5) | ($bytes[3] >> 3)) + . chr(($bytes[3] << 4) | ($bytes[4] >> 4)) + . chr(($bytes[4] << 3) | ($bytes[5] >> 5)) + . chr(($bytes[5] << 2) | ($bytes[6] >> 6)) + . chr($bytes[6] << 1); } /** * @throws SaslException */ - protected function validateBufferLength(string $data, SaslContext $context): void - { + private function validateBufferLength( + string $data, + SaslContext $context, + ): void { $maxbuf = $context->has('maxbuf') ? (int) $context->get('maxbuf') : self::MAXBUF; if (strlen($data) > $maxbuf) { throw new SaslException(sprintf( 'The wrapped buffer exceeds the maxbuf length of %s', - $maxbuf + $maxbuf, )); } } diff --git a/src/FreeDSx/Sasl/Security/SecurityLayerInterface.php b/src/FreeDSx/Sasl/Security/SecurityLayerInterface.php index 1d057ff..06794f1 100644 --- a/src/FreeDSx/Sasl/Security/SecurityLayerInterface.php +++ b/src/FreeDSx/Sasl/Security/SecurityLayerInterface.php @@ -1,5 +1,7 @@ */ -class SecurityStrength +final readonly class SecurityStrength { - /** - * @var bool - */ - protected $supportsIntegrity; - - /** - * @var bool - */ - protected $supportsPrivacy; - - /** - * @var bool - */ - protected $supportsAuth; - - /** - * @var bool - */ - protected $isPlainTextAuth; - - /** - * @var int - */ - protected $maxKeySize; - public function __construct( - bool $supportsIntegrity, - bool $supportsPrivacy, - bool $supportsAuth, - bool $isPlainTextAuth, - int $maxKeySize + private readonly bool $supportsIntegrity, + private readonly bool $supportsPrivacy, + private readonly bool $supportsAuth, + private readonly bool $isPlainTextAuth, + private readonly int $maxKeySize, ) { - $this->supportsIntegrity = $supportsIntegrity; - $this->supportsPrivacy = $supportsPrivacy; - $this->supportsAuth = $supportsAuth; - $this->isPlainTextAuth = $isPlainTextAuth; - $this->maxKeySize = $maxKeySize; } public function supportsIntegrity(): bool diff --git a/tests/unit/Challenge/AnonymousChallengeTest.php b/tests/unit/Challenge/AnonymousChallengeTest.php new file mode 100644 index 0000000..f0703e9 --- /dev/null +++ b/tests/unit/Challenge/AnonymousChallengeTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Challenge\AnonymousChallenge; +use PHPUnit\Framework\TestCase; + +final class AnonymousChallengeTest extends TestCase +{ + private AnonymousChallenge $challenge; + + protected function setUp(): void + { + $this->challenge = new AnonymousChallenge(); + } + + public function testTheClientChallenge(): void + { + $context = $this->challenge->challenge(null, ['username' => 'foo', 'password' => 'bar']); + + self::assertSame('foo', $context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + } + + public function testTheClientChallengeWithSpecificTrace(): void + { + $context = $this->challenge->challenge(null, ['trace' => 'foobar', 'password' => 'bar']); + + self::assertSame('foobar', $context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + } + + public function testTheServerResponseToTheClientWhenSuccessful(): void + { + $challenge = new AnonymousChallenge(true); + $context = $challenge->challenge('foo'); + + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + self::assertNull($context->getResponse()); + } +} diff --git a/tests/unit/Challenge/CramMD5ChallengeTest.php b/tests/unit/Challenge/CramMD5ChallengeTest.php new file mode 100644 index 0000000..b94f51f --- /dev/null +++ b/tests/unit/Challenge/CramMD5ChallengeTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Challenge\CramMD5Challenge; +use PHPUnit\Framework\TestCase; + +final class CramMD5ChallengeTest extends TestCase +{ + private CramMD5Challenge $challenge; + + protected function setUp(): void + { + $this->challenge = new CramMD5Challenge(); + } + + public function testChallengeWithFromClientWithServerChallenge(): void + { + $context = $this->challenge->challenge('', ['username' => 'foo', 'password' => 'bar']); + + self::assertSame('foo e23c893e9de272d4a75e646265768a45', $context->getResponse()); + self::assertTrue($context->isComplete()); + } + + public function testChallengeWithFromServerWithClientWrongResponse(): void + { + $serverChallenge = new CramMD5Challenge(true); + $validate = static fn (string $username, string $challenge): string => ''; + $serverChallenge->challenge(); + $context = $serverChallenge->challenge('foo e23c893e9de272d4a75e646265768a45', ['password' => $validate]); + + self::assertFalse($context->isAuthenticated()); + self::assertTrue($context->isComplete()); + } + + public function testChallengeWithFromServerWithClientCorrectResponse(): void + { + $serverChallenge = new CramMD5Challenge(true); + $validate = static fn (string $username, string $challenge): string => hash_hmac('md5', $challenge, 'bar'); + // The challenge option is the raw nonce; the encoder wraps it as . + $serverChallenge->challenge(null, ['challenge' => 'foobar']); + $context = $serverChallenge->challenge('foo e23c893e9de272d4a75e646265768a45', ['password' => $validate]); + + self::assertTrue($context->isAuthenticated()); + self::assertTrue($context->isComplete()); + } + + public function testPasswordCallableReceivesEncodedChallengeMatchingWhatClientUses(): void + { + $serverChallenge = new CramMD5Challenge(true); + + $challengePassedToCallable = null; + $validate = static function (string $username, string $challenge) use (&$challengePassedToCallable): string { + $challengePassedToCallable = $challenge; + + return hash_hmac('md5', $challenge, 'bar'); + }; + + // Server generates challenge with raw nonce 'foobar'; encoder sends '' to client. + $serverContext = $serverChallenge->challenge(null, ['challenge' => 'foobar']); + $encodedChallenge = $serverContext->getResponse(); + self::assertSame('', $encodedChallenge); + + // Client computes HMAC over the encoded challenge exactly as received. + $clientChallenge = new CramMD5Challenge(false); + $clientContext = $clientChallenge->challenge( + $encodedChallenge, + ['username' => 'foo', 'password' => 'bar'], + ); + + // Server validates the client response. + $serverChallenge->challenge( + $clientContext->getResponse(), + ['password' => $validate], + ); + + // The callable must receive the encoded form so it can compute the same digest as the client. + self::assertSame('', $challengePassedToCallable); + self::assertTrue($serverChallenge->challenge()->isAuthenticated()); + } + + public function testChallengeWithFromServerWithInitialChallenge(): void + { + $serverChallenge = new CramMD5Challenge(true); + $context = $serverChallenge->challenge(); + + self::assertNotNull($context->getResponse()); + self::assertFalse($context->isComplete()); + } +} diff --git a/tests/unit/Challenge/DigestMD5ChallengeTest.php b/tests/unit/Challenge/DigestMD5ChallengeTest.php new file mode 100644 index 0000000..335f9fa --- /dev/null +++ b/tests/unit/Challenge/DigestMD5ChallengeTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Challenge\DigestMD5Challenge; +use FreeDSx\Sasl\Encoder\DigestMD5Encoder; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\SaslContext; +use PHPUnit\Framework\TestCase; + +final class DigestMD5ChallengeTest extends TestCase +{ + private DigestMD5Challenge $challenge; + + private DigestMD5Encoder $encoder; + + private string $challengeData; + + private string $rspAuthData; + + private string $rspAuthSuccess; + + protected function setUp(): void + { + $this->challenge = new DigestMD5Challenge(); + $this->encoder = new DigestMD5Encoder(); + $this->challengeData = (string) hex2bin('6e6f6e63653d225a7a6b307578374b674f56506d4e37644c6f66476d394b714e6573626e43585263494151536d787551456b3d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'); + $this->rspAuthData = (string) hex2bin('727370617574683d3333323832383833643136316261376337656631616166633337666162393438'); + $this->rspAuthSuccess = (string) hex2bin('727370617574683d3333323832383833643136316261376337656631616166633337666162393438'); + } + + public function testThatClientResponseIsGeneratedFromChallenge(): void + { + $response = $this->encoder->decode( + (string) $this->challenge->challenge($this->challengeData, ['use_privacy' => true])->getResponse(), + (new SaslContext())->setIsServerMode(true), + ); + + self::assertSame('auth-conf', $response->get('qop')); + self::assertNotEmpty($response->get('response'), 'The response value is empty.'); + } + + public function testThatClientResponseUsesSpecificHostForDigestUriIfRequested(): void + { + $response = $this->encoder->decode( + (string) $this->challenge->challenge( + $this->challengeData, + ['use_privacy' => true, 'host' => 'foo.bar.local'], + )->getResponse(), + (new SaslContext())->setIsServerMode(true), + ); + + self::assertSame('ldap/foo.bar.local', $response->get('digest-uri')); + } + + public function testSecurityLayerIsInitializedProperlyInTheContext(): void + { + $options = [ + 'use_privacy' => true, + 'cipher' => 'rc4', + 'username' => 'WillifoA', + 'password' => 'Password1', + ]; + $this->challenge->challenge( + $this->challengeData, + ['cnonce' => 'tQvBPuDOMTE=', 'nonce' => 'Zzk0ux7KgOVPmN7dLofGm9KqNesbnCXRcIAQSmxuQEk='] + $options, + ); + $context = $this->challenge->challenge( + $this->rspAuthSuccess, + ['cnonce' => 'tQvBPuDOMTE=', 'nonce' => 'Zzk0ux7KgOVPmN7dLofGm9KqNesbnCXRcIAQSmxuQEk='] + $options, + ); + + self::assertTrue($context->isAuthenticated(), 'Context should be authenticated, but was not.'); + self::assertTrue($context->hasSecurityLayer(), 'Context should have a security layer, but it does not.'); + self::assertSame(0, $context->get('seqnumsnt')); + self::assertSame(0, $context->get('seqnumrcv')); + } + + public function testVerificationIsDoneOnTheServerResponse(): void + { + $this->challenge->challenge($this->challengeData); + $context = $this->challenge->challenge($this->rspAuthData); + + self::assertFalse($context->isAuthenticated()); + self::assertFalse($context->hasSecurityLayer()); + } + + public function testAnExceptionIsThrownIfTheRspauthIsReceivedOutOfOrder(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge($this->rspAuthData); + } + + public function testGenerateServerChallengeForClientInServerMode(): void + { + $serverChallenge = new DigestMD5Challenge(true); + $challenge = $this->encoder->decode( + (string) $serverChallenge->challenge(null, ['use_integrity' => true, 'use_privacy' => true])->getResponse(), + new SaslContext(), + ); + + self::assertSame(['auth', 'auth-int', 'auth-conf'], $challenge->get('qop')); + self::assertNotEmpty($challenge->get('cipher'), 'The cipher value is empty.'); + self::assertNotEmpty($challenge->get('nonce'), 'The nonce must be generated.'); + } + + public function testGenerateServerResponseToClientResponse(): void + { + self::markTestSkipped('Test does not work on newer PHP due to deprecated ciphers it seems. Needs investigation.'); + } + + public function testIsCompleteIsNotTrueUntilTheRspauthIsProcessed(): void + { + $context = $this->challenge->challenge($this->challengeData); + self::assertFalse($context->isComplete()); + + $context = $this->challenge->challenge($this->rspAuthData); + self::assertTrue($context->isComplete()); + } +} diff --git a/tests/unit/Challenge/PlainChallengeTest.php b/tests/unit/Challenge/PlainChallengeTest.php new file mode 100644 index 0000000..ce2dc08 --- /dev/null +++ b/tests/unit/Challenge/PlainChallengeTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Challenge\PlainChallenge; +use PHPUnit\Framework\TestCase; + +final class PlainChallengeTest extends TestCase +{ + private PlainChallenge $challenge; + + protected function setUp(): void + { + $this->challenge = new PlainChallenge(); + } + + public function testTheClientChallenge(): void + { + $context = $this->challenge->challenge(null, ['username' => 'foo', 'password' => 'bar']); + + self::assertSame("foo\x00foo\x00bar", $context->getResponse()); + self::assertTrue($context->isComplete()); + } + + public function testTheServerResponseToTheClientWhenSuccessful(): void + { + $serverChallenge = new PlainChallenge(true); + $validate = static fn (string $authzid, string $authcid, string $password): bool => true; + + $context = $serverChallenge->challenge("foo\x00foo\x00bar", ['validate' => $validate]); + + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + self::assertNull($context->getResponse()); + } + + public function testTheServerResponseToTheClientWhenNotSuccessful(): void + { + $serverChallenge = new PlainChallenge(true); + $validate = static fn (string $authzid, string $authcid, string $password): bool => false; + + $context = $serverChallenge->challenge("foo\x00foo\x00bar", ['validate' => $validate]); + + self::assertTrue($context->isComplete()); + self::assertFalse($context->isAuthenticated()); + self::assertNull($context->getResponse()); + } +} diff --git a/tests/unit/Challenge/ScramChallengeTest.php b/tests/unit/Challenge/ScramChallengeTest.php new file mode 100644 index 0000000..ab03106 --- /dev/null +++ b/tests/unit/Challenge/ScramChallengeTest.php @@ -0,0 +1,352 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Challenge\ScramChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\HashAlgorithm; +use PHPUnit\Framework\TestCase; + +/** + * Test vectors from RFC 5802 Appendix B (SCRAM-SHA-1): + * + * username: user + * password: pencil + * cnonce: fyko+d2lbbFgONRv9qkxdawL + * snonce: 3rfcNHYJY1ZVvWVs7j + * salt: QSXCR+Q6sek8bf92 (base64) + * iterations: 4096 + */ +final class ScramChallengeTest extends TestCase +{ + private const USERNAME = 'user'; + + private const PASSWORD = 'pencil'; + + private const CNONCE = 'fyko+d2lbbFgONRv9qkxdawL'; + + private const SNONCE = '3rfcNHYJY1ZVvWVs7j'; + + private const SALT_B64 = 'QSXCR+Q6sek8bf92'; + + private const ITERATIONS = 4096; + + private const CLIENT_FIRST = 'n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL'; + + private const SERVER_FIRST = 'r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096'; + + private const CLIENT_FINAL = 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts='; + + private const SERVER_FINAL = 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ='; + + private ScramChallenge $challenge; + + protected function setUp(): void + { + $this->challenge = new ScramChallenge(false, HashAlgorithm::SHA1); + } + + public function testClientFirstMessageMatchesRfcVector(): void + { + $context = $this->challenge->challenge(null, [ + 'username' => self::USERNAME, + 'cnonce' => self::CNONCE, + ]); + + self::assertSame(self::CLIENT_FIRST, $context->getResponse()); + self::assertFalse($context->isComplete()); + } + + public function testClientFinalMessageMatchesRfcVector(): void + { + $this->challenge->challenge(null, [ + 'username' => self::USERNAME, + 'cnonce' => self::CNONCE, + ]); + + $context = $this->challenge->challenge(self::SERVER_FIRST, ['password' => self::PASSWORD]); + + self::assertSame(self::CLIENT_FINAL, $context->getResponse()); + self::assertFalse($context->isComplete()); + } + + public function testClientCompletesAndAuthenticatesOnValidServerFinal(): void + { + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge(self::SERVER_FIRST, ['password' => self::PASSWORD]); + $context = $this->challenge->challenge(self::SERVER_FINAL); + + self::assertNull($context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + } + + public function testClientThrowsExceptionOnServerFinalWithError(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge(self::SERVER_FIRST, ['password' => self::PASSWORD]); + $this->challenge->challenge('e=unknown-user'); + } + + public function testClientThrowsExceptionOnInvalidServerSignature(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge(self::SERVER_FIRST, ['password' => self::PASSWORD]); + $this->challenge->challenge('v=aW52YWxpZHNpZ25hdHVyZQ=='); + } + + public function testClientThrowsExceptionWhenNonceDoesNotBeginWithClientNonce(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge( + 'r=wrongnonce,s=' . self::SALT_B64 . ',i=' . self::ITERATIONS, + ['password' => self::PASSWORD], + ); + } + + public function testClientThrowsExceptionWhenUsernameIsMissing(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(); + } + + public function testClientThrowsExceptionWhenPasswordIsMissing(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge(self::SERVER_FIRST); + } + + public function testClientEncodesSpecialCharactersInUsername(): void + { + $context = $this->challenge->challenge(null, [ + 'username' => 'user=name,test', + 'cnonce' => self::CNONCE, + ]); + + self::assertStringContainsString('n=user=3Dname=2Ctest', (string) $context->getResponse()); + } + + public function testChannelBindingClientFirstUsesCorrectGs2Header(): void + { + $plusChallenge = new ScramChallenge(false, HashAlgorithm::SHA256, true); + $context = $plusChallenge->challenge(null, [ + 'username' => self::USERNAME, + 'cnonce' => self::CNONCE, + 'cbind_type' => 'tls-unique', + ]); + + self::assertStringStartsWith('p=tls-unique,,', (string) $context->getResponse()); + } + + public function testServerReturnsNullResponseForInitialCall(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $context = $server->challenge(); + + self::assertNull($context->getResponse()); + self::assertFalse($context->isComplete()); + } + + public function testServerFirstMessageContainsClientNoncePrefix(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $context = $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => self::ITERATIONS, + ]); + + self::assertSame(self::SERVER_FIRST, $context->getResponse()); + self::assertFalse($context->isComplete()); + } + + public function testServerFinalMessageOnValidProof(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => self::ITERATIONS, + ]); + $context = $server->challenge(self::CLIENT_FINAL, ['password' => self::PASSWORD]); + + self::assertSame(self::SERVER_FINAL, $context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + } + + public function testServerFinalMessageOnInvalidProof(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => self::ITERATIONS, + ]); + $context = $server->challenge(self::CLIENT_FINAL, ['password' => 'wrongpassword']); + + self::assertStringStartsWith('e=', (string) $context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertFalse($context->isAuthenticated()); + } + + public function testServerFinalMessageWhenPasswordIsMissing(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => self::ITERATIONS, + ]); + $context = $server->challenge(self::CLIENT_FINAL, []); + + self::assertSame('e=invalid-proof', $context->getResponse()); + self::assertTrue($context->isComplete()); + self::assertFalse($context->isAuthenticated()); + } + + public function testClientThrowsExceptionWhenServerIterationCountIsBelowMinimum(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge( + 'r=' . self::CNONCE . self::SNONCE . ',s=' . self::SALT_B64 . ',i=1', + ['password' => self::PASSWORD], + ); + } + + public function testServerThrowsExceptionWhenSuppliedIterationCountIsZero(): void + { + $this->expectException(SaslException::class); + + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => 0, + ]); + } + + public function testClientThrowsExceptionOnInvalidBase64Salt(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge( + 'r=' . self::CNONCE . self::SNONCE . ',s=not!!valid!!base64,i=' . self::ITERATIONS, + ['password' => self::PASSWORD], + ); + } + + public function testServerReturnsInvalidProofOnMalformedBase64Proof(): void + { + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => self::ITERATIONS, + ]); + $tampered = (string) preg_replace('/,p=[^,]+$/', ',p=not!!valid', self::CLIENT_FINAL); + $context = $server->challenge($tampered, ['password' => self::PASSWORD]); + + self::assertSame('e=invalid-proof', $context->getResponse()); + self::assertFalse($context->isAuthenticated()); + } + + public function testClientThrowsExceptionWhenClientFinalCalledWithoutClientFirst(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(self::SERVER_FIRST, ['password' => self::PASSWORD]); + } + + public function testClientThrowsExceptionOnInvalidChannelBindingType(): void + { + $this->expectException(SaslException::class); + + $plusChallenge = new ScramChallenge(false, HashAlgorithm::SHA256, true); + $plusChallenge->challenge(null, [ + 'username' => self::USERNAME, + 'cnonce' => self::CNONCE, + 'cbind_type' => 'invalid,,type', + ]); + } + + public function testClientThrowsExceptionWhenServerIterationCountExceedsMaximum(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, ['username' => self::USERNAME, 'cnonce' => self::CNONCE]); + $this->challenge->challenge( + 'r=' . self::CNONCE . self::SNONCE . ',s=' . self::SALT_B64 . ',i=1000001', + ['password' => self::PASSWORD], + ); + } + + public function testServerThrowsExceptionWhenSuppliedIterationCountExceedsMaximum(): void + { + $this->expectException(SaslException::class); + + $server = new ScramChallenge(true, HashAlgorithm::SHA1); + $server->challenge(self::CLIENT_FIRST, [ + 'nonce' => self::SNONCE, + 'salt' => base64_decode(self::SALT_B64), + 'iterations' => 1000001, + ]); + } + + public function testClientThrowsExceptionOnEmptyCnonce(): void + { + $this->expectException(SaslException::class); + + $this->challenge->challenge(null, [ + 'username' => self::USERNAME, + 'cnonce' => '', + ]); + } + + public function testFullClientServerRoundTrip(): void + { + $client = new ScramChallenge(false, HashAlgorithm::SHA256); + $server = new ScramChallenge(true, HashAlgorithm::SHA256); + + $clientFirst = $client->challenge(null, ['username' => self::USERNAME])->getResponse(); + self::assertNotNull($clientFirst); + + $serverFirst = $server->challenge($clientFirst)->getResponse(); + self::assertNotNull($serverFirst); + + $clientFinal = $client->challenge($serverFirst, ['password' => self::PASSWORD])->getResponse(); + self::assertNotNull($clientFinal); + + $serverContext = $server->challenge($clientFinal, ['password' => self::PASSWORD]); + $serverFinal = $serverContext->getResponse(); + self::assertStringStartsWith('v=', (string) $serverFinal); + self::assertTrue($serverContext->isAuthenticated()); + + $clientContext = $client->challenge($serverFinal); + self::assertTrue($clientContext->isComplete()); + self::assertTrue($clientContext->isAuthenticated()); + } +} diff --git a/tests/unit/FreeDSx/Sasl/Encoder/AnonymousEncoderTest.php b/tests/unit/Encoder/AnonymousEncoderTest.php similarity index 51% rename from tests/unit/FreeDSx/Sasl/Encoder/AnonymousEncoderTest.php rename to tests/unit/Encoder/AnonymousEncoderTest.php index 42819ab..e7347d7 100644 --- a/tests/unit/FreeDSx/Sasl/Encoder/AnonymousEncoderTest.php +++ b/tests/unit/Encoder/AnonymousEncoderTest.php @@ -1,5 +1,7 @@ encoder = new AnonymousEncoder(); $this->context = new SaslContext(); } - public function testItEncodes() + public function testItEncodes(): void { $result = $this->encoder->encode( new Message(['trace' => 'foo@bar.local']), - $this->context + $this->context, ); - $this->assertEquals("foo@bar.local", $result); + self::assertSame('foo@bar.local', $result); } - public function testItDecodes() + public function testItDecodes(): void { - $result = $this->encoder->decode("foo@bar.local", $this->context); + $result = $this->encoder->decode('foo@bar.local', $this->context); - $this->assertEquals(['trace' => 'foo@bar.local'], $result->toArray()); + self::assertSame(['trace' => 'foo@bar.local'], $result->toArray()); } - public function testItDecodesWithNoData() + public function testItDecodesWithNoData(): void { $result = $this->encoder->decode('', $this->context); - $this->assertEquals([], $result->toArray()); + self::assertSame([], $result->toArray()); } } diff --git a/tests/unit/FreeDSx/Sasl/Encoder/CramMD5EncoderTest.php b/tests/unit/Encoder/CramMD5EncoderTest.php similarity index 56% rename from tests/unit/FreeDSx/Sasl/Encoder/CramMD5EncoderTest.php rename to tests/unit/Encoder/CramMD5EncoderTest.php index b6f00f8..877afb9 100644 --- a/tests/unit/FreeDSx/Sasl/Encoder/CramMD5EncoderTest.php +++ b/tests/unit/Encoder/CramMD5EncoderTest.php @@ -1,5 +1,7 @@ encoder = new CramMD5Encoder(); $this->context = new SaslContext(); } - public function testEncodeClientResponse() + public function testEncodeClientResponse(): void { $digest = hash_hmac('md5', 'foo', 'bar'); $response = $this->encoder->encode( new Message(['username' => 'foo', 'digest' => $digest]), - $this->context + $this->context, ); - $this->assertEquals('foo 31b6db9e5eb4addb42f1a6ca07367adc', $response); + + self::assertSame('foo 31b6db9e5eb4addb42f1a6ca07367adc', $response); } - public function testEncodeServerChallenge() + public function testEncodeServerChallenge(): void { $this->context->setIsServerMode(true); $response = $this->encoder->encode(new Message(['challenge' => 'foobar']), $this->context); - $this->assertEquals('', $response); + self::assertSame('', $response); } - public function testDecodeServerChallenge() + public function testDecodeServerChallenge(): void { $response = $this->encoder->decode('', $this->context); - $this->assertEquals(['challenge' => ''], $response->toArray()); + self::assertSame(['challenge' => ''], $response->toArray()); } - public function testDecodeClientChallenge() + public function testDecodeClientChallenge(): void { $this->context->setIsServerMode(true); $response = $this->encoder->decode('foo 31b6db9e5eb4addb42f1a6ca07367adc', $this->context); - $this->assertEquals(['username' => 'foo', 'digest' => '31b6db9e5eb4addb42f1a6ca07367adc'], $response->toArray()); + self::assertSame( + ['username' => 'foo', 'digest' => '31b6db9e5eb4addb42f1a6ca07367adc'], + $response->toArray(), + ); } } diff --git a/tests/unit/FreeDSx/Sasl/Encoder/DigestMD5EncoderTest.php b/tests/unit/Encoder/DigestMD5EncoderTest.php similarity index 60% rename from tests/unit/FreeDSx/Sasl/Encoder/DigestMD5EncoderTest.php rename to tests/unit/Encoder/DigestMD5EncoderTest.php index ce6fb0b..e395ffc 100644 --- a/tests/unit/FreeDSx/Sasl/Encoder/DigestMD5EncoderTest.php +++ b/tests/unit/Encoder/DigestMD5EncoderTest.php @@ -1,4 +1,7 @@ encoder = new DigestMD5Encoder(); } - public function testDecodeServerChallenge() + public function testDecodeServerChallenge(): void { $challenge = $this->encoder->decode( - hex2bin('6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), - new SaslContext() + (string) hex2bin('6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), + new SaslContext(), ); - $this->assertEquals(new Message([ + self::assertEquals(new Message([ 'nonce' => 'iIAIQH05uQvpZmm2XR+ih3dDV3zuTidxtMO3PsFGRSI=', 'realm' => 'huh-sys', - 'qop' => ['auth', 'auth-int', 'auth-conf',], - 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des',], + 'qop' => ['auth', 'auth-int', 'auth-conf'], + 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des'], 'maxbuf' => '65536', 'charset' => 'utf-8', 'algorithm' => 'md5-sess', ]), $challenge); } - public function testDecodeClientResponse() + public function testDecodeClientResponse(): void { $response = $this->encoder->decode( - hex2bin('757365726e616d653d2257696c6c69666f41222c7265616c6d3d226875682d737973222c6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c636e6f6e63653d226a536f346c6f4c38575772486f353042496d73716a5352424b4d416f58444458726a4e4743665932762b513d222c6e633d30303030303030312c716f703d617574682d636f6e662c6369706865723d7263342c6d61786275663d31363737373231352c6469676573742d7572693d226c6461702f6875682d737973222c726573706f6e73653d3136633065653762666135666363336232643139623530663137656262386632'), - (new SaslContext())->setIsServerMode(true) + (string) hex2bin('757365726e616d653d2257696c6c69666f41222c7265616c6d3d226875682d737973222c6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c636e6f6e63653d226a536f346c6f4c38575772486f353042496d73716a5352424b4d416f58444458726a4e4743665932762b513d222c6e633d30303030303030312c716f703d617574682d636f6e662c6369706865723d7263342c6d61786275663d31363737373231352c6469676573742d7572693d226c6461702f6875682d737973222c726573706f6e73653d3136633065653762666135666363336232643139623530663137656262386632'), + (new SaslContext())->setIsServerMode(true), ); - $this->assertEquals(new Message([ + self::assertEquals(new Message([ 'username' => 'WillifoA', 'realm' => 'huh-sys', 'nonce' => 'iIAIQH05uQvpZmm2XR+ih3dDV3zuTidxtMO3PsFGRSI=', @@ -67,37 +67,35 @@ public function testDecodeClientResponse() ]), $response); } - public function testDecodeServerResponse() + public function testDecodeServerResponse(): void { $response = $this->encoder->decode( - hex2bin('727370617574683d3239306664633430623436626164303735663861303863663734333631666363'), - new SaslContext() + (string) hex2bin('727370617574683d3239306664633430623436626164303735663861303863663734333631666363'), + new SaslContext(), ); - $this->assertEquals(new Message([ - 'rspauth' => '290fdc40b46bad075f8a08cf74361fcc', - ]), $response); + self::assertEquals(new Message(['rspauth' => '290fdc40b46bad075f8a08cf74361fcc']), $response); } - public function testEncodeServerChallenge() + public function testEncodeServerChallenge(): void { $challenge = new Message([ 'nonce' => 'iIAIQH05uQvpZmm2XR+ih3dDV3zuTidxtMO3PsFGRSI=', 'realm' => 'huh-sys', - 'qop' => ['auth', 'auth-int', 'auth-conf',], - 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des',], + 'qop' => ['auth', 'auth-int', 'auth-conf'], + 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des'], 'maxbuf' => '65536', 'charset' => 'utf-8', 'algorithm' => 'md5-sess', ]); - $this->assertEquals( - hex2bin('6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), - $this->encoder->encode($challenge, (new SaslContext())->setIsServerMode(true)) + self::assertSame( + (string) hex2bin('6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), + $this->encoder->encode($challenge, (new SaslContext())->setIsServerMode(true)), ); } - public function testEncodeClientResponse() + public function testEncodeClientResponse(): void { $response = new Message([ 'username' => 'WillifoA', @@ -112,140 +110,139 @@ public function testEncodeClientResponse() 'response' => '16c0ee7bfa5fcc3b2d19b50f17ebb8f2', ]); - $this->assertEquals( - hex2bin('757365726e616d653d2257696c6c69666f41222c7265616c6d3d226875682d737973222c6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c636e6f6e63653d226a536f346c6f4c38575772486f353042496d73716a5352424b4d416f58444458726a4e4743665932762b513d222c6e633d30303030303030312c716f703d617574682d636f6e662c6369706865723d7263342c6d61786275663d31363737373231352c6469676573742d7572693d226c6461702f6875682d737973222c726573706f6e73653d3136633065653762666135666363336232643139623530663137656262386632'), - $this->encoder->encode($response, new SaslContext()) + self::assertSame( + (string) hex2bin('757365726e616d653d2257696c6c69666f41222c7265616c6d3d226875682d737973222c6e6f6e63653d226949414951483035755176705a6d6d3258522b696833644456337a7554696478744d4f33507346475253493d222c636e6f6e63653d226a536f346c6f4c38575772486f353042496d73716a5352424b4d416f58444458726a4e4743665932762b513d222c6e633d30303030303030312c716f703d617574682d636f6e662c6369706865723d7263342c6d61786275663d31363737373231352c6469676573742d7572693d226c6461702f6875682d737973222c726573706f6e73653d3136633065653762666135666363336232643139623530663137656262386632'), + $this->encoder->encode($response, new SaslContext()), ); } - public function testEncodeServerResponse() + public function testEncodeServerResponse(): void { $response = new Message(['rspauth' => '290fdc40b46bad075f8a08cf74361fcc']); - $this->assertEquals( - hex2bin('727370617574683d3239306664633430623436626164303735663861303863663734333631666363'), - $this->encoder->encode($response, new SaslContext([], true, false, true)) + self::assertSame( + (string) hex2bin('727370617574683d3239306664633430623436626164303735663861303863663734333631666363'), + $this->encoder->encode($response, (new SaslContext())->setIsServerMode(true)), ); } - public function testDecodingThrowsExceptionOnMalformedKeys() + public function testDecodingThrowsExceptionOnMalformedKeys(): void { $this->expectExceptionObject(new SaslEncodingException('The digest is malformed. Expected a key, but none was found.')); $this->encoder->decode('f00=bar', new SaslContext()); } - public function testDecodingThrowsExceptionWhenKeyValuesDoNotHaveACommaAfterThem() + public function testDecodingThrowsExceptionWhenKeyValuesDoNotHaveACommaAfterThem(): void { $this->expectExceptionObject(new SaslEncodingException('Expected a comma following digest value for username.')); $this->encoder->decode('username="foo"foo=bar', new SaslContext()); } - public function testDecodingThrowsAnExceptionWhenAKeyIsNotRecognized() + public function testDecodingThrowsAnExceptionWhenAKeyIsNotRecognized(): void { $this->expectExceptionObject(new SaslEncodingException('Digest option foo is not supported.')); $this->encoder->decode('foo="bar"', new SaslContext()); } - public function testDecodingThrowsAnExceptionWhenStaleIsNotTrue() + public function testDecodingThrowsAnExceptionWhenStaleIsNotTrue(): void { $this->expectExceptionObject(new SaslEncodingException('Expected the directive value to be "true", but it is not.')); $this->encoder->decode('stale=false', new SaslContext()); } - public function testDecodingThrowsAnExceptionWhenAlgorithmIsNotMD5Sess() + public function testDecodingThrowsAnExceptionWhenAlgorithmIsNotMD5Sess(): void { $this->expectExceptionObject(new SaslEncodingException('Expected the directive value to be "md5-sess", but it is not.')); $this->encoder->decode('algorithm=foo', new SaslContext()); } - public function testDecodingThrowsAnExceptionWhenCharsetIsNotUTF8() + public function testDecodingThrowsAnExceptionWhenCharsetIsNotUTF8(): void { $this->expectExceptionObject(new SaslEncodingException('Expected the directive value to be "utf-8", but it is not.')); $this->encoder->decode('charset=en-us', new SaslContext()); } - - public function testDecodingThatCharsetMayOnlyOccurOnce() + public function testDecodingThatCharsetMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "charset" may occur only once.')); $this->encoder->decode('charset=utf-8,charset=utf-8', new SaslContext()); } - public function testDecodingThatStaleMayOnlyOccurOnce() + public function testDecodingThatStaleMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "stale" may occur only once.')); $this->encoder->decode('stale=true,stale=true', new SaslContext()); } - public function testDecodingThatAlgorithmMayOnlyOccurOnce() + public function testDecodingThatAlgorithmMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "algorithm" may occur only once.')); $this->encoder->decode('algorithm=md5-sess,algorithm=md5-sess', new SaslContext()); } - public function testDecodingThatNonceMayOnlyOccurOnce() + public function testDecodingThatNonceMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "nonce" may occur only once.')); $this->encoder->decode('nonce="fooo",nonce="barr"', new SaslContext()); } - public function testDecodingThatCnonceMayOnlyOccurOnce() + public function testDecodingThatCnonceMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "cnonce" may occur only once.')); $this->encoder->decode('cnonce="fooo",cnonce="barr"', new SaslContext()); } - public function testDecodingThatNcMayOnlyOccurOnce() + public function testDecodingThatNcMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "nc" may occur only once.')); $this->encoder->decode('nc=00000001,nc=00000002', new SaslContext()); } - public function testDecodingThatQopMayOnlyOccurOnce() + public function testDecodingThatQopMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "qop" may occur only once.')); $this->encoder->decode('qop="auth-int",qop="auth-conf"', new SaslContext()); } - public function testDecodingThatDigestUriMayOnlyOccurOnce() + public function testDecodingThatDigestUriMayOnlyOccurOnce(): void { $this->expectExceptionObject(new SaslEncodingException('The option "digest-uri" may occur only once.')); $this->encoder->decode('digest-uri="foo/bar",digest-uri="bar/foo"', new SaslContext()); } - public function testDecodingQuotedValuesWithEscapedQuotes() + public function testDecodingQuotedValuesWithEscapedQuotes(): void { $message = $this->encoder->decode('username="foo\"bar"', new SaslContext()); - $this->assertEquals('foo"bar', $message->get('username')); + self::assertSame('foo"bar', $message->get('username')); } - public function testDecodingCorrectlyUnescapesCharactersInQuotedStrings() + public function testDecodingCorrectlyUnescapesCharactersInQuotedStrings(): void { $message = $this->encoder->decode('username="\f\o\o\\\\b\a\r\""', new SaslContext()); - $this->assertEquals('foo\bar"', $message->get('username')); + self::assertSame('foo\bar"', $message->get('username')); } - public function testEncodingCorrectlyEscapesCharactersInQuotedStrings() + public function testEncodingCorrectlyEscapesCharactersInQuotedStrings(): void { - $this->assertEquals( + self::assertSame( 'username="foo\\\bar\""', - $this->encoder->encode(new Message(['username' => 'foo\bar"']), new SaslContext()) + $this->encoder->encode(new Message(['username' => 'foo\bar"']), new SaslContext()), ); } } diff --git a/tests/unit/FreeDSx/Sasl/Encoder/PlainEncoderTest.php b/tests/unit/Encoder/PlainEncoderTest.php similarity index 58% rename from tests/unit/FreeDSx/Sasl/Encoder/PlainEncoderTest.php rename to tests/unit/Encoder/PlainEncoderTest.php index 8b74315..c3871f8 100644 --- a/tests/unit/FreeDSx/Sasl/Encoder/PlainEncoderTest.php +++ b/tests/unit/Encoder/PlainEncoderTest.php @@ -1,5 +1,7 @@ encoder = new PlainEncoder(); $this->context = new SaslContext(); } - public function testItEncodes() + public function testItEncodes(): void { $result = $this->encoder->encode( new Message(['authzid' => 'foo', 'authcid' => 'foo', 'password' => 'bar']), - $this->context + $this->context, ); - $this->assertEquals("foo\x00foo\x00bar", $result); + self::assertSame("foo\x00foo\x00bar", $result); } - public function testItDecodes() + public function testItDecodes(): void { $result = $this->encoder->decode("foo1\x00foo2\x00bar", $this->context); - $expected = [ - 'authzid' => 'foo1', - 'authcid' => 'foo2', - 'password' => 'bar', - ]; - $this->assertEquals($expected, $result->toArray()); + self::assertSame( + ['authzid' => 'foo1', 'authcid' => 'foo2', 'password' => 'bar'], + $result->toArray(), + ); } - public function testItValidatesTheDecodedMessage() + public function testItValidatesTheDecodedMessage(): void { $this->expectException(SaslEncodingException::class); $this->encoder->decode("fo\x00o1\x00foo2\x00bar", $this->context); } - public function testItValidatesTheEncodedMessage() + public function testItValidatesTheEncodedMessage(): void { $this->expectException(SaslEncodingException::class); $this->encoder->encode(new Message(['authzid' => 'foo']), $this->context); } - public function testItValidatesTheEncodedMessageHasNoNullCharacters() + public function testItValidatesTheEncodedMessageHasNoNullCharacters(): void { $this->expectException(SaslEncodingException::class); - $this->encoder->encode(new Message(['authzid' => "fo\x00o", 'authcid' => 'foo', 'password' => 'bar']), $this->context); + $this->encoder->encode( + new Message(['authzid' => "fo\x00o", 'authcid' => 'foo', 'password' => 'bar']), + $this->context, + ); } } diff --git a/tests/unit/Encoder/ScramEncoderTest.php b/tests/unit/Encoder/ScramEncoderTest.php new file mode 100644 index 0000000..8d2f608 --- /dev/null +++ b/tests/unit/Encoder/ScramEncoderTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Encoder; + +use FreeDSx\Sasl\Encoder\ScramEncoder; +use FreeDSx\Sasl\Exception\SaslEncodingException; +use FreeDSx\Sasl\Message; +use FreeDSx\Sasl\SaslContext; +use PHPUnit\Framework\TestCase; + +final class ScramEncoderTest extends TestCase +{ + private ScramEncoder $encoder; + + private SaslContext $context; + + protected function setUp(): void + { + $this->encoder = new ScramEncoder(); + $this->context = new SaslContext(); + } + + public function testEncodeProducesCommaSeparatedAttrValuePairs(): void + { + $message = new Message([ + 'n' => 'user', + 'r' => 'fyko+d2lbbFgONRv9qkxdawL', + ]); + + $encoded = $this->encoder->encode($message, $this->context); + + self::assertSame('n=user,r=fyko+d2lbbFgONRv9qkxdawL', $encoded); + } + + public function testEncodePreservesBase64ValuesWithEqualSigns(): void + { + $message = new Message(['v' => 'rmF9pqV8S7suAoZWja4dJRkFsKQ=']); + + self::assertSame( + 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ=', + $this->encoder->encode($message, $this->context), + ); + } + + public function testDecodeClientFirstMessageStripsGs2Header(): void + { + $decoded = $this->encoder->decode('n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL', $this->context); + + self::assertSame('n,,', $decoded->get('gs2-header')); + self::assertSame('user', $decoded->get('n')); + self::assertSame('fyko+d2lbbFgONRv9qkxdawL', $decoded->get('r')); + } + + public function testDecodeClientFirstMessageWithChannelBindingGs2Header(): void + { + $decoded = $this->encoder->decode('p=tls-unique,,n=user,r=clientnonce', $this->context); + + self::assertSame('p=tls-unique,,', $decoded->get('gs2-header')); + self::assertSame('user', $decoded->get('n')); + self::assertSame('clientnonce', $decoded->get('r')); + } + + public function testDecodeClientFirstMessageWithYGs2Header(): void + { + $decoded = $this->encoder->decode('y,,n=user,r=clientnonce', $this->context); + + self::assertSame('y,,', $decoded->get('gs2-header')); + self::assertSame('user', $decoded->get('n')); + } + + public function testDecodeServerFirstMessage(): void + { + $decoded = $this->encoder->decode( + 'r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096', + $this->context, + ); + + self::assertSame('fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j', $decoded->get('r')); + self::assertSame('QSXCR+Q6sek8bf92', $decoded->get('s')); + self::assertSame('4096', $decoded->get('i')); + self::assertNull($decoded->get('gs2-header')); + } + + public function testDecodeClientFinalMessage(): void + { + $decoded = $this->encoder->decode( + 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=', + $this->context, + ); + + self::assertSame('biws', $decoded->get('c')); + self::assertSame('fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j', $decoded->get('r')); + self::assertSame('v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=', $decoded->get('p')); + } + + public function testDecodeServerFinalMessage(): void + { + $decoded = $this->encoder->decode('v=rmF9pqV8S7suAoZWja4dJRkFsKQ=', $this->context); + + self::assertSame('rmF9pqV8S7suAoZWja4dJRkFsKQ=', $decoded->get('v')); + } + + public function testDecodeThrowsOnMalformedAttributeWithNoEquals(): void + { + $this->expectException(SaslEncodingException::class); + + $this->encoder->decode('r=validnonce,malformed', $this->context); + } + + public function testDecodeThrowsOnMandatoryExtension(): void + { + $this->expectException(SaslEncodingException::class); + + $this->encoder->decode('m=unsupported,r=nonce', $this->context); + } +} diff --git a/tests/unit/Factory/DigestMD5MessageFactoryTest.php b/tests/unit/Factory/DigestMD5MessageFactoryTest.php new file mode 100644 index 0000000..fee2f3c --- /dev/null +++ b/tests/unit/Factory/DigestMD5MessageFactoryTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Factory; + +use FreeDSx\Sasl\Encoder\DigestMD5Encoder; +use FreeDSx\Sasl\Factory\DigestMD5MessageFactory; +use FreeDSx\Sasl\Factory\DigestMD5MessageType; +use FreeDSx\Sasl\Message; +use FreeDSx\Sasl\SaslContext; +use PHPUnit\Framework\TestCase; + +final class DigestMD5MessageFactoryTest extends TestCase +{ + private DigestMD5MessageFactory $factory; + + private DigestMD5Encoder $encoder; + + protected function setUp(): void + { + $this->factory = new DigestMD5MessageFactory(); + $this->encoder = new DigestMD5Encoder(); + } + + public function testCreateServerChallenge(): void + { + $challenge = $this->factory->create( + DigestMD5MessageType::SERVER_CHALLENGE, + ['use_integrity' => true, 'use_privacy' => true], + ); + + self::assertCount(7, $challenge->toArray()); + self::assertSame('md5-sess', $challenge->get('algorithm')); + self::assertSame(['auth', 'auth-int', 'auth-conf'], $challenge->get('qop')); + self::assertSame('65536', $challenge->get('maxbuf')); + self::assertSame('utf-8', $challenge->get('charset')); + self::assertNotEmpty($challenge->get('realm'), 'The realm value is empty.'); + self::assertNotEmpty($challenge->get('nonce'), 'The nonce value is empty.'); + self::assertNotEmpty($challenge->get('cipher'), 'The cipher value is empty.'); + } + + public function testCreateClientResponse(): void + { + $challenge = $this->encoder->decode( + (string) hex2bin('6e6f6e63653d225a7a6b307578374b674f56506d4e37644c6f66476d394b714e6573626e43585263494151536d787551456b3d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), + new SaslContext(), + ); + $response = $this->factory->create( + DigestMD5MessageType::CLIENT_RESPONSE, + ['service' => 'ldap'], + $challenge, + ); + + self::assertCount(9, $response->toArray()); + self::assertSame('auth-conf', $response->get('qop')); + $digestUri = $response->get('digest-uri'); + self::assertIsString($digestUri); + self::assertStringStartsWith('ldap/huh-sys', $digestUri); + self::assertSame(1, $response->get('nc')); + self::assertSame('md5-sess', $response->get('algorithm')); + self::assertNotEmpty($response->get('realm'), 'The realm value is empty.'); + self::assertNotEmpty($response->get('username'), 'The username value is empty.'); + self::assertNotEmpty($response->get('cnonce'), 'The cnonce value is empty.'); + self::assertNotEmpty($response->get('nonce'), 'The nonce value is empty.'); + } + + public function testCreateServerResponse(): void + { + $response = $this->factory->create( + DigestMD5MessageType::SERVER_RESPONSE, + ['rspauth' => 'foobar'], + ); + + self::assertEquals(new Message(['rspauth' => 'foobar']), $response); + } +} diff --git a/tests/unit/FreeDSx/Sasl/Challenge/AnonymousChallengeTest.php b/tests/unit/FreeDSx/Sasl/Challenge/AnonymousChallengeTest.php deleted file mode 100644 index 8ab184e..0000000 --- a/tests/unit/FreeDSx/Sasl/Challenge/AnonymousChallengeTest.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Challenge; - -use FreeDSx\Sasl\Challenge\AnonymousChallenge; -use PHPUnit\Framework\TestCase; - -class AnonymousChallengeTest extends TestCase -{ - /** - * @var AnonymousChallenge - */ - protected $challenge; - - public function setUp(): void - { - parent::setUp(); - $this->challenge = new AnonymousChallenge(); - } - - public function testTheClientChallenge() - { - $context = $this->challenge->challenge(null, ['username' => 'foo', 'password' => 'bar']); - - - $this->assertEquals("foo", $context->getResponse()); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - } - - public function testTheClientChallengeWithSpecificTrace() - { - $context = $this->challenge->challenge(null, ['trace' => 'foobar', 'password' => 'bar']); - - $this->assertEquals("foobar", $context->getResponse()); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - } - - public function testTheServerResponseToTheClientWhenSuccessful() - { - $this->challenge = new AnonymousChallenge(true); - $context = $this->challenge->challenge("foo"); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - $this->assertNull($context->getResponse()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Challenge/CramMD5ChallengeTest.php b/tests/unit/FreeDSx/Sasl/Challenge/CramMD5ChallengeTest.php deleted file mode 100644 index ce077d5..0000000 --- a/tests/unit/FreeDSx/Sasl/Challenge/CramMD5ChallengeTest.php +++ /dev/null @@ -1,127 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Challenge; - -use FreeDSx\Sasl\Challenge\CramMD5Challenge; -use FreeDSx\Sasl\Encoder\CramMD5Encoder; -use PHPUnit\Framework\TestCase; - -class CramMD5ChallengeTest extends TestCase -{ - /** - * @var CramMD5Challenge - */ - protected $challenge; - - /** - * @var CramMD5Encoder - */ - protected $encoder; - - public function setUp(): void - { - parent::setUp(); - $this->challenge = new CramMD5Challenge(); - $this->encoder = new CramMD5Encoder(); - } - - public function testChallengeWithFromClientWithServerChallenge() - { - $context = $this->challenge->challenge('', ['username' => 'foo', 'password' => 'bar']); - - $this->assertEquals('foo e23c893e9de272d4a75e646265768a45', $context->getResponse()); - $this->assertTrue($context->isComplete()); - } - - public function testChallengeWithFromServerWithClientWrongResponse() - { - $this->challenge = new CramMD5Challenge(true); - $validate = function (string $username, string $challenge) { - return ''; - }; - $this->challenge->challenge(); - $context = $this->challenge->challenge('foo e23c893e9de272d4a75e646265768a45', ['password' => $validate]); - - $this->assertFalse($context->isAuthenticated()); - $this->assertTrue($context->isComplete()); - } - - public function testChallengeWithFromServerWithClientCorrectResponse() - { - $this->challenge = new CramMD5Challenge(true); - $validate = function (string $username, string $challenge) { - return hash_hmac('md5', $challenge, 'bar'); - }; - // The challenge option is the raw nonce; the encoder wraps it as . - $this->challenge->challenge(null, ['challenge' => 'foobar']); - $context = $this->challenge->challenge('foo e23c893e9de272d4a75e646265768a45', ['password' => $validate]); - - $this->assertTrue($context->isAuthenticated()); - $this->assertTrue($context->isComplete()); - } - - public function testPasswordCallableReceivesEncodedChallengeMatchingWhatClientUses(): void - { - $this->challenge = new CramMD5Challenge(true); - - $challengePassedToCallable = null; - $validate = function (string $username, string $challenge) use (&$challengePassedToCallable) { - $challengePassedToCallable = $challenge; - - return hash_hmac( - 'md5', - $challenge, - 'bar' - ); - }; - - // Server generates challenge with raw nonce 'foobar'; encoder sends '' to client. - $serverContext = $this->challenge->challenge( - null, - ['challenge' => 'foobar'] - ); - $encodedChallenge = $serverContext->getResponse(); - $this->assertSame( - '', - $encodedChallenge - ); - - // Client computes HMAC over the encoded challenge exactly as received. - $clientChallenge = new CramMD5Challenge(false); - $clientContext = $clientChallenge->challenge( - $encodedChallenge, - ['username' => 'foo', 'password' => 'bar'] - ); - - // Server validates the client response. - $this->challenge->challenge( - $clientContext->getResponse(), - ['password' => $validate] - ); - - // The callable must receive the encoded form so it can compute the same digest as the client. - $this->assertSame( - '', - $challengePassedToCallable - ); - $this->assertTrue($this->challenge->challenge()->isAuthenticated()); - } - - public function testChallengeWithFromServerWithInitialChallenge() - { - $this->challenge = new CramMD5Challenge(true); - $context = $this->challenge->challenge(); - - $this->assertNotNull($context->getResponse()); - $this->assertFalse($context->isComplete()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Challenge/DigestMD5ChallengeTest.php b/tests/unit/FreeDSx/Sasl/Challenge/DigestMD5ChallengeTest.php deleted file mode 100644 index 512364f..0000000 --- a/tests/unit/FreeDSx/Sasl/Challenge/DigestMD5ChallengeTest.php +++ /dev/null @@ -1,162 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Challenge; - -use FreeDSx\Sasl\Challenge\DigestMD5Challenge; -use FreeDSx\Sasl\Encoder\DigestMD5Encoder; -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\SaslContext; -use PHPUnit\Framework\TestCase; - -class DigestMD5ChallengeTest extends TestCase -{ - /** - * @var DigestMD5Challenge - */ - protected $challenge; - - /** - * @var DigestMD5Encoder - */ - protected $encoder; - - /** - * @var string - */ - protected $challengeData; - - /** - * @var string - */ - protected $responseData; - - /** - * @var string - */ - protected $rspAuthData; - - /** - * @var string - */ - protected $rspAuthSuccess; - - public function setUp(): void - { - $this->challenge = new DigestMD5Challenge(); - $this->encoder = new DigestMD5Encoder(); - $this->challengeData = hex2bin('6e6f6e63653d225a7a6b307578374b674f56506d4e37644c6f66476d394b714e6573626e43585263494151536d787551456b3d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'); - $this->responseData = hex2bin('757365726e616d653d2257696c6c69666f41222c6469676573742d7572693d226c6461702f6875682d737973222c716f703d617574682d636f6e662c6369706865723d7263342c616c676f726974686d3d6d64352d736573732c6e6f6e63653d225a7a6b307578374b674f56506d4e37644c6f66476d394b714e6573626e43585263494151536d787551456b3d222c636e6f6e63653d22745176425075444f4d54453d222c6e633d30303030303030312c7265616c6d3d226875682d737973222c726573706f6e73653d6439363436333039326565323462616466666261663531386331343934306430'); - $this->rspAuthData = hex2bin('727370617574683d3333323832383833643136316261376337656631616166633337666162393438'); - $this->rspAuthSuccess = hex2bin('727370617574683d3333323832383833643136316261376337656631616166633337666162393438'); - } - - public function testThatClientResponseIsGeneratedFromChallenge() - { - $response = $this->encoder->decode( - $this->challenge->challenge($this->challengeData, ['use_privacy' => true])->getResponse(), - (new SaslContext())->setIsServerMode(true) - ); - - $this->assertEquals('auth-conf', $response->get('qop')); - $this->assertNotEmpty($response->get('response'), 'The response value is empty.'); - } - - public function testThatClientResponseUsesSpecificHostForDigestUriIfRequested() - { - $response = $this->encoder->decode( - $this->challenge->challenge($this->challengeData, ['use_privacy' => true, 'host' => 'foo.bar.local'])->getResponse(), - (new SaslContext())->setIsServerMode(true) - ); - - $this->assertEquals('ldap/foo.bar.local', $response->get('digest-uri')); - } - - public function testSecurityLayerIsInitializedProperlyInTheContext() - { - $options = [ - 'use_privacy' => true, - 'cipher' => 'rc4', - 'username' => 'WillifoA', - 'password' => 'Password1', - ]; - $this->challenge->challenge($this->challengeData, ['cnonce' => 'tQvBPuDOMTE=', 'nonce' => 'Zzk0ux7KgOVPmN7dLofGm9KqNesbnCXRcIAQSmxuQEk='] + $options); - $context = $this->challenge->challenge($this->rspAuthSuccess, ['cnonce' => 'tQvBPuDOMTE=', 'nonce' => 'Zzk0ux7KgOVPmN7dLofGm9KqNesbnCXRcIAQSmxuQEk='] + $options); - - $this->assertTrue($context->isAuthenticated(), 'Context should be authenticated, but was not.'); - $this->assertTrue($context->hasSecurityLayer(), 'Context should have a security layer, but it does not.'); - $this->assertEquals(0, $context->get('seqnumsnt')); - $this->assertEquals(0, $context->get('seqnumrcv')); - } - - public function testVerificationIsDoneOnTheServerResponse() - { - $this->challenge->challenge($this->challengeData); - $context = $this->challenge->challenge($this->rspAuthData); - - $this->assertFalse($context->isAuthenticated()); - $this->assertFalse($context->hasSecurityLayer()); - } - - public function testAnExceptionIsThrownIfTheRspauthIsReceivedOutOfOrder() - { - $this->expectException(SaslException::class); - - $this->challenge->challenge($this->rspAuthData); - } - - public function testGenerateServerChallengeForClientInServerMode() - { - $options = [ - 'use_integrity' => true, - 'use_privacy' => true, - ]; - $this->challenge = new DigestMD5Challenge(true); - $challenge = $this->encoder->decode($this->challenge->challenge(null, $options)->getResponse(), new SaslContext()); - - $this->assertEquals(['auth', 'auth-int', 'auth-conf'], $challenge->get('qop')); - $this->assertNotEmpty($challenge->get('cipher'), 'The realm value is empty.'); - $this->assertNotEmpty($challenge->get('nonce'), 'The nonce must be generated.'); - } - - public function testGenerateServerResponseToClientResponse() - { - $this->markTestSkipped('Test does not work on newer PHP due to deprecated ciphers it seems. Needs investigation.'); - $options = [ - 'use_integrity' => true, - 'use_privacy' => true, - ]; - $this->challenge = new DigestMD5Challenge(true); - $this->challenge->challenge(null, [ - 'nonce' => 'Zzk0ux7KgOVPmN7dLofGm9KqNesbnCXRcIAQSmxuQEk=', - 'realm' => 'huh-sys', - ] + $options); - $response = $this->challenge->challenge( - $this->responseData, - ['password' => 'Password1'] + $options - )->getResponse(); - $response = $this->encoder->decode( - (string) $response, - (new SaslContext())->setIsServerMode(true) - ); - - $this->assertCount(1, $response->toArray()); - $this->assertEquals('33282883d161ba7c7ef1aafc37fab948', $response->get('rspauth')); - } - - public function testIsCompleteIsNotTrueUntilTheRspauthIsProcessed() - { - $context = $this->challenge->challenge($this->challengeData); - $this->assertFalse($context->isComplete()); - - $context = $this->challenge->challenge($this->rspAuthData); - $this->assertTrue($context->isComplete()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Challenge/PlainChallengeTest.php b/tests/unit/FreeDSx/Sasl/Challenge/PlainChallengeTest.php deleted file mode 100644 index 81bb9d1..0000000 --- a/tests/unit/FreeDSx/Sasl/Challenge/PlainChallengeTest.php +++ /dev/null @@ -1,64 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Challenge; - -use FreeDSx\Sasl\Challenge\PlainChallenge; -use PHPUnit\Framework\TestCase; - -class PlainChallengeTest extends TestCase -{ - /** - * @var PlainChallenge - */ - protected $challenge; - - public function setUp(): void - { - parent::setUp(); - $this->challenge = new PlainChallenge(); - } - - public function testTheClientChallenge() - { - $context = $this->challenge->challenge(null, ['username' => 'foo', 'password' => 'bar']); - - - $this->assertEquals("foo\x00foo\x00bar", $context->getResponse()); - $this->assertTrue($context->isComplete()); - } - - public function testTheServerResponseToTheClientWhenSuccessful() - { - $this->challenge = new PlainChallenge(true); - $validate = function (string $authzid, string $authcid, string $password) { - return true; - }; - - $context = $this->challenge->challenge("foo\x00foo\x00bar", ['validate' => $validate]); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - $this->assertNull($context->getResponse()); - } - - public function testTheServerResponseToTheClientWhenNotSuccessful() - { - $this->challenge = new PlainChallenge(true); - $validate = function (string $authzid, string $authcid, string $password) { - return false; - }; - - $context = $this->challenge->challenge("foo\x00foo\x00bar", ['validate' => $validate]); - $this->assertTrue($context->isComplete()); - $this->assertFalse($context->isAuthenticated()); - $this->assertNull($context->getResponse()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Challenge/ScramChallengeTest.php b/tests/unit/FreeDSx/Sasl/Challenge/ScramChallengeTest.php deleted file mode 100644 index 1274091..0000000 --- a/tests/unit/FreeDSx/Sasl/Challenge/ScramChallengeTest.php +++ /dev/null @@ -1,481 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Challenge; - -use FreeDSx\Sasl\Challenge\ScramChallenge; -use FreeDSx\Sasl\Exception\SaslException; -use PHPUnit\Framework\TestCase; - -/** - * Test vectors from RFC 5802 Appendix B (SCRAM-SHA-1): - * - * username: user - * password: pencil - * cnonce: fyko+d2lbbFgONRv9qkxdawL - * snonce: 3rfcNHYJY1ZVvWVs7j - * salt: QSXCR+Q6sek8bf92 (base64) - * iterations: 4096 - * - * C: n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL - * S: r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096 - * C: c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts= - * S: v=rmF9pqV8S7suAoZWja4dJRkFsKQ= - */ -class ScramChallengeTest extends TestCase -{ - private const USERNAME = 'user'; - private const PASSWORD = 'pencil'; - private const CNONCE = 'fyko+d2lbbFgONRv9qkxdawL'; - private const SNONCE = '3rfcNHYJY1ZVvWVs7j'; - private const SALT_B64 = 'QSXCR+Q6sek8bf92'; - private const ITERATIONS = 4096; - - private const CLIENT_FIRST = 'n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL'; - private const SERVER_FIRST = 'r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096'; - private const CLIENT_FINAL = 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts='; - private const SERVER_FINAL = 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ='; - - /** - * @var ScramChallenge - */ - private $challenge; - - public function setUp(): void - { - parent::setUp(); - - $this->challenge = new ScramChallenge(false, 'sha1'); - } - - public function testClientFirstMessageMatchesRfcVector(): void - { - $context = $this->challenge->challenge(null, [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE, - ]); - - $this->assertSame( - self::CLIENT_FIRST, - $context->getResponse() - ); - $this->assertFalse($context->isComplete()); - } - - public function testClientFinalMessageMatchesRfcVector(): void - { - $this->challenge->challenge(null, [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE, - ]); - - $context = $this->challenge->challenge(self::SERVER_FIRST, [ - 'password' => self::PASSWORD, - ]); - - $this->assertSame( - self::CLIENT_FINAL, - $context->getResponse() - ); - $this->assertFalse($context->isComplete()); - } - - public function testClientCompletesAndAuthenticatesOnValidServerFinal(): void - { - $this->challenge->challenge( - null, - [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE - ] - ); - $this->challenge->challenge( - self::SERVER_FIRST, - ['password' => self::PASSWORD] - ); - $context = $this->challenge->challenge(self::SERVER_FINAL); - - $this->assertNull($context->getResponse()); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - } - - public function testClientThrowsExceptionOnServerFinalWithError(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE - ] - ); - $this->challenge->challenge( - self::SERVER_FIRST, - ['password' => self::PASSWORD] - ); - $this->challenge->challenge('e=unknown-user'); - } - - public function testClientThrowsExceptionOnInvalidServerSignature(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - ['username' => self::USERNAME, 'cnonce' => self::CNONCE] - ); - $this->challenge->challenge( - self::SERVER_FIRST, - ['password' => self::PASSWORD] - ); - $this->challenge->challenge('v=aW52YWxpZHNpZ25hdHVyZQ=='); - } - - public function testClientThrowsExceptionWhenNonceDoesNotBeginWithClientNonce(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE - ] - ); - // Server-first with a nonce that doesn't start with the client nonce - $this->challenge->challenge( - 'r=wrongnonce,s=' . self::SALT_B64 . ',i=' . self::ITERATIONS, - ['password' => self::PASSWORD] - ); - } - - public function testClientThrowsExceptionWhenUsernameIsMissing(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge(); - } - - public function testClientThrowsExceptionWhenPasswordIsMissing(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE - ] - ); - $this->challenge->challenge(self::SERVER_FIRST); - } - - public function testClientEncodesSpecialCharactersInUsername(): void - { - $context = $this->challenge->challenge(null, [ - 'username' => 'user=name,test', - 'cnonce' => self::CNONCE, - ]); - - $this->assertStringContainsString( - 'n=user=3Dname=2Ctest', - (string) $context->getResponse() - ); - } - - public function testChannelBindingClientFirstUsesCorrectGs2Header(): void - { - $plusChallenge = new ScramChallenge(false, 'sha256', true); - $context = $plusChallenge->challenge(null, [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE, - 'cbind_type' => 'tls-unique', - ]); - - $this->assertStringStartsWith( - 'p=tls-unique,,', - (string) $context->getResponse() - ); - } - - public function testServerReturnsNullResponseForInitialCall(): void - { - $server = new ScramChallenge( - true, - 'sha1', - false - ); - $context = $server->challenge(); - - $this->assertNull($context->getResponse()); - $this->assertFalse($context->isComplete()); - } - - public function testServerFirstMessageContainsClientNoncePrefix(): void - { - $server = new ScramChallenge( - true, - 'sha1', - false - ); - $context = $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => self::ITERATIONS, - ]); - - $this->assertSame( - self::SERVER_FIRST, - $context->getResponse() - ); - $this->assertFalse($context->isComplete()); - } - - public function testServerFinalMessageOnValidProof(): void - { - $server = new ScramChallenge( - true, - 'sha1', - false - ); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => self::ITERATIONS, - ]); - $context = $server->challenge( - self::CLIENT_FINAL, - ['password' => self::PASSWORD] - ); - - $this->assertSame( - self::SERVER_FINAL, - $context->getResponse() - ); - $this->assertTrue($context->isComplete()); - $this->assertTrue($context->isAuthenticated()); - } - - public function testServerFinalMessageOnInvalidProof(): void - { - $server = new ScramChallenge(true, 'sha1', false); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => self::ITERATIONS, - ]); - // Send a valid-looking client-final but with the wrong password on the server - $context = $server->challenge(self::CLIENT_FINAL, ['password' => 'wrongpassword']); - - $this->assertStringStartsWith( - 'e=', - (string) $context->getResponse() - ); - $this->assertTrue($context->isComplete()); - $this->assertFalse($context->isAuthenticated()); - } - - public function testServerFinalMessageWhenPasswordIsMissing(): void - { - $server = new ScramChallenge( - true, - 'sha1', - false - ); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => self::ITERATIONS, - ]); - $context = $server->challenge(self::CLIENT_FINAL, []); - - $this->assertSame( - 'e=invalid-proof', - $context->getResponse() - ); - $this->assertTrue($context->isComplete()); - $this->assertFalse($context->isAuthenticated()); - } - - public function testClientThrowsExceptionWhenServerIterationCountIsBelowMinimum(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - ['username' => self::USERNAME, 'cnonce' => self::CNONCE] - ); - // i=1 is below the SHA-1 minimum of 4096 - $this->challenge->challenge( - 'r=' . self::CNONCE . self::SNONCE . ',s=' . self::SALT_B64 . ',i=1', - ['password' => self::PASSWORD] - ); - } - - public function testServerThrowsExceptionWhenSuppliedIterationCountIsZero(): void - { - $this->expectException(SaslException::class); - - $server = new ScramChallenge(true, 'sha1', false); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => 0, - ]); - } - - public function testClientThrowsExceptionOnInvalidBase64Salt(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - ['username' => self::USERNAME, 'cnonce' => self::CNONCE] - ); - // s= value is not valid base64 - $this->challenge->challenge( - 'r=' . self::CNONCE . self::SNONCE . ',s=not!!valid!!base64,i=' . self::ITERATIONS, - ['password' => self::PASSWORD] - ); - } - - public function testServerReturnsInvalidProofOnMalformedBase64Proof(): void - { - $server = new ScramChallenge(true, 'sha1', false); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => self::ITERATIONS, - ]); - // Replace the valid proof with invalid base64 - $tampered = preg_replace('/,p=[^,]+$/', ',p=not!!valid', self::CLIENT_FINAL); - $context = $server->challenge($tampered, ['password' => self::PASSWORD]); - - $this->assertSame('e=invalid-proof', $context->getResponse()); - $this->assertFalse($context->isAuthenticated()); - } - - public function testClientThrowsExceptionWhenClientFinalCalledWithoutClientFirst(): void - { - $this->expectException(SaslException::class); - - // Skip client-first and call directly with a server-first message - $this->challenge->challenge( - self::SERVER_FIRST, - ['password' => self::PASSWORD] - ); - } - - public function testClientThrowsExceptionOnInvalidChannelBindingType(): void - { - $this->expectException(SaslException::class); - - $plusChallenge = new ScramChallenge(false, 'sha256', true); - $plusChallenge->challenge(null, [ - 'username' => self::USERNAME, - 'cnonce' => self::CNONCE, - 'cbind_type' => 'invalid,,type', - ]); - } - - public function testThrowsExceptionOnUnsupportedHashAlgorithm(): void - { - $this->expectException(SaslException::class); - - new ScramChallenge(false, 'md5'); - } - - public function testClientThrowsExceptionWhenServerIterationCountExceedsMaximum(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge( - null, - ['username' => self::USERNAME, 'cnonce' => self::CNONCE] - ); - $this->challenge->challenge( - 'r=' . self::CNONCE . self::SNONCE . ',s=' . self::SALT_B64 . ',i=1000001', - ['password' => self::PASSWORD] - ); - } - - public function testServerThrowsExceptionWhenSuppliedIterationCountExceedsMaximum(): void - { - $this->expectException(SaslException::class); - - $server = new ScramChallenge(true, 'sha1', false); - $server->challenge(self::CLIENT_FIRST, [ - 'nonce' => self::SNONCE, - 'salt' => base64_decode(self::SALT_B64), - 'iterations' => 1000001, - ]); - } - - public function testClientThrowsExceptionOnEmptyCnonce(): void - { - $this->expectException(SaslException::class); - - $this->challenge->challenge(null, [ - 'username' => self::USERNAME, - 'cnonce' => '', - ]); - } - - public function testFullClientServerRoundTrip(): void - { - $client = new ScramChallenge( - false, - 'sha256' - ); - $server = new ScramChallenge( - true, - 'sha256', - false - ); - - // Round 1: client-first -> server - $clientFirst = $client->challenge( - null, - ['username' => self::USERNAME] - )->getResponse(); - $this->assertNotNull($clientFirst); - - // Round 2: server processes client-first, sends server-first - $serverFirst = $server->challenge($clientFirst)->getResponse(); - $this->assertNotNull($serverFirst); - - // Round 3: client processes server-first, sends client-final - $clientFinal = $client->challenge( - $serverFirst, - ['password' => self::PASSWORD] - )->getResponse(); - $this->assertNotNull($clientFinal); - - // Round 4: server processes client-final, sends server-final - $serverContext = $server->challenge( - $clientFinal, - ['password' => self::PASSWORD] - ); - $serverFinal = $serverContext->getResponse(); - $this->assertStringStartsWith( - 'v=', - (string) $serverFinal - ); - $this->assertTrue($serverContext->isAuthenticated()); - - // Round 5: client verifies server-final - $clientContext = $client->challenge($serverFinal); - $this->assertTrue($clientContext->isComplete()); - $this->assertTrue($clientContext->isAuthenticated()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Encoder/ScramEncoderTest.php b/tests/unit/FreeDSx/Sasl/Encoder/ScramEncoderTest.php deleted file mode 100644 index 694344f..0000000 --- a/tests/unit/FreeDSx/Sasl/Encoder/ScramEncoderTest.php +++ /dev/null @@ -1,192 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Encoder; - -use FreeDSx\Sasl\Encoder\ScramEncoder; -use FreeDSx\Sasl\Exception\SaslEncodingException; -use FreeDSx\Sasl\Message; -use FreeDSx\Sasl\SaslContext; -use PHPUnit\Framework\TestCase; - -class ScramEncoderTest extends TestCase -{ - /** - * @var ScramEncoder - */ - private $encoder; - - /** - * @var SaslContext - */ - private $context; - - public function setUp(): void - { - parent::setUp(); - $this->encoder = new ScramEncoder(); - $this->context = new SaslContext(); - } - - public function testEncodeProducesCommaSeparatedAttrValuePairs(): void - { - $message = new Message([ - 'n' => 'user', - 'r' => 'fyko+d2lbbFgONRv9qkxdawL', - ]); - - $encoded = $this->encoder->encode($message, $this->context); - - $this->assertSame( - 'n=user,r=fyko+d2lbbFgONRv9qkxdawL', - $encoded - ); - } - - public function testEncodePreservesBase64ValuesWithEqualSigns(): void - { - $message = new Message([ - 'v' => 'rmF9pqV8S7suAoZWja4dJRkFsKQ=', - ]); - - $this->assertSame( - 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ=', - $this->encoder->encode($message, $this->context) - ); - } - - public function testDecodeClientFirstMessageStripsGs2Header(): void - { - // n,, is the GS2 header for a standard (non-channel-binding) client-first-message - $decoded = $this->encoder->decode('n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL', $this->context); - - $this->assertSame( - 'n,,', - $decoded->get('gs2-header') - ); - $this->assertSame( - 'user', - $decoded->get('n') - ); - $this->assertSame( - 'fyko+d2lbbFgONRv9qkxdawL', - $decoded->get('r') - ); - } - - public function testDecodeClientFirstMessageWithChannelBindingGs2Header(): void - { - $decoded = $this->encoder->decode('p=tls-unique,,n=user,r=clientnonce', $this->context); - - $this->assertSame( - 'p=tls-unique,,', - $decoded->get('gs2-header') - ); - $this->assertSame( - 'user', - $decoded->get('n') - ); - $this->assertSame( - 'clientnonce', - $decoded->get('r') - ); - } - - public function testDecodeClientFirstMessageWithYGs2Header(): void - { - $decoded = $this->encoder->decode('y,,n=user,r=clientnonce', $this->context); - - $this->assertSame( - 'y,,', - $decoded->get('gs2-header') - ); - $this->assertSame( - 'user', - $decoded->get('n') - ); - } - - public function testDecodeServerFirstMessage(): void - { - $decoded = $this->encoder->decode( - 'r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096', - $this->context - ); - - $this->assertSame( - 'fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j', - $decoded->get('r') - ); - $this->assertSame( - 'QSXCR+Q6sek8bf92', - $decoded->get('s') - ); - $this->assertSame( - '4096', - $decoded->get('i') - ); - $this->assertNull($decoded->get('gs2-header')); - } - - public function testDecodeClientFinalMessage(): void - { - $decoded = $this->encoder->decode( - 'c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=', - $this->context - ); - - $this->assertSame( - 'biws', - $decoded->get('c') - ); - $this->assertSame( - 'fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j', - $decoded->get('r') - ); - $this->assertSame( - 'v0X8v3Bz2T0CJGbJQyF0X+HI4Ts=', - $decoded->get('p') - ); - } - - public function testDecodeServerFinalMessage(): void - { - $decoded = $this->encoder->decode( - 'v=rmF9pqV8S7suAoZWja4dJRkFsKQ=', - $this->context - ); - - $this->assertSame( - 'rmF9pqV8S7suAoZWja4dJRkFsKQ=', - $decoded->get('v') - ); - } - - public function testDecodeThrowsOnMalformedAttributeWithNoEquals(): void - { - $this->expectException(SaslEncodingException::class); - - $this->encoder->decode( - 'r=validnonce,malformed', - $this->context - ); - } - - public function testDecodeThrowsOnMandatoryExtension(): void - { - $this->expectException(SaslEncodingException::class); - - $this->encoder->decode( - 'm=unsupported,r=nonce', - $this->context - ); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Factory/DigestMD5MessageFactoryTest.php b/tests/unit/FreeDSx/Sasl/Factory/DigestMD5MessageFactoryTest.php deleted file mode 100644 index cd7c024..0000000 --- a/tests/unit/FreeDSx/Sasl/Factory/DigestMD5MessageFactoryTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Factory; - -use FreeDSx\Sasl\Encoder\DigestMD5Encoder; -use FreeDSx\Sasl\Factory\DigestMD5MessageFactory; -use FreeDSx\Sasl\Message; -use FreeDSx\Sasl\SaslContext; -use PHPUnit\Framework\TestCase; - -class DigestMD5MessageFactoryTest extends TestCase -{ - protected $factory; - - protected $encoder; - - public function setUp(): void - { - $this->factory = new DigestMD5MessageFactory(); - $this->encoder = new DigestMD5Encoder(); - } - - public function testCreateServerChallenge() - { - $challenge = $this->factory->create( - DigestMD5MessageFactory::MESSAGE_SERVER_CHALLENGE, ['use_integrity' => true, 'use_privacy' => true] - ); - - $this->assertCount(7, $challenge->toArray()); - $this->assertEquals('md5-sess', $challenge->get('algorithm')); - $this->assertEquals(['auth', 'auth-int', 'auth-conf'], $challenge->get('qop')); - $this->assertEquals('65536', $challenge->get('maxbuf')); - $this->assertEquals('utf-8', $challenge->get('charset')); - $this->assertNotEmpty($challenge->get('realm'), 'The realm value is empty.'); - $this->assertNotEmpty($challenge->get('nonce'), 'The nonce value is empty.'); - $this->assertNotEmpty($challenge->get('cipher'), 'The cipher value is empty.'); - } - - public function testCreateClientResponse() - { - $challenge = $this->encoder->decode(hex2bin('6e6f6e63653d225a7a6b307578374b674f56506d4e37644c6f66476d394b714e6573626e43585263494151536d787551456b3d222c7265616c6d3d226875682d737973222c716f703d22617574682c617574682d696e742c617574682d636f6e66222c6369706865723d227263342d34302c7263342d35362c7263342c6465732c33646573222c6d61786275663d36353533362c636861727365743d7574662d382c616c676f726974686d3d6d64352d73657373'), new SaslContext()); - $response = $this->factory->create(DigestMD5MessageFactory::MESSAGE_CLIENT_RESPONSE, ['service' => 'ldap'], $challenge); - - $this->assertCount(9, $response->toArray()); - $this->assertEquals('auth-conf', $response->get('qop')); - $this->assertStringStartsWith('ldap/huh-sys', $response->get('digest-uri')); - $this->assertEquals("00000001", $response->get('nc')); - $this->assertEquals("md5-sess", $response->get('algorithm')); - $this->assertNotEmpty($response->get('realm'), 'The realm value is empty.'); - $this->assertNotEmpty($response->get('username'), 'The username value is empty.'); - $this->assertNotEmpty($response->get('cnonce'), 'The cnonce value is empty.'); - $this->assertNotEmpty($response->get('nonce'), 'The nonce value is empty.'); - } - - public function testCreateServerResponse() - { - $response = $this->factory->create(DigestMD5MessageFactory::MESSAGE_SERVER_RESPONSE, ['rspauth' => 'foobar']); - - $this->assertEquals($response, new Message(['rspauth' => 'foobar'])); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Mechanism/AnonymousMechanismTest.php b/tests/unit/FreeDSx/Sasl/Mechanism/AnonymousMechanismTest.php deleted file mode 100644 index abfe0eb..0000000 --- a/tests/unit/FreeDSx/Sasl/Mechanism/AnonymousMechanismTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Mechanism; - -use FreeDSx\Sasl\Challenge\AnonymousChallenge; -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\AnonymousMechanism; -use PHPUnit\Framework\TestCase; - -class AnonymousMechanismTest extends TestCase -{ - /** - * @var AnonymousMechanism - */ - protected $mechanism; - - public function setUp(): void - { - parent::setUp(); - $this->mechanism = new AnonymousMechanism(); - } - - public function testSecurityStrength() - { - $strength = $this->mechanism->securityStrength(); - - $this->assertFalse($strength->supportsPrivacy()); - $this->assertFalse($strength->supportsIntegrity()); - $this->assertFalse($strength->supportsAuth()); - $this->assertFalse($strength->isPlainTextAuth()); - $this->assertEquals(0, $strength->maxKeySize()); - } - - public function testSecurityThrowsAnException() - { - $this->expectException(SaslException::class); - - $this->mechanism->securityLayer(); - } - - public function testChallengeReturnsThePlainChallenge() - { - $this->assertInstanceOf(AnonymousChallenge::class, $this->mechanism->challenge()); - } - - public function testGetName() - { - $this->assertEquals('ANONYMOUS', $this->mechanism->getName()); - } -} \ No newline at end of file diff --git a/tests/unit/FreeDSx/Sasl/Mechanism/CramMD5MechanismTest.php b/tests/unit/FreeDSx/Sasl/Mechanism/CramMD5MechanismTest.php deleted file mode 100644 index 3abd028..0000000 --- a/tests/unit/FreeDSx/Sasl/Mechanism/CramMD5MechanismTest.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Mechanism; - -use FreeDSx\Sasl\Challenge\CramMD5Challenge; -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; -use PHPUnit\Framework\TestCase; - -class CramMD5MechanismTest extends TestCase -{ - protected $mechanism; - - public function setUp(): void - { - parent::setUp(); - $this->mechanism = new CramMD5Mechanism(); - } - - public function testSecurityStrength() - { - $strength = $this->mechanism->securityStrength(); - - $this->assertFalse($strength->supportsPrivacy()); - $this->assertFalse($strength->supportsIntegrity()); - $this->assertTrue($strength->supportsAuth()); - $this->assertFalse($strength->isPlainTextAuth()); - $this->assertEquals(0, $strength->maxKeySize()); - } - - public function testSecurityThrowsAnException() - { - $this->expectException(SaslException::class); - - $this->mechanism->securityLayer(); - } - - public function testChallenge() - { - $challenge = $this->mechanism->challenge(); - - $this->assertInstanceOf(CramMD5Challenge::class, $challenge); - } - - public function testGetName() - { - $this->assertEquals('CRAM-MD5', $this->mechanism->getName()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Mechanism/PlainMechanismTest.php b/tests/unit/FreeDSx/Sasl/Mechanism/PlainMechanismTest.php deleted file mode 100644 index 82101f9..0000000 --- a/tests/unit/FreeDSx/Sasl/Mechanism/PlainMechanismTest.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Mechanism; - -use FreeDSx\Sasl\Challenge\PlainChallenge; -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\PlainMechanism; -use PHPUnit\Framework\TestCase; - -class PlainMechanismTest extends TestCase -{ - protected $mechanism; - - public function setUp(): void - { - parent::setUp(); - $this->mechanism = new PlainMechanism(); - } - - public function testSecurityStrength() - { - $strength = $this->mechanism->securityStrength(); - - $this->assertFalse($strength->supportsPrivacy()); - $this->assertFalse($strength->supportsIntegrity()); - $this->assertTrue($strength->supportsAuth()); - $this->assertTrue($strength->isPlainTextAuth()); - $this->assertEquals(0, $strength->maxKeySize()); - } - - public function testSecurityThrowsAnException() - { - $this->expectException(SaslException::class); - - $this->mechanism->securityLayer(); - } - - public function testChallengeReturnsThePlainChallenge() - { - $this->assertInstanceOf(PlainChallenge::class, $this->mechanism->challenge()); - } - - public function testGetName() - { - $this->assertEquals('PLAIN', $this->mechanism->getName()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Mechanism/ScramMechanismTest.php b/tests/unit/FreeDSx/Sasl/Mechanism/ScramMechanismTest.php deleted file mode 100644 index 4edd6ec..0000000 --- a/tests/unit/FreeDSx/Sasl/Mechanism/ScramMechanismTest.php +++ /dev/null @@ -1,109 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Mechanism; - -use FreeDSx\Sasl\Challenge\ScramChallenge; -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\ScramMechanism; -use PHPUnit\Framework\TestCase; - -class ScramMechanismTest extends TestCase -{ - /** - * @var ScramMechanism - */ - private $mechanism; - - public function setUp(): void - { - parent::setUp(); - - $this->mechanism = new ScramMechanism(ScramMechanism::SHA256); - } - - public function testGetName(): void - { - $this->assertSame( - 'SCRAM-SHA-256', - $this->mechanism->getName() - ); - } - - /** - * @dataProvider variantNameProvider - */ - public function testGetNameForAllVariants(string $name): void - { - $mechanism = new ScramMechanism($name); - - $this->assertSame( - $name, - $mechanism->getName() - ); - } - - public function variantNameProvider(): array - { - return [ - [ScramMechanism::SHA1], - [ScramMechanism::SHA1_PLUS], - [ScramMechanism::SHA224], - [ScramMechanism::SHA224_PLUS], - [ScramMechanism::SHA256], - [ScramMechanism::SHA256_PLUS], - [ScramMechanism::SHA384], - [ScramMechanism::SHA384_PLUS], - [ScramMechanism::SHA512], - [ScramMechanism::SHA512_PLUS], - [ScramMechanism::SHA3_512], - [ScramMechanism::SHA3_512_PLUS], - ]; - } - - public function testSecurityStrength(): void - { - $strength = $this->mechanism->securityStrength(); - - $this->assertFalse($strength->supportsPrivacy()); - $this->assertFalse($strength->supportsIntegrity()); - $this->assertTrue($strength->supportsAuth()); - $this->assertFalse($strength->isPlainTextAuth()); - $this->assertSame(0, $strength->maxKeySize()); - } - - public function testSecurityLayerThrowsAnException(): void - { - $this->expectException(SaslException::class); - - $this->mechanism->securityLayer(); - } - - public function testChallengeReturnsScramChallenge(): void - { - $this->assertInstanceOf(ScramChallenge::class, $this->mechanism->challenge()); - } - - public function testInvalidVariantThrowsAnException(): void - { - $this->expectException(SaslException::class); - - new ScramMechanism('SCRAM-MD5'); - } - - public function testToString(): void - { - $this->assertSame( - 'SCRAM-SHA-256', - (string) $this->mechanism - ); - } -} diff --git a/tests/unit/FreeDSx/Sasl/MechanismSelectorTest.php b/tests/unit/FreeDSx/Sasl/MechanismSelectorTest.php deleted file mode 100644 index 9595fa3..0000000 --- a/tests/unit/FreeDSx/Sasl/MechanismSelectorTest.php +++ /dev/null @@ -1,109 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl; - -use FreeDSx\Sasl\Exception\SaslException; -use FreeDSx\Sasl\Mechanism\AnonymousMechanism; -use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; -use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; -use FreeDSx\Sasl\Mechanism\PlainMechanism; -use FreeDSx\Sasl\MechanismSelector; -use PHPUnit\Framework\TestCase; - -class MechanismSelectorTest extends TestCase -{ - /** - * @var MechanismSelector - */ - protected $selector; - - public function setUp(): void - { - parent::setUp(); - $this->selector = new MechanismSelector([ - new PlainMechanism(), - new CramMD5Mechanism(), - new AnonymousMechanism(), - new DigestMD5Mechanism(), - ]); - } - - public function testSelectWithNoChoicesSpecified() - { - $choice = $this->selector->select(); - - $this->assertEquals('DIGEST-MD5', $choice->getName()); - } - - public function testSelectWithAnonymousOrAuthOnly() - { - $choice = $this->selector->select(['CRAM-MD5', 'ANONYMOUS']); - - $this->assertEquals('CRAM-MD5', $choice->getName()); - } - - public function testSelectWithUseIntegrity() - { - $choice = $this->selector->select([], ['use_integrity' => true]); - - $this->assertEquals('DIGEST-MD5', $choice->getName()); - } - - public function testSelectWithUsePrivacy() - { - $choice = $this->selector->select([], ['use_integrity' => true]); - - $this->assertEquals('DIGEST-MD5', $choice->getName()); - } - - public function testSelectWithoutPrivacyOrIntegrityStillSelectsTheBetterChoiceForAuth() - { - $choice = $this->selector->select([], ['use_integrity' => false, 'use_privacy' => false]); - - $this->assertEquals('DIGEST-MD5', $choice->getName()); - } - - public function testSelectChoosesAuthChoicesCorrectly() - { - $choice = $this->selector->select(['ANONYMOUS', 'PLAIN', 'CRAM-MD5']); - - $this->assertEquals('CRAM-MD5', $choice->getName()); - } - - public function testSelectChoosesAuthOverAnon() - { - $choice = $this->selector->select(['ANONYMOUS', 'PLAIN']); - - $this->assertEquals('PLAIN', $choice->getName()); - } - - public function testSelectThrowsAnExceptionIfNoMechsMeetsIntegrityRequirements() - { - $this->expectException(SaslException::CLASS); - - $this->selector->select(['ANONYMOUS', 'PLAIN', 'CRAM-MD5'], ['use_integrity' => true]); - } - - public function testSelectThrowsAnExceptionIfNoMechsMeetsPrivacyRequirements() - { - $this->expectException(SaslException::CLASS); - - $this->selector->select(['ANONYMOUS', 'PLAIN', 'CRAM-MD5'], ['use_privacy' => true]); - } - - public function testSelectThrowsAnExceptionIfNoSupportedMechsAreFound() - { - $this->expectException(SaslException::CLASS); - - $this->selector->select(['FOO']); - } -} diff --git a/tests/unit/FreeDSx/Sasl/MessageTest.php b/tests/unit/FreeDSx/Sasl/MessageTest.php deleted file mode 100644 index f278bf5..0000000 --- a/tests/unit/FreeDSx/Sasl/MessageTest.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl; - -use FreeDSx\Sasl\Message; -use PHPUnit\Framework\TestCase; - -class MessageTest extends TestCase -{ - /** - * @var Message - */ - protected $message; - - public function setUp(): void - { - $this->message = new Message(['foo' => 'bar', 'bar' => 'foo']); - } - - public function testSet() - { - $this->message->set('name', 'test'); - - $this->assertEquals('test', $this->message->get('name')); - } - - public function testGet() - { - $this->assertEquals('bar', $this->message->get('foo')); - } - - public function testCount() - { - $this->assertEquals(2, $this->message->count()); - } - - public function testHas() - { - $this->assertEquals(true, $this->message->has('foo')); - $this->assertEquals(false, $this->message->has('nothing')); - } - - public function testToArray() - { - $this->assertEquals([ - 'foo' => 'bar', - 'bar' => 'foo' - ], $this->message->toArray()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/SaslContextTest.php b/tests/unit/FreeDSx/Sasl/SaslContextTest.php deleted file mode 100644 index 8869488..0000000 --- a/tests/unit/FreeDSx/Sasl/SaslContextTest.php +++ /dev/null @@ -1,98 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl; - -use PHPUnit\Framework\TestCase; -use FreeDSx\Sasl\SaslContext; - -class SaslContextTest extends TestCase -{ - /** - * @var SaslContext - */ - protected $context; - - public function setUp(): void - { - $this->context = new SaslContext(['username' => 'foo']); - } - - public function testHas() - { - $this->assertFalse($this->context->has('foo')); - $this->assertTrue($this->context->has('username')); - } - - public function testIsAuthenticated() - { - $this->assertFalse($this->context->isAuthenticated()); - } - - public function testIsServerMode() - { - $this->assertFalse($this->context->isServerMode()); - } - - public function testSetIsServerMode() - { - $this->context->setIsServerMode(true); - - $this->assertTrue($this->context->isServerMode()); - } - - public function testGetData() - { - $this->assertEquals(['username' => 'foo'], $this->context->getData()); - } - - public function testSetIsAuthenticated() - { - $this->context->setIsAuthenticated(true); - - $this->assertTrue($this->context->isAuthenticated()); - } - - public function testSet() - { - $this->context->set('foo', 'bar'); - - $this->assertEquals('bar', $this->context->get('foo')); - } - - public function testSetHasSecurityLayer() - { - $this->context->setHasSecurityLayer(true); - - $this->assertTrue($this->context->hasSecurityLayer()); - } - - public function testHasSecurityLayer() - { - $this->assertFalse($this->context->hasSecurityLayer()); - } - - public function testGet() - { - $this->assertEquals('foo', $this->context->get('username')); - } - - public function testGetResponse() - { - $this->assertNull($this->context->getResponse()); - } - - public function testSetResponse() - { - $this->context->setResponse('foo'); - - $this->assertEquals('foo', $this->context->getResponse()); - } -} diff --git a/tests/unit/FreeDSx/Sasl/SaslTest.php b/tests/unit/FreeDSx/Sasl/SaslTest.php deleted file mode 100644 index a73576e..0000000 --- a/tests/unit/FreeDSx/Sasl/SaslTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl; - -use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; -use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; -use FreeDSx\Sasl\Mechanism\MechanismInterface; -use FreeDSx\Sasl\Sasl; -use PHPUnit\Framework\TestCase; - -class SaslTest extends TestCase -{ - /** - * @var Sasl - */ - protected $sasl; - - public function setUp(): void - { - $this->sasl = new Sasl(); - } - - public function testAdd() - { - $mechMock = $this->createMock(MechanismInterface::class); - $mechMock->method('getName')->willReturn('FOO'); - - $this->sasl->add($mechMock); - $this->assertEquals($mechMock, $this->sasl->get('FOO')); - } - - public function testMechanisms() - { - $mechs = $this->sasl->mechanisms(); - - $this->assertArrayHasKey('DIGEST-MD5', $mechs); - $this->assertArrayHasKey('CRAM-MD5', $mechs); - $this->assertArrayHasKey('PLAIN', $mechs); - $this->assertArrayHasKey('ANONYMOUS', $mechs); - $this->assertCount(16, $mechs); - } - - public function testRemove() - { - $this->sasl->remove('DIGEST-MD5'); - - $this->assertFalse($this->sasl->supports('DIGEST-MD5')); - } - - public function testGetDigestMD5() - { - $this->assertInstanceOf(DigestMD5Mechanism::class, $this->sasl->get('DIGEST-MD5')); - } - - public function testGetCramMD5() - { - $this->assertInstanceOf(CramMD5Mechanism::class, $this->sasl->get('CRAM-MD5')); - } - - public function testSupportedOption() - { - $sasl = new Sasl(['supported' => ['foo']]); - $this->assertEmpty($sasl->mechanisms()); - - $sasl = new Sasl(['supported' => ['DIGEST-MD5']]); - $this->assertCount(1, $sasl->mechanisms()); - } - - public function testSelect() - { - $this->assertInstanceOf(DigestMD5Mechanism::class, $this->sasl->select()); - $this->assertInstanceOf(CramMD5Mechanism::class, $this->sasl->select(['CRAM-MD5'])); - } -} diff --git a/tests/unit/FreeDSx/Sasl/Security/DigestMD5SecurityLayerTest.php b/tests/unit/FreeDSx/Sasl/Security/DigestMD5SecurityLayerTest.php deleted file mode 100644 index 607e4be..0000000 --- a/tests/unit/FreeDSx/Sasl/Security/DigestMD5SecurityLayerTest.php +++ /dev/null @@ -1,250 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl\Security; - -use FreeDSx\Sasl\SaslContext; -use FreeDSx\Sasl\Security\DigestMD5SecurityLayer; -use PHPUnit\Framework\TestCase; - -class DigestMD5SecurityLayerTest extends TestCase -{ - /** - * @var DigestMD5SecurityLayer - */ - protected $security; - - /** - * @var SaslContext - */ - protected $clientContext; - - /** - * @var SaslContext - */ - protected $serverContext; - - public function setUp(): void - { - $this->markTestSkipped('Need to investigate why this is failing on all recent OS types :('); - $this->security = new DigestMD5SecurityLayer(); - $this->clientContext = new SaslContext(); - $this->clientContext->setData([ - "username" => "WillifoA", - "digest-uri" => "ldap/huh-sys", - "qop" => "auth-conf", - "seqnumrcv" => 0, - "seqnumsnt" => 0, - ]); - $this->clientContext->setIsAuthenticated(true); - $this->clientContext->setHasSecurityLayer(true); - - $this->serverContext = new SaslContext(); - $this->serverContext->setData([ - "username" => "WillifoA", - "digest-uri" => "ldap/huh-sys", - "qop" => "auth-conf", - "seqnumrcv" => 0, - "seqnumsnt" => 0, - ]); - $this->serverContext->setIsAuthenticated(true); - $this->serverContext->setHasSecurityLayer(true); - $this->serverContext->setIsServerMode(true); - } - - public function testUnWrapWithPrivacyRC4SeqNumNonZero() - { - $this->markTestSkipped('Messages past the first sequence number do not decrypt. Unable to determine what is actually wrong here.'); - - $this->clientContext->set('a1', hex2bin("5c002ca5ad1405892f3a5e4bc594b08a")); - $this->clientContext->set('cipher', 'rc4'); - $this->clientContext->set('seqnumrcv', 1); - $this->clientContext->set('seqnumsnt', 1); - - $this->serverContext->set('a1', hex2bin("5c002ca5ad1405892f3a5e4bc594b08a")); - $this->serverContext->set('cipher', 'rc4'); - $this->serverContext->set('seqnumrcv', 1); - $this->serverContext->set('seqnumsnt', 1); - - $resdoneMsg = hex2bin('300c02010365070a010004000400'); - #$resentryMsg = hex2bin('3070020103646b0446636e3d41646d696e2057696c6c69666f72642c6f753d41646d696e6973747261746976652c6f753d467265654453782d546573742c64633d6578616d706c652c64633d636f6d3021301f04046d61696c3117041557696c6c69666f41406e732d6d61696c382e636f6d'); - $resdoneEnc = hex2bin('20ebcfb4998c974483008a48f3003202ba151ab98074035e000100000001'); - #$resEntryEnc = hex2bin('d73c8d2c02c2f5f1fe37b4d7bd0c40ca8906b888ee8be3da1434558a42992e041dbd28ff6f53fab9136aece40ae15905144a76a55bf1b6560a9ffa558158cc05e5b97eb9206615dede875c73a8df036dcf7d200e1acf49125310818f8a5972be65f3367c26f9007441887346bd39ea9ba063ee81f599bc119cba6939000100000000'); - - $this->assertEquals( - $resdoneEnc, - $this->security->wrap($resdoneMsg, $this->serverContext) - ); - } - - public function testWrapWithPrivacyRC4() - { - $this->clientContext->set('a1', hex2bin("960bcfc7a190d6b1dcabcd5bc7f53fe0")); - $this->clientContext->set('cipher', 'rc4'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - hex2bin('6c9849465f4a88af35b82a6b8f35295e8c60e9d17245f3a8fbe5be1ca6599d7aef09b7f5caf7f43a7eea74b02c669c6a9bb346ba863ab843cf51eccd5b0b570f912e910dd234000100000000'), - $this->security->wrap($message, $this->clientContext) - ); - } - - public function testUnwrapWithPrivacyRC4() - { - $this->markSkippedIfCipherNotSupported('rc4'); - $this->serverContext->set('a1', hex2bin("960bcfc7a190d6b1dcabcd5bc7f53fe0")); - $this->serverContext->set('cipher', 'rc4'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - $message, - $this->security->unwrap(hex2bin('6c9849465f4a88af35b82a6b8f35295e8c60e9d17245f3a8fbe5be1ca6599d7aef09b7f5caf7f43a7eea74b02c669c6a9bb346ba863ab843cf51eccd5b0b570f912e910dd234000100000000'), $this->serverContext) - ); - } - - public function testWrapWithRC440() - { - $this->clientContext->set('a1', hex2bin('407a52fb725042db234e11b34fb5fd55')); - $this->clientContext->set('cipher', 'rc4-40'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - hex2bin('056173eaae8b7ac19b1f0d73e8e340c37e83b6ab23377f143b1d2722cf11657b1de53c61ce7a2898786b01a30ca940521c6ade80f01f155e798babc7a1275614656d1b74dee7000100000000'), - $this->security->wrap($message, $this->clientContext) - ); - } - - public function testUnwrapWithRC440() - { - $this->markSkippedIfCipherNotSupported('rc4-40'); - $this->serverContext->set('a1', hex2bin('407a52fb725042db234e11b34fb5fd55')); - $this->serverContext->set('cipher', 'rc4-40'); - $encrypted = hex2bin('056173eaae8b7ac19b1f0d73e8e340c37e83b6ab23377f143b1d2722cf11657b1de53c61ce7a2898786b01a30ca940521c6ade80f01f155e798babc7a1275614656d1b74dee7000100000000'); - - $this->assertEquals( - hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), - $this->security->unwrap($encrypted, $this->serverContext) - ); - } - - public function testWrapWithRC456() - { - $this->markTestSkipped('Cipher unavailable.'); - - $this->clientContext->set('a1', hex2bin('3b39571696572c05957522d94817ce46')); - $this->clientContext->set('cipher', 'rc4-56'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - hex2bin('96979065bdf4d9633698c2213aefd9e7ff99fb63d6bb10a65a67afc3cef4104f10a3e1855430c08c4539ce8e034717df84c537fb82f4e2bb8e6939806b18fcb7882feee2faeb000100000000'), - $this->security->wrap($message, $this->clientContext) - ); - } - - public function testUnwrapWithRC456() - { - $this->markTestSkipped('Cipher unavailable.'); - - $this->serverContext->set('a1', hex2bin('3b39571696572c05957522d94817ce46')); - $this->serverContext->set('cipher', 'rc4-56'); - $encrypted = hex2bin('96979065bdf4d9633698c2213aefd9e7ff99fb63d6bb10a65a67afc3cef4104f10a3e1855430c08c4539ce8e034717df84c537fb82f4e2bb8e6939806b18fcb7882feee2faeb000100000000'); - - - $this->assertEquals( - hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), - $this->security->unwrap($encrypted, $this->serverContext) - ); - } - - public function testWrapWithPrivacyDes() - { - $this->markTestIncomplete('Still needs to be tested with DES data.'); - } - - public function testUnwrapWithPrivacyDes() - { - $this->markTestIncomplete('Still needs to be tested with DES data.'); - } - - public function testWrapWithPrivacy3Des() - { - $this->clientContext->set('a1', hex2bin('9969a25310dd52c864715057ca181374')); - $this->clientContext->set('cipher', '3des'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - hex2bin('73ebc692c839c8382c4bc669076fa893d1deaca630a1c74dcb0354159680372e555863e2e609a0ce72f06b7bb64fed4e3cf30057af5fd23ff6e0ffda06eb7fdb67edc8b331723f02000100000000'), - $this->security->wrap($message, $this->clientContext) - ); - } - - public function testUnwrapWithPrivacy3Des() - { - $this->serverContext->set('a1', hex2bin('9969a25310dd52c864715057ca181374')); - $this->serverContext->set('cipher', '3des'); - $encrypted = hex2bin('73ebc692c839c8382c4bc669076fa893d1deaca630a1c74dcb0354159680372e555863e2e609a0ce72f06b7bb64fed4e3cf30057af5fd23ff6e0ffda06eb7fdb67edc8b331723f02000100000000'); - - $this->assertEquals( - hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), - $this->security->unwrap($encrypted, $this->serverContext) - ); - } - - public function testWrapWithOnlyIntegrity() - { - $this->clientContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); - $this->clientContext->set('qop', 'auth-int'); - $message = hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); - - $this->assertEquals( - hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), - $this->security->wrap($message, $this->clientContext) - ); - } - - public function testUnwrapWithOnlyIntegrity() - { - $this->serverContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); - $this->serverContext->set('qop', 'auth-int'); - - $this->assertEquals( - hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), - $this->security->unwrap(hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), $this->serverContext) - ); - } - - public function testThatItThrowsAnExceptionIfTheWrappedDataExceedsTheBufferSizeDuringUnwrap() - { - $context = new SaslContext(['maxbuf' => 2, 'qop' => 'auth-int']); - $this->expectExceptionMessage('The wrapped buffer exceeds the maxbuf length of 2'); - $this->security->unwrap('foo', $context); - } - - - public function testThatItThrowsAnExceptionIfTheWrappedDataExceedsTheBufferSizeDuringWrap() - { - $this->serverContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); - $this->serverContext->set('qop', 'auth-int'); - $this->serverContext->set('maxbuf', 2); - - $this->expectExceptionMessage('The wrapped buffer exceeds the maxbuf length of 2'); - $this->security->unwrap(hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), $this->serverContext); - } - - private function markSkippedIfCipherNotSupported(string $cipher): void - { - if (!(in_array($cipher, openssl_get_cipher_methods(), true))) { - $this->markTestSkipped(sprintf( - 'The cipher %s is not supported on this system.', - $cipher - )); - } - } -} diff --git a/tests/unit/FreeDSx/Sasl/SecurityStrengthTest.php b/tests/unit/FreeDSx/Sasl/SecurityStrengthTest.php deleted file mode 100644 index fcac93b..0000000 --- a/tests/unit/FreeDSx/Sasl/SecurityStrengthTest.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace unit\FreeDSx\Sasl; - -use FreeDSx\Sasl\SecurityStrength; -use PHPUnit\Framework\TestCase; - -class SecurityStrengthTest extends TestCase -{ - protected $strength; - - public function setUp(): void - { - parent::setUp(); - $this->strength = new SecurityStrength( - true, - true, - true, - false, - 512 - ); - } - - public function testSupportsIntegrity() - { - $this->assertTrue($this->strength->supportsIntegrity()); - } - - public function testSupportsPrivacy() - { - $this->assertTrue($this->strength->supportsPrivacy()); - } - - public function testSupportsAuth() - { - $this->assertTrue($this->strength->supportsAuth()); - } - - public function testIsPlainTextAuth() - { - $this->assertFalse($this->strength->isPlainTextAuth()); - } - - public function testMaxKeySize() - { - $this->assertEquals(512, $this->strength->maxKeySize()); - } -} diff --git a/tests/unit/Mechanism/AnonymousMechanismTest.php b/tests/unit/Mechanism/AnonymousMechanismTest.php new file mode 100644 index 0000000..cde13df --- /dev/null +++ b/tests/unit/Mechanism/AnonymousMechanismTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Mechanism; + +use FreeDSx\Sasl\Challenge\AnonymousChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\AnonymousMechanism; +use FreeDSx\Sasl\Mechanism\MechanismName; +use PHPUnit\Framework\TestCase; + +final class AnonymousMechanismTest extends TestCase +{ + private AnonymousMechanism $mechanism; + + protected function setUp(): void + { + $this->mechanism = new AnonymousMechanism(); + } + + public function testSecurityStrength(): void + { + $strength = $this->mechanism->securityStrength(); + + self::assertFalse($strength->supportsPrivacy()); + self::assertFalse($strength->supportsIntegrity()); + self::assertFalse($strength->supportsAuth()); + self::assertFalse($strength->isPlainTextAuth()); + self::assertSame(0, $strength->maxKeySize()); + } + + public function testSecurityThrowsAnException(): void + { + $this->expectException(SaslException::class); + + $this->mechanism->securityLayer(); + } + + public function testChallengeReturnsTheAnonymousChallenge(): void + { + self::assertInstanceOf(AnonymousChallenge::class, $this->mechanism->challenge()); + } + + public function testGetName(): void + { + self::assertSame(MechanismName::ANONYMOUS, $this->mechanism->getName()); + } +} diff --git a/tests/unit/Mechanism/CramMD5MechanismTest.php b/tests/unit/Mechanism/CramMD5MechanismTest.php new file mode 100644 index 0000000..c8d5edf --- /dev/null +++ b/tests/unit/Mechanism/CramMD5MechanismTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Mechanism; + +use FreeDSx\Sasl\Challenge\CramMD5Challenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; +use FreeDSx\Sasl\Mechanism\MechanismName; +use PHPUnit\Framework\TestCase; + +final class CramMD5MechanismTest extends TestCase +{ + private CramMD5Mechanism $mechanism; + + protected function setUp(): void + { + $this->mechanism = new CramMD5Mechanism(); + } + + public function testSecurityStrength(): void + { + $strength = $this->mechanism->securityStrength(); + + self::assertFalse($strength->supportsPrivacy()); + self::assertFalse($strength->supportsIntegrity()); + self::assertTrue($strength->supportsAuth()); + self::assertFalse($strength->isPlainTextAuth()); + self::assertSame(0, $strength->maxKeySize()); + } + + public function testSecurityThrowsAnException(): void + { + $this->expectException(SaslException::class); + + $this->mechanism->securityLayer(); + } + + public function testChallenge(): void + { + self::assertInstanceOf(CramMD5Challenge::class, $this->mechanism->challenge()); + } + + public function testGetName(): void + { + self::assertSame(MechanismName::CRAM_MD5, $this->mechanism->getName()); + } +} diff --git a/tests/unit/FreeDSx/Sasl/Mechanism/DigestMD5MechanismTest.php b/tests/unit/Mechanism/DigestMD5MechanismTest.php similarity index 58% rename from tests/unit/FreeDSx/Sasl/Mechanism/DigestMD5MechanismTest.php rename to tests/unit/Mechanism/DigestMD5MechanismTest.php index 73c4f57..9029818 100644 --- a/tests/unit/FreeDSx/Sasl/Mechanism/DigestMD5MechanismTest.php +++ b/tests/unit/Mechanism/DigestMD5MechanismTest.php @@ -1,4 +1,7 @@ mech = new DigestMD5Mechanism(); } - public function testToString() + public function testToString(): void { - $this->assertEquals('DIGEST-MD5', (string) $this->mech); + self::assertSame('DIGEST-MD5', (string) $this->mech); } - public function testSecurityStrength() + public function testSecurityStrength(): void { $strength = $this->mech->securityStrength(); - $this->assertTrue($strength->supportsPrivacy()); - $this->assertTrue($strength->supportsIntegrity()); - $this->assertTrue($strength->supportsAuth()); - $this->assertFalse($strength->isPlainTextAuth()); - $this->assertEquals(128, $strength->maxKeySize()); + self::assertTrue($strength->supportsPrivacy()); + self::assertTrue($strength->supportsIntegrity()); + self::assertTrue($strength->supportsAuth()); + self::assertFalse($strength->isPlainTextAuth()); + self::assertSame(128, $strength->maxKeySize()); } - public function testSecurity() + public function testSecurity(): void { - $this->assertInstanceOf(SecurityLayerInterface::class, $this->mech->securityLayer()); + self::assertInstanceOf(DigestMD5SecurityLayer::class, $this->mech->securityLayer()); } - public function testChallenge() + public function testChallenge(): void { - $this->assertInstanceOf(ChallengeInterface::class, $this->mech->challenge()); + self::assertInstanceOf(DigestMD5Challenge::class, $this->mech->challenge()); } - public function testGetName() + public function testGetName(): void { - $this->assertEquals('DIGEST-MD5', $this->mech->getName()); + self::assertSame(MechanismName::DIGEST_MD5, $this->mech->getName()); } - public function testComputeResponse() + public function testComputeResponse(): void { $challenge = new Message([ 'nonce' => 'iIAIQH05uQvpZmm2XR+ih3dDV3zuTidxtMO3PsFGRSI=', 'realm' => 'huh-sys', - 'qop' => ['auth', 'auth-int', 'auth-conf',], - 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des',], + 'qop' => ['auth', 'auth-int', 'auth-conf'], + 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des'], 'maxbuf' => '65536', 'charset' => 'utf-8', 'algorithm' => 'md5-sess', @@ -82,19 +83,19 @@ public function testComputeResponse() 'digest-uri' => 'ldap/huh-sys', ]); - $this->assertEquals( + self::assertSame( '16c0ee7bfa5fcc3b2d19b50f17ebb8f2', - DigestMD5Mechanism::computeResponse('Password1',$challenge, $response, false) + DigestMD5Mechanism::computeResponse('Password1', $challenge, $response, false), ); } - public function testComputeA1() + public function testComputeA1(): void { $challenge = new Message([ 'nonce' => 'iIAIQH05uQvpZmm2XR+ih3dDV3zuTidxtMO3PsFGRSI=', 'realm' => 'huh-sys', - 'qop' => ['auth', 'auth-int', 'auth-conf',], - 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des',], + 'qop' => ['auth', 'auth-int', 'auth-conf'], + 'cipher' => ['rc4-40', 'rc4-56', 'rc4', 'des', '3des'], 'maxbuf' => '65536', 'charset' => 'utf-8', 'algorithm' => 'md5-sess', @@ -111,9 +112,9 @@ public function testComputeA1() 'digest-uri' => 'ldap/huh-sys', ]); - $this->assertEquals( + self::assertSame( 'c951ca891eaedcf85e8c6c9d8763406d', - DigestMD5Mechanism::computeA1('Password1',$challenge, $response) + DigestMD5Mechanism::computeA1('Password1', $challenge, $response), ); } } diff --git a/tests/unit/Mechanism/PlainMechanismTest.php b/tests/unit/Mechanism/PlainMechanismTest.php new file mode 100644 index 0000000..4a4b42d --- /dev/null +++ b/tests/unit/Mechanism/PlainMechanismTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Mechanism; + +use FreeDSx\Sasl\Challenge\PlainChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Mechanism\PlainMechanism; +use PHPUnit\Framework\TestCase; + +final class PlainMechanismTest extends TestCase +{ + private PlainMechanism $mechanism; + + protected function setUp(): void + { + $this->mechanism = new PlainMechanism(); + } + + public function testSecurityStrength(): void + { + $strength = $this->mechanism->securityStrength(); + + self::assertFalse($strength->supportsPrivacy()); + self::assertFalse($strength->supportsIntegrity()); + self::assertTrue($strength->supportsAuth()); + self::assertTrue($strength->isPlainTextAuth()); + self::assertSame(0, $strength->maxKeySize()); + } + + public function testSecurityThrowsAnException(): void + { + $this->expectException(SaslException::class); + + $this->mechanism->securityLayer(); + } + + public function testChallengeReturnsThePlainChallenge(): void + { + self::assertInstanceOf(PlainChallenge::class, $this->mechanism->challenge()); + } + + public function testGetName(): void + { + self::assertSame(MechanismName::PLAIN, $this->mechanism->getName()); + } +} diff --git a/tests/unit/Mechanism/ScramMechanismTest.php b/tests/unit/Mechanism/ScramMechanismTest.php new file mode 100644 index 0000000..f234368 --- /dev/null +++ b/tests/unit/Mechanism/ScramMechanismTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Mechanism; + +use FreeDSx\Sasl\Challenge\ScramChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Mechanism\ScramMechanism; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class ScramMechanismTest extends TestCase +{ + private ScramMechanism $mechanism; + + protected function setUp(): void + { + $this->mechanism = new ScramMechanism(MechanismName::SCRAM_SHA256); + } + + public function testGetName(): void + { + self::assertSame(MechanismName::SCRAM_SHA256, $this->mechanism->getName()); + } + + #[DataProvider('variantNameProvider')] + public function testGetNameForAllVariants(MechanismName $variant): void + { + $mechanism = new ScramMechanism($variant); + + self::assertSame($variant, $mechanism->getName()); + } + + /** + * @return array + */ + public static function variantNameProvider(): array + { + return [ + [MechanismName::SCRAM_SHA1], + [MechanismName::SCRAM_SHA1_PLUS], + [MechanismName::SCRAM_SHA224], + [MechanismName::SCRAM_SHA224_PLUS], + [MechanismName::SCRAM_SHA256], + [MechanismName::SCRAM_SHA256_PLUS], + [MechanismName::SCRAM_SHA384], + [MechanismName::SCRAM_SHA384_PLUS], + [MechanismName::SCRAM_SHA512], + [MechanismName::SCRAM_SHA512_PLUS], + [MechanismName::SCRAM_SHA3_512], + [MechanismName::SCRAM_SHA3_512_PLUS], + ]; + } + + public function testSecurityStrength(): void + { + $strength = $this->mechanism->securityStrength(); + + self::assertFalse($strength->supportsPrivacy()); + self::assertFalse($strength->supportsIntegrity()); + self::assertTrue($strength->supportsAuth()); + self::assertFalse($strength->isPlainTextAuth()); + self::assertSame(0, $strength->maxKeySize()); + } + + public function testSecurityLayerThrowsAnException(): void + { + $this->expectException(SaslException::class); + + $this->mechanism->securityLayer(); + } + + public function testChallengeReturnsScramChallenge(): void + { + self::assertInstanceOf(ScramChallenge::class, $this->mechanism->challenge()); + } + + public function testNonScramMechanismThrowsAnException(): void + { + $this->expectException(SaslException::class); + + new ScramMechanism(MechanismName::PLAIN); + } + + public function testToString(): void + { + self::assertSame('SCRAM-SHA-256', (string) $this->mechanism); + } +} diff --git a/tests/unit/MechanismSelectorTest.php b/tests/unit/MechanismSelectorTest.php new file mode 100644 index 0000000..68b3ee8 --- /dev/null +++ b/tests/unit/MechanismSelectorTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl; + +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\AnonymousMechanism; +use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; +use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Mechanism\PlainMechanism; +use FreeDSx\Sasl\MechanismSelector; +use PHPUnit\Framework\TestCase; + +final class MechanismSelectorTest extends TestCase +{ + private MechanismSelector $selector; + + protected function setUp(): void + { + $this->selector = new MechanismSelector([ + new PlainMechanism(), + new CramMD5Mechanism(), + new AnonymousMechanism(), + new DigestMD5Mechanism(), + ]); + } + + public function testSelectWithNoChoicesSpecified(): void + { + $choice = $this->selector->select(); + + self::assertSame(MechanismName::DIGEST_MD5, $choice->getName()); + } + + public function testSelectWithAnonymousOrAuthOnly(): void + { + $choice = $this->selector->select([MechanismName::CRAM_MD5, MechanismName::ANONYMOUS]); + + self::assertSame(MechanismName::CRAM_MD5, $choice->getName()); + } + + public function testSelectWithUseIntegrity(): void + { + $choice = $this->selector->select([], ['use_integrity' => true]); + + self::assertSame(MechanismName::DIGEST_MD5, $choice->getName()); + } + + public function testSelectWithUsePrivacy(): void + { + $choice = $this->selector->select([], ['use_privacy' => true]); + + self::assertSame(MechanismName::DIGEST_MD5, $choice->getName()); + } + + public function testSelectWithoutPrivacyOrIntegrityStillSelectsTheBetterChoiceForAuth(): void + { + $choice = $this->selector->select([], ['use_integrity' => false, 'use_privacy' => false]); + + self::assertSame(MechanismName::DIGEST_MD5, $choice->getName()); + } + + public function testSelectChoosesAuthChoicesCorrectly(): void + { + $choice = $this->selector->select([MechanismName::ANONYMOUS, MechanismName::PLAIN, MechanismName::CRAM_MD5]); + + self::assertSame(MechanismName::CRAM_MD5, $choice->getName()); + } + + public function testSelectChoosesAuthOverAnon(): void + { + $choice = $this->selector->select([MechanismName::ANONYMOUS, MechanismName::PLAIN]); + + self::assertSame(MechanismName::PLAIN, $choice->getName()); + } + + public function testSelectThrowsAnExceptionIfNoMechsMeetsIntegrityRequirements(): void + { + $this->expectException(SaslException::class); + + $this->selector->select( + [MechanismName::ANONYMOUS, MechanismName::PLAIN, MechanismName::CRAM_MD5], + ['use_integrity' => true], + ); + } + + public function testSelectThrowsAnExceptionIfNoMechsMeetsPrivacyRequirements(): void + { + $this->expectException(SaslException::class); + + $this->selector->select( + [MechanismName::ANONYMOUS, MechanismName::PLAIN, MechanismName::CRAM_MD5], + ['use_privacy' => true], + ); + } + + public function testSelectThrowsAnExceptionIfNoSupportedMechsAreFound(): void + { + $this->expectException(SaslException::class); + + $this->selector->select([MechanismName::SCRAM_SHA1]); + } +} diff --git a/tests/unit/MessageTest.php b/tests/unit/MessageTest.php new file mode 100644 index 0000000..8d479b9 --- /dev/null +++ b/tests/unit/MessageTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl; + +use FreeDSx\Sasl\Message; +use PHPUnit\Framework\TestCase; + +final class MessageTest extends TestCase +{ + private Message $message; + + protected function setUp(): void + { + $this->message = new Message(['foo' => 'bar', 'bar' => 'foo']); + } + + public function testSet(): void + { + $this->message->set('name', 'test'); + + self::assertSame('test', $this->message->get('name')); + } + + public function testGet(): void + { + self::assertSame('bar', $this->message->get('foo')); + } + + public function testCount(): void + { + self::assertCount(2, $this->message); + } + + public function testHas(): void + { + self::assertTrue($this->message->has('foo')); + self::assertFalse($this->message->has('nothing')); + } + + public function testToArray(): void + { + self::assertSame([ + 'foo' => 'bar', + 'bar' => 'foo', + ], $this->message->toArray()); + } +} diff --git a/tests/unit/FreeDSx/Sasl/SaslBufferTest.php b/tests/unit/SaslBufferTest.php similarity index 57% rename from tests/unit/FreeDSx/Sasl/SaslBufferTest.php rename to tests/unit/SaslBufferTest.php index 92fde55..fb9e956 100644 --- a/tests/unit/FreeDSx/Sasl/SaslBufferTest.php +++ b/tests/unit/SaslBufferTest.php @@ -1,4 +1,7 @@ assertEquals(SaslBuffer::unwrap(hex2bin('00000003666f6f')), 'foo'); + self::assertSame('foo', SaslBuffer::unwrap((string) hex2bin('00000003666f6f'))); } - public function testWrap() + public function testWrap(): void { - $this->assertEquals(SaslBuffer::wrap('foo'), hex2bin('00000003666f6f')); + self::assertSame(hex2bin('00000003666f6f'), SaslBuffer::wrap('foo')); } - public function testUnwrapOnlyRemovesTheSizeSpecified() + public function testUnwrapOnlyRemovesTheSizeSpecified(): void { - $this->assertEquals(SaslBuffer::unwrap(hex2bin('00000003666f6f6f6f6f')), 'foo'); + self::assertSame('foo', SaslBuffer::unwrap((string) hex2bin('00000003666f6f6f6f6f'))); } - public function testUnwrapThrowsIncompleteBufferWhenTheLengthIsTooSmall() + public function testUnwrapThrowsIncompleteBufferWhenTheLengthIsTooSmall(): void { $this->expectException(SaslBufferException::class); SaslBuffer::unwrap('000000'); } - public function testUnwrapThrowsIncompleteBufferWhenTheDataIsIncomplete() + public function testUnwrapThrowsIncompleteBufferWhenTheDataIsIncomplete(): void { $this->expectException(SaslBufferException::class); diff --git a/tests/unit/SaslContextTest.php b/tests/unit/SaslContextTest.php new file mode 100644 index 0000000..6052d18 --- /dev/null +++ b/tests/unit/SaslContextTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl; + +use FreeDSx\Sasl\SaslContext; +use PHPUnit\Framework\TestCase; + +final class SaslContextTest extends TestCase +{ + private SaslContext $context; + + protected function setUp(): void + { + $this->context = new SaslContext(['username' => 'foo']); + } + + public function testHas(): void + { + self::assertFalse($this->context->has('foo')); + self::assertTrue($this->context->has('username')); + } + + public function testIsAuthenticated(): void + { + self::assertFalse($this->context->isAuthenticated()); + } + + public function testIsServerMode(): void + { + self::assertFalse($this->context->isServerMode()); + } + + public function testSetIsServerMode(): void + { + $this->context->setIsServerMode(true); + + self::assertTrue($this->context->isServerMode()); + } + + public function testGetData(): void + { + self::assertSame(['username' => 'foo'], $this->context->getData()); + } + + public function testSetIsAuthenticated(): void + { + $this->context->setIsAuthenticated(true); + + self::assertTrue($this->context->isAuthenticated()); + } + + public function testSet(): void + { + $this->context->set('foo', 'bar'); + + self::assertSame('bar', $this->context->get('foo')); + } + + public function testSetHasSecurityLayer(): void + { + $this->context->setHasSecurityLayer(true); + + self::assertTrue($this->context->hasSecurityLayer()); + } + + public function testHasSecurityLayer(): void + { + self::assertFalse($this->context->hasSecurityLayer()); + } + + public function testGet(): void + { + self::assertSame('foo', $this->context->get('username')); + } + + public function testGetResponse(): void + { + self::assertNull($this->context->getResponse()); + } + + public function testSetResponse(): void + { + $this->context->setResponse('foo'); + + self::assertSame('foo', $this->context->getResponse()); + } +} diff --git a/tests/unit/FreeDSx/Sasl/SaslPrepTest.php b/tests/unit/SaslPrepTest.php similarity index 75% rename from tests/unit/FreeDSx/Sasl/SaslPrepTest.php rename to tests/unit/SaslPrepTest.php index 5557e22..7d4c124 100644 --- a/tests/unit/FreeDSx/Sasl/SaslPrepTest.php +++ b/tests/unit/SaslPrepTest.php @@ -1,5 +1,7 @@ assertSame( + #[DataProvider('mappingCases')] + public function testPrepare( + string $input, + string $expected, + string $message, + ): void { + self::assertSame( $expected, SaslPrep::prepare($input), - $message + $message, ); } - /** - * @dataProvider prohibitedCases - */ + #[DataProvider('prohibitedCases')] public function testPrepareThrowsOnProhibitedCharacter(string $input): void { $this->expectException(SaslException::class); @@ -39,7 +41,10 @@ public function testPrepareThrowsOnProhibitedCharacter(string $input): void SaslPrep::prepare($input); } - public function mappingCases(): array + /** + * @return array + */ + public static function mappingCases(): array { return [ ['user', 'user', 'ASCII string is returned unchanged'], @@ -60,12 +65,15 @@ public function mappingCases(): array ]; } - public function prohibitedCases(): array + /** + * @return array + */ + public static function prohibitedCases(): array { return [ // C.2.1 — ASCII control characters - 'null byte (U+0000)' => ["pass\x00word"], - 'unit separator (U+001F)' => ["pass\x1Fword"], + 'null byte (U+0000)' => ["pass\x00word"], + 'unit separator (U+001F)' => ["pass\x1Fword"], 'delete character (U+007F)' => ["pass\x7Fword"], // C.2.2 — Non-ASCII control characters (C1 range) @@ -78,8 +86,8 @@ public function prohibitedCases(): array // C.4 — Non-character code points 'first non-character in FDD0-FDEF block (U+FDD0)' => ["pass\u{FDD0}word"], - 'non-character U+FFFE' => ["pass\u{FFFE}word"], - 'non-character U+FFFF' => ["pass\u{FFFF}word"], + 'non-character U+FFFE' => ["pass\u{FFFE}word"], + 'non-character U+FFFF' => ["pass\u{FFFF}word"], // C.6 — Inappropriate for plain text 'interlinear annotation anchor (U+FFF9)' => ["pass\u{FFF9}word"], @@ -89,8 +97,8 @@ public function prohibitedCases(): array 'ideographic description character left-to-right (U+2FF0)' => ["pass\u{2FF0}word"], // C.8 — Change display properties or deprecated - 'left-to-right mark (U+200E)' => ["pass\u{200E}word"], - 'right-to-left mark (U+200F)' => ["pass\u{200F}word"], + 'left-to-right mark (U+200E)' => ["pass\u{200E}word"], + 'right-to-left mark (U+200F)' => ["pass\u{200F}word"], 'left-to-right override (U+202D)' => ["pass\u{202D}word"], // C.9 — Tagging characters diff --git a/tests/unit/SaslTest.php b/tests/unit/SaslTest.php new file mode 100644 index 0000000..5150ee9 --- /dev/null +++ b/tests/unit/SaslTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl; + +use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; +use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; +use FreeDSx\Sasl\Mechanism\MechanismInterface; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Sasl; +use PHPUnit\Framework\TestCase; + +final class SaslTest extends TestCase +{ + private Sasl $sasl; + + protected function setUp(): void + { + $this->sasl = new Sasl(); + } + + public function testAdd(): void + { + $mechMock = $this->createMock(MechanismInterface::class); + $mechMock->method('getName')->willReturn(MechanismName::ANONYMOUS); + + $this->sasl->add($mechMock); + self::assertSame($mechMock, $this->sasl->get(MechanismName::ANONYMOUS)); + } + + public function testMechanisms(): void + { + $mechs = $this->sasl->mechanisms(); + + self::assertArrayHasKey('DIGEST-MD5', $mechs); + self::assertArrayHasKey('CRAM-MD5', $mechs); + self::assertArrayHasKey('PLAIN', $mechs); + self::assertArrayHasKey('ANONYMOUS', $mechs); + self::assertCount(16, $mechs); + } + + public function testRemove(): void + { + $this->sasl->remove(MechanismName::DIGEST_MD5); + + self::assertFalse($this->sasl->supports(MechanismName::DIGEST_MD5)); + } + + public function testGetDigestMD5(): void + { + self::assertInstanceOf(DigestMD5Mechanism::class, $this->sasl->get(MechanismName::DIGEST_MD5)); + } + + public function testGetCramMD5(): void + { + self::assertInstanceOf(CramMD5Mechanism::class, $this->sasl->get(MechanismName::CRAM_MD5)); + } + + public function testSupportedOption(): void + { + $sasl = new Sasl(['supported' => [MechanismName::DIGEST_MD5]]); + self::assertCount(1, $sasl->mechanisms()); + } + + public function testSelect(): void + { + self::assertInstanceOf(DigestMD5Mechanism::class, $this->sasl->select()); + self::assertInstanceOf(CramMD5Mechanism::class, $this->sasl->select([MechanismName::CRAM_MD5])); + } +} diff --git a/tests/unit/Security/DigestMD5SecurityLayerTest.php b/tests/unit/Security/DigestMD5SecurityLayerTest.php new file mode 100644 index 0000000..68d42e6 --- /dev/null +++ b/tests/unit/Security/DigestMD5SecurityLayerTest.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl\Security; + +use FreeDSx\Sasl\SaslContext; +use FreeDSx\Sasl\Security\DigestMD5SecurityLayer; +use PHPUnit\Framework\TestCase; + +final class DigestMD5SecurityLayerTest extends TestCase +{ + private DigestMD5SecurityLayer $security; + + private SaslContext $clientContext; + + private SaslContext $serverContext; + + protected function setUp(): void + { + $this->security = new DigestMD5SecurityLayer(); + $this->clientContext = new SaslContext(); + $this->clientContext->setData([ + 'username' => 'WillifoA', + 'digest-uri' => 'ldap/huh-sys', + 'qop' => 'auth-conf', + 'seqnumrcv' => 0, + 'seqnumsnt' => 0, + ]); + $this->clientContext->setIsAuthenticated(true); + $this->clientContext->setHasSecurityLayer(true); + + $this->serverContext = new SaslContext(); + $this->serverContext->setData([ + 'username' => 'WillifoA', + 'digest-uri' => 'ldap/huh-sys', + 'qop' => 'auth-conf', + 'seqnumrcv' => 0, + 'seqnumsnt' => 0, + ]); + $this->serverContext->setIsAuthenticated(true); + $this->serverContext->setHasSecurityLayer(true); + $this->serverContext->setIsServerMode(true); + + # Encrypted-payload tests fail with newer OpenSSL across OSes — needs investigation. + self::markTestSkipped('Need to investigate why this is failing on all recent OS types :('); + } + + public function testWrapWithPrivacyRC4(): void + { + $this->clientContext->set('a1', hex2bin('960bcfc7a190d6b1dcabcd5bc7f53fe0')); + $this->clientContext->set('cipher', 'rc4'); + $message = (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); + + self::assertSame( + (string) hex2bin('6c9849465f4a88af35b82a6b8f35295e8c60e9d17245f3a8fbe5be1ca6599d7aef09b7f5caf7f43a7eea74b02c669c6a9bb346ba863ab843cf51eccd5b0b570f912e910dd234000100000000'), + $this->security->wrap($message, $this->clientContext), + ); + } + + public function testUnwrapWithPrivacyRC4(): void + { + $this->markSkippedIfCipherNotSupported('rc4'); + $this->serverContext->set('a1', hex2bin('960bcfc7a190d6b1dcabcd5bc7f53fe0')); + $this->serverContext->set('cipher', 'rc4'); + $message = (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); + + self::assertSame( + $message, + $this->security->unwrap( + (string) hex2bin('6c9849465f4a88af35b82a6b8f35295e8c60e9d17245f3a8fbe5be1ca6599d7aef09b7f5caf7f43a7eea74b02c669c6a9bb346ba863ab843cf51eccd5b0b570f912e910dd234000100000000'), + $this->serverContext, + ), + ); + } + + public function testWrapWithRC440(): void + { + $this->clientContext->set('a1', hex2bin('407a52fb725042db234e11b34fb5fd55')); + $this->clientContext->set('cipher', 'rc4-40'); + $message = (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); + + self::assertSame( + (string) hex2bin('056173eaae8b7ac19b1f0d73e8e340c37e83b6ab23377f143b1d2722cf11657b1de53c61ce7a2898786b01a30ca940521c6ade80f01f155e798babc7a1275614656d1b74dee7000100000000'), + $this->security->wrap($message, $this->clientContext), + ); + } + + public function testUnwrapWithRC440(): void + { + $this->markSkippedIfCipherNotSupported('rc4-40'); + $this->serverContext->set('a1', hex2bin('407a52fb725042db234e11b34fb5fd55')); + $this->serverContext->set('cipher', 'rc4-40'); + $encrypted = (string) hex2bin('056173eaae8b7ac19b1f0d73e8e340c37e83b6ab23377f143b1d2722cf11657b1de53c61ce7a2898786b01a30ca940521c6ade80f01f155e798babc7a1275614656d1b74dee7000100000000'); + + self::assertSame( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), + $this->security->unwrap($encrypted, $this->serverContext), + ); + } + + public function testWrapWithPrivacy3Des(): void + { + $this->clientContext->set('a1', hex2bin('9969a25310dd52c864715057ca181374')); + $this->clientContext->set('cipher', '3des'); + $message = (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); + + self::assertSame( + (string) hex2bin('73ebc692c839c8382c4bc669076fa893d1deaca630a1c74dcb0354159680372e555863e2e609a0ce72f06b7bb64fed4e3cf30057af5fd23ff6e0ffda06eb7fdb67edc8b331723f02000100000000'), + $this->security->wrap($message, $this->clientContext), + ); + } + + public function testUnwrapWithPrivacy3Des(): void + { + $this->serverContext->set('a1', hex2bin('9969a25310dd52c864715057ca181374')); + $this->serverContext->set('cipher', '3des'); + $encrypted = (string) hex2bin('73ebc692c839c8382c4bc669076fa893d1deaca630a1c74dcb0354159680372e555863e2e609a0ce72f06b7bb64fed4e3cf30057af5fd23ff6e0ffda06eb7fdb67edc8b331723f02000100000000'); + + self::assertSame( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), + $this->security->unwrap($encrypted, $this->serverContext), + ); + } + + public function testWrapWithOnlyIntegrity(): void + { + $this->clientContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); + $this->clientContext->set('qop', 'auth-int'); + $message = (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'); + + self::assertSame( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), + $this->security->wrap($message, $this->clientContext), + ); + } + + public function testUnwrapWithOnlyIntegrity(): void + { + $this->serverContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); + $this->serverContext->set('qop', 'auth-int'); + + self::assertSame( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f413000'), + $this->security->unwrap( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), + $this->serverContext, + ), + ); + } + + public function testThatItThrowsAnExceptionIfTheWrappedDataExceedsTheBufferSizeDuringUnwrap(): void + { + $context = new SaslContext(['maxbuf' => 2, 'qop' => 'auth-int']); + $this->expectExceptionMessage('The wrapped buffer exceeds the maxbuf length of 2'); + $this->security->unwrap('foo', $context); + } + + public function testThatItThrowsAnExceptionIfTheWrappedDataExceedsTheBufferSizeDuringWrap(): void + { + $this->serverContext->set('a1', hex2bin('7b712f824ba6ad44548ba16b2ec75988')); + $this->serverContext->set('qop', 'auth-int'); + $this->serverContext->set('maxbuf', 2); + + $this->expectExceptionMessage('The wrapped buffer exceeds the maxbuf length of 2'); + $this->security->unwrap( + (string) hex2bin('303a0201036335041164633d6578616d706c652c64633d636f6d0a01020a0100020100020100010100a30f0403756964040857696c6c69666f41300086e03fcc17597a6dfb1c000100000000'), + $this->serverContext, + ); + } + + private function markSkippedIfCipherNotSupported(string $cipher): void + { + if (!in_array($cipher, openssl_get_cipher_methods(), true)) { + self::markTestSkipped(sprintf( + 'The cipher %s is not supported on this system.', + $cipher, + )); + } + } +} diff --git a/tests/unit/SecurityStrengthTest.php b/tests/unit/SecurityStrengthTest.php new file mode 100644 index 0000000..4ca2061 --- /dev/null +++ b/tests/unit/SecurityStrengthTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Sasl; + +use FreeDSx\Sasl\SecurityStrength; +use PHPUnit\Framework\TestCase; + +final class SecurityStrengthTest extends TestCase +{ + private SecurityStrength $strength; + + protected function setUp(): void + { + $this->strength = new SecurityStrength( + supportsIntegrity: true, + supportsPrivacy: true, + supportsAuth: true, + isPlainTextAuth: false, + maxKeySize: 512, + ); + } + + public function testSupportsIntegrity(): void + { + self::assertTrue($this->strength->supportsIntegrity()); + } + + public function testSupportsPrivacy(): void + { + self::assertTrue($this->strength->supportsPrivacy()); + } + + public function testSupportsAuth(): void + { + self::assertTrue($this->strength->supportsAuth()); + } + + public function testIsPlainTextAuth(): void + { + self::assertFalse($this->strength->isPlainTextAuth()); + } + + public function testMaxKeySize(): void + { + self::assertSame(512, $this->strength->maxKeySize()); + } +}