From 149153b0868de36cec5128c7e0bf1fca6b4a2df0 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 7 Jun 2026 16:14:43 -0400 Subject: [PATCH] Add the EXTERNAL mechanism. --- composer.json | 2 +- ecs.php | 31 +++--- .../Sasl/Challenge/DigestMD5Challenge.php | 12 +-- .../Sasl/Challenge/ExternalChallenge.php | 97 +++++++++++++++++ src/FreeDSx/Sasl/Challenge/ScramChallenge.php | 20 ++-- src/FreeDSx/Sasl/Encoder/ExternalEncoder.php | 47 ++++++++ .../Sasl/Mechanism/ExternalMechanism.php | 54 ++++++++++ src/FreeDSx/Sasl/Mechanism/MechanismName.php | 1 + src/FreeDSx/Sasl/Options/ExternalOptions.php | 64 +++++++++++ src/FreeDSx/Sasl/Sasl.php | 2 + src/FreeDSx/Sasl/SaslPrep.php | 24 ++--- .../unit/Challenge/ExternalChallengeTest.php | 102 ++++++++++++++++++ tests/unit/Encoder/ExternalEncoderTest.php | 66 ++++++++++++ .../unit/Mechanism/ExternalMechanismTest.php | 61 +++++++++++ tests/unit/SaslPrepTest.php | 18 ++-- tests/unit/SaslTest.php | 6 +- 16 files changed, 550 insertions(+), 57 deletions(-) create mode 100644 src/FreeDSx/Sasl/Challenge/ExternalChallenge.php create mode 100644 src/FreeDSx/Sasl/Encoder/ExternalEncoder.php create mode 100644 src/FreeDSx/Sasl/Mechanism/ExternalMechanism.php create mode 100644 src/FreeDSx/Sasl/Options/ExternalOptions.php create mode 100644 tests/unit/Challenge/ExternalChallengeTest.php create mode 100644 tests/unit/Encoder/ExternalEncoderTest.php create mode 100644 tests/unit/Mechanism/ExternalMechanismTest.php diff --git a/composer.json b/composer.json index 1d26197..c05ea07 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/extension-installer": "^1.4", - "symplify/easy-coding-standard": "^9.4", + "symplify/easy-coding-standard": "^13", "squizlabs/php_codesniffer": "^3.7", "slevomat/coding-standard": "^7.2" }, diff --git a/ecs.php b/ecs.php index 8ccea1a..627b3d2 100644 --- a/ecs.php +++ b/ecs.php @@ -5,26 +5,21 @@ use PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer; use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer; use PhpCsFixer\Fixer\PhpTag\BlankLineAfterOpeningTagFixer; -use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symplify\CodingStandard\Fixer\Strict\BlankLineAfterStrictTypesFixer; -use Symplify\EasyCodingStandard\ValueObject\Option; -use Symplify\EasyCodingStandard\ValueObject\Set\SetList; +use Symplify\EasyCodingStandard\Config\ECSConfig; -return static function (ContainerConfigurator $containerConfigurator): void { - $parameters = $containerConfigurator->parameters(); - $parameters->set(Option::PATHS, [ +return ECSConfig::configure() + ->withPaths([ __DIR__ . '/src', __DIR__ . '/tests', + ]) + ->withPreparedSets(psr12: true) + ->withConfiguredRule( + ArraySyntaxFixer::class, + ['syntax' => 'short'], + ) + ->withRules([ + BlankLineAfterStrictTypesFixer::class, + BlankLineAfterOpeningTagFixer::class, + NoUnusedImportsFixer::class, ]); - - $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/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php b/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php index 7151a5f..52c6e29 100644 --- a/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php +++ b/src/FreeDSx/Sasl/Challenge/DigestMD5Challenge.php @@ -253,12 +253,12 @@ private function generateServerChallenge(DigestMD5Options $options): Message DigestMD5MessageType::SERVER_CHALLENGE, [ 'use_integrity' => $options->isUseIntegrity(), - 'use_privacy' => $options->isUsePrivacy(), - 'nonce' => $options->getNonce(), - 'nonce_size' => $options->getNonceSize(), - 'realm' => $options->getRealm(), - 'maxbuf' => $options->getMaxbuf(), - 'cipher' => $options->getCipher(), + 'use_privacy' => $options->isUsePrivacy(), + 'nonce' => $options->getNonce(), + 'nonce_size' => $options->getNonceSize(), + 'realm' => $options->getRealm(), + 'maxbuf' => $options->getMaxbuf(), + 'cipher' => $options->getCipher(), ], ); diff --git a/src/FreeDSx/Sasl/Challenge/ExternalChallenge.php b/src/FreeDSx/Sasl/Challenge/ExternalChallenge.php new file mode 100644 index 0000000..9cd5b93 --- /dev/null +++ b/src/FreeDSx/Sasl/Challenge/ExternalChallenge.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Challenge; + +use FreeDSx\Sasl\Encoder\ExternalEncoder; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Message; +use FreeDSx\Sasl\Options\ChallengeOptionsInterface; +use FreeDSx\Sasl\Options\ExternalOptions; +use FreeDSx\Sasl\SaslContext; + +/** + * The EXTERNAL challenge / response class. + * + * @author Chad Sikorra + */ +final readonly class ExternalChallenge implements ChallengeInterface +{ + use ResolvesOptionsTrait; + + private ExternalEncoder $encoder; + + private SaslContext $context; + + public function __construct(bool $isServerMode = false) + { + $this->encoder = new ExternalEncoder(); + $this->context = new SaslContext(); + $this->context->setIsServerMode($isServerMode); + } + + public function challenge( + ?string $received = null, + ?ChallengeOptionsInterface $options = null, + ): SaslContext { + $resolved = $this->resolveOptions( + $options ?? new ExternalOptions(), + ExternalOptions::class, + ); + + return $this->context->isServerMode() + ? $this->serverProcess($received, $resolved) + : $this->clientProcess($resolved); + } + + /** + * @throws SaslException + */ + private function serverProcess( + ?string $received, + ExternalOptions $options, + ): SaslContext { + $validate = $options->getValidate(); + if ($validate === null) { + throw new SaslException('You must pass a callable validate option to the external mechanism in server mode.'); + } + + // The credential is the verified peer identity from a lower layer; the payload is only the optional authzId. + $rawAuthzId = $this->encoder->decode($received ?? '', $this->context)->get('authzid'); + $authzId = is_string($rawAuthzId) + ? $rawAuthzId + : null; + + $this->context->setIsComplete(true); + $this->context->setIsAuthenticated($validate($authzId)); + + return $this->context; + } + + private function clientProcess(ExternalOptions $options): SaslContext + { + $data = []; + if ($options->getAuthzId() !== null) { + $data['authzid'] = $options->getAuthzId(); + } + + $this->context->setResponse($this->encoder->encode( + new Message($data), + $this->context, + )); + $this->context->setIsComplete(true); + $this->context->setIsAuthenticated(true); + + return $this->context; + } +} diff --git a/src/FreeDSx/Sasl/Challenge/ScramChallenge.php b/src/FreeDSx/Sasl/Challenge/ScramChallenge.php index 98fa947..8522845 100644 --- a/src/FreeDSx/Sasl/Challenge/ScramChallenge.php +++ b/src/FreeDSx/Sasl/Challenge/ScramChallenge.php @@ -55,11 +55,11 @@ * Values follow RFC 5802 (SHA-1: 4096) and RFC 7677 (SHA-256: 4096). */ private const MIN_ITERATIONS = [ - 'sha1' => 4096, - 'sha224' => 4096, - 'sha256' => 4096, - 'sha384' => 4096, - 'sha512' => 4096, + 'sha1' => 4096, + 'sha224' => 4096, + 'sha256' => 4096, + 'sha384' => 4096, + 'sha512' => 4096, 'sha3-512' => 4096, ]; @@ -75,11 +75,11 @@ * for stronger hash variants. */ private const DEFAULT_ITERATIONS = [ - 'sha1' => 10000, - 'sha224' => 15000, - 'sha256' => 15000, - 'sha384' => 10000, - 'sha512' => 10000, + 'sha1' => 10000, + 'sha224' => 15000, + 'sha256' => 15000, + 'sha384' => 10000, + 'sha512' => 10000, 'sha3-512' => 10000, ]; diff --git a/src/FreeDSx/Sasl/Encoder/ExternalEncoder.php b/src/FreeDSx/Sasl/Encoder/ExternalEncoder.php new file mode 100644 index 0000000..38c8bab --- /dev/null +++ b/src/FreeDSx/Sasl/Encoder/ExternalEncoder.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Encoder; + +use FreeDSx\Sasl\Message; +use FreeDSx\Sasl\SaslContext; + +/** + * Encodes / decodes EXTERNAL messages. The payload is the optional authzId string (RFC 4422 §3.1). + * + * @author Chad Sikorra + */ +final readonly class ExternalEncoder implements EncoderInterface +{ + public function encode( + Message $message, + SaslContext $context, + ): string { + return $message->has('authzid') + ? $message->getString('authzid') + : ''; + } + + public function decode( + string $data, + SaslContext $context, + ): Message { + $message = new Message(); + + if ($data !== '') { + $message->set('authzid', $data); + } + + return $message; + } +} diff --git a/src/FreeDSx/Sasl/Mechanism/ExternalMechanism.php b/src/FreeDSx/Sasl/Mechanism/ExternalMechanism.php new file mode 100644 index 0000000..ad11b8d --- /dev/null +++ b/src/FreeDSx/Sasl/Mechanism/ExternalMechanism.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Mechanism; + +use FreeDSx\Sasl\Challenge\ChallengeInterface; +use FreeDSx\Sasl\Challenge\ExternalChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Security\SecurityLayerInterface; +use FreeDSx\Sasl\SecurityStrength; + +/** + * The EXTERNAL mechanism. + * + * @author Chad Sikorra + */ +final readonly class ExternalMechanism implements MechanismInterface +{ + public function getName(): MechanismName + { + return MechanismName::EXTERNAL; + } + + public function challenge(bool $serverMode = false): ChallengeInterface + { + return new ExternalChallenge($serverMode); + } + + public function securityStrength(): SecurityStrength + { + return new SecurityStrength( + supportsIntegrity: false, + supportsPrivacy: false, + supportsAuth: true, + isPlainTextAuth: false, + maxKeySize: 0, + ); + } + + public function securityLayer(): SecurityLayerInterface + { + throw new SaslException('The EXTERNAL mechanism does not support a security layer.'); + } +} diff --git a/src/FreeDSx/Sasl/Mechanism/MechanismName.php b/src/FreeDSx/Sasl/Mechanism/MechanismName.php index 8238dd0..8e86b72 100644 --- a/src/FreeDSx/Sasl/Mechanism/MechanismName.php +++ b/src/FreeDSx/Sasl/Mechanism/MechanismName.php @@ -21,6 +21,7 @@ enum MechanismName: string { case ANONYMOUS = 'ANONYMOUS'; + case EXTERNAL = 'EXTERNAL'; case PLAIN = 'PLAIN'; case CRAM_MD5 = 'CRAM-MD5'; case DIGEST_MD5 = 'DIGEST-MD5'; diff --git a/src/FreeDSx/Sasl/Options/ExternalOptions.php b/src/FreeDSx/Sasl/Options/ExternalOptions.php new file mode 100644 index 0000000..963d2d4 --- /dev/null +++ b/src/FreeDSx/Sasl/Options/ExternalOptions.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Sasl\Options; + +use Closure; + +/** + * Options for the EXTERNAL challenge. + * + * - Client mode: optionally set the authzId. + * - Server mode: set the validate closure. + * + * @author Chad Sikorra + */ +final class ExternalOptions implements ChallengeOptionsInterface +{ + private ?string $authzId = null; + + /** + * @var (Closure(?string $authzId): bool)|null + */ + private ?Closure $validate = null; + + public function getAuthzId(): ?string + { + return $this->authzId; + } + + public function setAuthzId(?string $authzId): self + { + $this->authzId = $authzId; + + return $this; + } + + /** + * @return (Closure(?string $authzId): bool)|null + */ + public function getValidate(): ?Closure + { + return $this->validate; + } + + /** + * @param Closure(?string $authzId): bool $validate + */ + public function setValidate(Closure $validate): self + { + $this->validate = $validate; + + return $this; + } +} diff --git a/src/FreeDSx/Sasl/Sasl.php b/src/FreeDSx/Sasl/Sasl.php index bbf07ae..e7dd900 100644 --- a/src/FreeDSx/Sasl/Sasl.php +++ b/src/FreeDSx/Sasl/Sasl.php @@ -17,6 +17,7 @@ use FreeDSx\Sasl\Mechanism\AnonymousMechanism; use FreeDSx\Sasl\Mechanism\CramMD5Mechanism; use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; +use FreeDSx\Sasl\Mechanism\ExternalMechanism; use FreeDSx\Sasl\Mechanism\MechanismInterface; use FreeDSx\Sasl\Mechanism\MechanismName; use FreeDSx\Sasl\Mechanism\PlainMechanism; @@ -93,6 +94,7 @@ private function initMechs(): void MechanismName::CRAM_MD5->value => new CramMD5Mechanism(), MechanismName::PLAIN->value => new PlainMechanism(), MechanismName::ANONYMOUS->value => new AnonymousMechanism(), + MechanismName::EXTERNAL->value => new ExternalMechanism(), ]; foreach (MechanismName::cases() as $case) { diff --git a/src/FreeDSx/Sasl/SaslPrep.php b/src/FreeDSx/Sasl/SaslPrep.php index bde46e0..929c85f 100644 --- a/src/FreeDSx/Sasl/SaslPrep.php +++ b/src/FreeDSx/Sasl/SaslPrep.php @@ -34,13 +34,13 @@ final class SaslPrep * C.2.1 and C.2.2 share the 'control' group since they produce the same error. */ private const PROHIBITED_ERRORS = [ - 'control' => self::ERR_CONTROL_CHAR, - 'private' => self::ERR_PRIVATE_USE, - 'nonchar' => self::ERR_NON_CHARACTER, + 'control' => self::ERR_CONTROL_CHAR, + 'private' => self::ERR_PRIVATE_USE, + 'nonchar' => self::ERR_NON_CHARACTER, 'plaintext' => self::ERR_PLAIN_TEXT, 'canonical' => self::ERR_CANONICAL, - 'display' => self::ERR_DISPLAY_PROP, - 'tagging' => self::ERR_TAGGING, + 'display' => self::ERR_DISPLAY_PROP, + 'tagging' => self::ERR_TAGGING, ]; /** @@ -119,19 +119,19 @@ final class SaslPrep "\u{3000}", // IDEOGRAPHIC SPACE ]; - private const ERR_CONTROL_CHAR = 'String contains a prohibited control character.'; + private const ERR_CONTROL_CHAR = 'String contains a prohibited control character.'; - private const ERR_PRIVATE_USE = 'String contains a prohibited private use character.'; + private const ERR_PRIVATE_USE = 'String contains a prohibited private use character.'; - private const ERR_NON_CHARACTER = 'String contains a prohibited non-character code point.'; + private const ERR_NON_CHARACTER = 'String contains a prohibited non-character code point.'; - private const ERR_PLAIN_TEXT = 'String contains a character inappropriate for plain text.'; + private const ERR_PLAIN_TEXT = 'String contains a character inappropriate for plain text.'; - private const ERR_CANONICAL = 'String contains a character inappropriate for canonical representation.'; + private const ERR_CANONICAL = 'String contains a character inappropriate for canonical representation.'; - private const ERR_DISPLAY_PROP = 'String contains a deprecated or display-property-altering character.'; + private const ERR_DISPLAY_PROP = 'String contains a deprecated or display-property-altering character.'; - private const ERR_TAGGING = 'String contains a prohibited tagging character.'; + private const ERR_TAGGING = 'String contains a prohibited tagging character.'; /** * Prepares a string according to the SASLprep profile (RFC 4013). diff --git a/tests/unit/Challenge/ExternalChallengeTest.php b/tests/unit/Challenge/ExternalChallengeTest.php new file mode 100644 index 0000000..170e0a6 --- /dev/null +++ b/tests/unit/Challenge/ExternalChallengeTest.php @@ -0,0 +1,102 @@ + + * + * 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\ExternalChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Options\ExternalOptions; +use PHPUnit\Framework\TestCase; + +final class ExternalChallengeTest extends TestCase +{ + public function testTheClientChallengeWithAnAuthzId(): void + { + $context = (new ExternalChallenge()) + ->challenge(null, (new ExternalOptions())->setAuthzId('dn:cn=foo')); + + self::assertSame( + 'dn:cn=foo', + $context->getResponse(), + ); + self::assertTrue($context->isComplete()); + } + + public function testTheClientChallengeWithoutAnAuthzId(): void + { + $context = (new ExternalChallenge()) + ->challenge(null, new ExternalOptions()); + + self::assertSame( + '', + $context->getResponse(), + ); + self::assertTrue($context->isComplete()); + } + + public function testTheServerAuthenticatesWhenTheValidateCallbackPasses(): void + { + $challenge = new ExternalChallenge(true); + $context = $challenge->challenge('', (new ExternalOptions())->setValidate(fn (?string $authzId): bool => true)); + + self::assertTrue($context->isComplete()); + self::assertTrue($context->isAuthenticated()); + self::assertNull($context->getResponse()); + } + + public function testTheServerDoesNotAuthenticateWhenTheValidateCallbackFails(): void + { + $challenge = new ExternalChallenge(true); + $context = $challenge->challenge('', (new ExternalOptions())->setValidate(fn (?string $authzId): bool => false)); + + self::assertTrue($context->isComplete()); + self::assertFalse($context->isAuthenticated()); + } + + public function testTheServerPassesAProvidedAuthzIdToTheValidateCallback(): void + { + $received = 'unset'; + $challenge = new ExternalChallenge(true); + $challenge->challenge('dn:cn=foo', (new ExternalOptions())->setValidate( + function (?string $authzId) use (&$received): bool { + $received = $authzId; + + return true; + }, + )); + + self::assertSame('dn:cn=foo', $received); + } + + public function testTheServerPassesNullToTheValidateCallbackWhenNoAuthzIdIsSent(): void + { + $received = 'unset'; + $challenge = new ExternalChallenge(true); + $challenge->challenge('', (new ExternalOptions())->setValidate( + function (?string $authzId) use (&$received): bool { + $received = $authzId; + + return true; + }, + )); + + self::assertNull($received); + } + + public function testTheServerThrowsWithoutAValidateCallback(): void + { + $this->expectException(SaslException::class); + + (new ExternalChallenge(true))->challenge(''); + } +} diff --git a/tests/unit/Encoder/ExternalEncoderTest.php b/tests/unit/Encoder/ExternalEncoderTest.php new file mode 100644 index 0000000..8470504 --- /dev/null +++ b/tests/unit/Encoder/ExternalEncoderTest.php @@ -0,0 +1,66 @@ + + * + * 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\ExternalEncoder; +use FreeDSx\Sasl\Message; +use FreeDSx\Sasl\SaslContext; +use PHPUnit\Framework\TestCase; + +final class ExternalEncoderTest extends TestCase +{ + private ExternalEncoder $encoder; + + private SaslContext $context; + + protected function setUp(): void + { + $this->encoder = new ExternalEncoder(); + $this->context = new SaslContext(); + } + + public function testItEncodes(): void + { + $result = $this->encoder->encode( + new Message(['authzid' => 'dn:cn=foo']), + $this->context, + ); + + self::assertSame('dn:cn=foo', $result); + } + + public function testItEncodesWithNoAuthzId(): void + { + $result = $this->encoder->encode( + new Message(), + $this->context, + ); + + self::assertSame('', $result); + } + + public function testItDecodes(): void + { + $result = $this->encoder->decode('dn:cn=foo', $this->context); + + self::assertSame(['authzid' => 'dn:cn=foo'], $result->toArray()); + } + + public function testItDecodesWithNoData(): void + { + $result = $this->encoder->decode('', $this->context); + + self::assertSame([], $result->toArray()); + } +} diff --git a/tests/unit/Mechanism/ExternalMechanismTest.php b/tests/unit/Mechanism/ExternalMechanismTest.php new file mode 100644 index 0000000..6fc3715 --- /dev/null +++ b/tests/unit/Mechanism/ExternalMechanismTest.php @@ -0,0 +1,61 @@ + + * + * 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\ExternalChallenge; +use FreeDSx\Sasl\Exception\SaslException; +use FreeDSx\Sasl\Mechanism\ExternalMechanism; +use FreeDSx\Sasl\Mechanism\MechanismName; +use PHPUnit\Framework\TestCase; + +final class ExternalMechanismTest extends TestCase +{ + private ExternalMechanism $mechanism; + + protected function setUp(): void + { + $this->mechanism = new ExternalMechanism(); + } + + 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 testChallengeReturnsTheExternalChallenge(): void + { + self::assertInstanceOf(ExternalChallenge::class, $this->mechanism->challenge()); + } + + public function testGetName(): void + { + self::assertSame(MechanismName::EXTERNAL, $this->mechanism->getName()); + } +} diff --git a/tests/unit/SaslPrepTest.php b/tests/unit/SaslPrepTest.php index 7d4c124..fc4213d 100644 --- a/tests/unit/SaslPrepTest.php +++ b/tests/unit/SaslPrepTest.php @@ -72,33 +72,33 @@ 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) 'first C1 control (U+0080)' => ["pass\u{0080}word"], - 'last C1 control (U+009F)' => ["pass\u{009F}word"], + 'last C1 control (U+009F)' => ["pass\u{009F}word"], // C.3 — Private use characters 'first BMP private use (U+E000)' => ["pass\u{E000}word"], - 'last BMP private use (U+F8FF)' => ["pass\u{F8FF}word"], + 'last BMP private use (U+F8FF)' => ["pass\u{F8FF}word"], // 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"], - 'replacement character (U+FFFD)' => ["pass\u{FFFD}word"], + 'replacement character (U+FFFD)' => ["pass\u{FFFD}word"], // C.7 — Inappropriate for canonical representation '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 index 2b7853f..a700962 100644 --- a/tests/unit/SaslTest.php +++ b/tests/unit/SaslTest.php @@ -47,7 +47,11 @@ public function testMechanisms(): void self::assertArrayHasKey('CRAM-MD5', $mechs); self::assertArrayHasKey('PLAIN', $mechs); self::assertArrayHasKey('ANONYMOUS', $mechs); - self::assertCount(16, $mechs); + self::assertArrayHasKey('EXTERNAL', $mechs); + self::assertCount( + 17, + $mechs, + ); } public function testRemove(): void