Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CHANGELOG
* 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.
* Replace all `array $options` parameters with typed options DTOs (see UPGRADE-1.0.md).

0.2.1 (2026-03-22)
------------------
Expand Down
146 changes: 146 additions & 0 deletions UPGRADE-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,149 @@ 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.

## Options arrays replaced by typed DTOs

All `array $options` parameters have been replaced by typed DTO objects from the `FreeDSx\Sasl\Options` namespace.

### `Sasl` constructor

```php
// before
new Sasl(['supported' => [MechanismName::DIGEST_MD5]]);

// after
use FreeDSx\Sasl\Options\SaslOptions;

new Sasl(new SaslOptions(supported: [MechanismName::DIGEST_MD5]));
```

### `Sasl::select()` / `MechanismSelector::select()`

```php
// before
$sasl->select($choices, ['use_integrity' => true]);
$sasl->select($choices, ['use_privacy' => true]);

// after
use FreeDSx\Sasl\Options\SelectOptions;

$sasl->select($choices, (new SelectOptions())->setUseIntegrity(true));
$sasl->select($choices, (new SelectOptions())->setUsePrivacy(true));
```

### `ChallengeInterface::challenge()`

The second parameter changed from `array $options = []` to `?ChallengeOptionsInterface $options = null`. Pass the mechanism-specific DTO, or omit the argument entirely when no options are needed.

Passing a DTO of the wrong type throws a `SaslException`.

#### ANONYMOUS

```php
// before
$challenge->challenge(null, ['trace' => 'user@example.com']);

// after
use FreeDSx\Sasl\Options\AnonymousOptions;

$challenge->challenge(null, (new AnonymousOptions())->setTrace('user@example.com'));
```

The `username` key alias accepted by the old array has been removed. Use `setTrace()` instead.

#### PLAIN

```php
// before — client
$challenge->challenge(null, ['username' => 'alice', 'password' => 'secret']);

// before — server
$challenge->challenge($received, ['validate' => $fn]);

// after — client
use FreeDSx\Sasl\Options\PlainOptions;

$challenge->challenge(null, (new PlainOptions())->setUsername('alice')->setPassword('secret'));

// after — server
$challenge->challenge($received, (new PlainOptions())->setValidate($fn));
```

#### CRAM-MD5

The server-side callable was previously passed under the `password` key (conflicting with the client's string password). It is now `setPasswordCallback()`.

```php
// before — client
$challenge->challenge($received, ['username' => 'alice', 'password' => 'secret']);

// before — server (generate challenge with fixed nonce)
$challenge->challenge(null, ['challenge' => 'mynonce']);

// before — server (validate)
$challenge->challenge($received, ['password' => $callable]);

// after — client
use FreeDSx\Sasl\Options\CramMD5Options;

$challenge->challenge($received, (new CramMD5Options())->setUsername('alice')->setPassword('secret'));

// after — server (generate challenge with fixed nonce)
$challenge->challenge(null, (new CramMD5Options())->setChallenge('mynonce'));

// after — server (validate)
$challenge->challenge($received, (new CramMD5Options())->setPasswordCallback($callable));
```

#### SCRAM

```php
// before — client-first
$challenge->challenge(null, ['username' => 'alice', 'cnonce' => $cnonce]);

// before — client-final
$challenge->challenge($serverFirst, ['password' => 'secret']);

// before — server-first
$challenge->challenge($clientFirst, ['nonce' => $snonce, 'salt' => $salt, 'iterations' => 4096]);

// before — server-final
$challenge->challenge($clientFinal, ['password' => 'secret']);

// after
use FreeDSx\Sasl\Options\ScramOptions;

$challenge->challenge(null, (new ScramOptions())->setUsername('alice')->setCnonce($cnonce));
$challenge->challenge($serverFirst, (new ScramOptions())->setPassword('secret'));
$challenge->challenge($clientFirst, (new ScramOptions())->setNonce($snonce)->setSalt($salt)->setIterations(4096));
$challenge->challenge($clientFinal, (new ScramOptions())->setPassword('secret'));
```

`cbind_type` → `setCbindType()`, `cbind_data` → `setCbindData()`.

#### DIGEST-MD5

```php
// before
$challenge->challenge($received, [
'use_privacy' => true,
'use_integrity' => false,
'username' => 'alice',
'password' => 'secret',
'host' => 'ldap.example.com',
'nonce_size' => 32,
]);

// after
use FreeDSx\Sasl\Options\DigestMD5Options;

$challenge->challenge($received, (new DigestMD5Options())
->setUsePrivacy(true)
->setUsername('alice')
->setPassword('secret')
->setHost('ldap.example.com')
->setNonceSize(32));
```

Key renames: `use_integrity` → `setUseIntegrity()`, `use_privacy` → `setUsePrivacy()`, `nonce_size` → `setNonceSize()`.
6 changes: 2 additions & 4 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
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<string, mixed>
# accessors on Message and SaslContext.
level: 6
level: max
paths:
- %currentWorkingDirectory%/src
- %currentWorkingDirectory%/tests
treatPhpDocTypesAsCertain: false
reportUnmatchedIgnoredErrors: false
22 changes: 14 additions & 8 deletions src/FreeDSx/Sasl/Challenge/AnonymousChallenge.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

use FreeDSx\Sasl\Encoder\AnonymousEncoder;
use FreeDSx\Sasl\Message;
use FreeDSx\Sasl\Options\AnonymousOptions;
use FreeDSx\Sasl\Options\ChallengeOptionsInterface;
use FreeDSx\Sasl\SaslContext;

/**
Expand All @@ -24,6 +26,8 @@
*/
final readonly class AnonymousChallenge implements ChallengeInterface
{
use ResolvesOptionsTrait;

private readonly SaslContext $context;

private readonly AnonymousEncoder $encoder;
Expand All @@ -37,12 +41,17 @@ public function __construct(bool $isServerMode = false)

public function challenge(
?string $received = null,
array $options = [],
?ChallengeOptionsInterface $options = null,
): SaslContext {
$resolved = $this->resolveOptions(
$options ?? new AnonymousOptions(),
AnonymousOptions::class,
);

if ($this->context->isServerMode()) {
$this->processServer($received);
} else {
$this->processClient($options);
$this->processClient($resolved);
}

return $this->context;
Expand All @@ -63,14 +72,11 @@ private function processServer(?string $received): void
}
}

/**
* @param array<string, mixed> $options
*/
private function processClient(array $options): void
private function processClient(AnonymousOptions $options): void
{
$data = [];
if (isset($options['username']) || isset($options['trace'])) {
$data['trace'] = $options['trace'] ?? $options['username'];
if ($options->getTrace() !== null) {
$data['trace'] = $options->getTrace();
}

$this->context->setResponse($this->encoder->encode(new Message($data), $this->context));
Expand Down
12 changes: 4 additions & 8 deletions src/FreeDSx/Sasl/Challenge/ChallengeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace FreeDSx\Sasl\Challenge;

use FreeDSx\Sasl\Exception\SaslException;
use FreeDSx\Sasl\Options\ChallengeOptionsInterface;
use FreeDSx\Sasl\SaslContext;

/**
Expand All @@ -24,20 +25,15 @@
interface ChallengeInterface
{
/**
* Generate the next response to send in the challenge. It takes two optional parameters:
*
* - The last message received. Null if no message has been received yet.
* - An array of options used for generating the next message.
*
* The SaslContext returned indicates various aspects of the state of the challenge, including the response.
* Generate the next response to send in the challenge.
*
* @param string|null $received the last message received, or null if none
* @param array<string, mixed> $options options for generating the next message
* @param ?ChallengeOptionsInterface $options mechanism-specific options DTO
*
* @throws SaslException
*/
public function challenge(
?string $received = null,
array $options = [],
?ChallengeOptionsInterface $options = null,
): SaslContext;
}
50 changes: 23 additions & 27 deletions src/FreeDSx/Sasl/Challenge/CramMD5Challenge.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use FreeDSx\Sasl\Exception\SaslException;
use FreeDSx\Sasl\Factory\NonceTrait;
use FreeDSx\Sasl\Message;
use FreeDSx\Sasl\Options\ChallengeOptionsInterface;
use FreeDSx\Sasl\Options\CramMD5Options;
use FreeDSx\Sasl\SaslContext;

/**
Expand All @@ -27,6 +29,7 @@
final readonly class CramMD5Challenge implements ChallengeInterface
{
use NonceTrait;
use ResolvesOptionsTrait;

private readonly SaslContext $context;

Expand All @@ -41,33 +44,34 @@ public function __construct(bool $isServerMode = false)

public function challenge(
?string $received = null,
array $options = [],
?ChallengeOptionsInterface $options = null,
): SaslContext {
$resolved = $this->resolveOptions(
$options ?? new CramMD5Options(),
CramMD5Options::class,
);
$message = $received === null ? null : $this->encoder->decode($received, $this->context);

if ($message === null) {
if ($this->context->isServerMode()) {
$this->generateServerChallenge($options);
$this->generateServerChallenge($resolved);
}

return $this->context;
}

if ($this->context->isServerMode()) {
$this->validateClientResponse($message, $options);
$this->validateClientResponse($message, $resolved);
} else {
$this->generateClientResponse($message, $options);
$this->generateClientResponse($message, $resolved);
}

return $this->context;
}

/**
* @param array<string, mixed> $options
*/
private function generateServerChallenge(array $options): void
private function generateServerChallenge(CramMD5Options $options): void
{
$nonce = (string) ($options['challenge'] ?? $this->generateNonce(32));
$nonce = $options->getChallenge() ?? $this->generateNonce(32);
$challenge = new Message(['challenge' => $nonce]);
$encoded = $this->encoder->encode($challenge, $this->context);
$this->context->setResponse($encoded);
Expand All @@ -77,54 +81,46 @@ private function generateServerChallenge(array $options): void
}

/**
* @param array<string, mixed> $options
*
* @throws SaslException
*/
private function generateClientResponse(
Message $received,
array $options,
CramMD5Options $options,
): void {
if (!$received->has('challenge')) {
throw new SaslException('Expected a server challenge to generate a client response.');
}
if (!isset($options['username'], $options['password'])) {
if ($options->getUsername() === null || $options->getPassword() === null) {
throw new SaslException('A username and password is required for a client response.');
}
$response = new Message([
'username' => $options['username'],
'digest' => $this->generateDigest((string) $received->get('challenge'), (string) $options['password']),
'username' => $options->getUsername(),
'digest' => $this->generateDigest($received->getString('challenge'), $options->getPassword()),
]);
$this->context->setResponse($this->encoder->encode($response, $this->context));
$this->context->setIsComplete(true);
}

/**
* @param array<string, mixed> $options
*
* @throws SaslException
*/
private function validateClientResponse(
Message $received,
array $options,
CramMD5Options $options,
): void {
if (!$received->has('username')) {
throw new SaslException('The client response must have a username.');
}
if (!$received->has('digest')) {
throw new SaslException('The client response must have a digest.');
}
if (!isset($options['password'])) {
throw new SaslException('To validate the client response you must supply the password option.');
if ($options->getPasswordCallback() === null) {
throw new SaslException('To validate the client response you must supply the passwordCallback option.');
}
$username = $received->get('username');
$digest = $received->get('digest');
$username = $received->getString('username');
$digest = $received->getString('digest');

$password = $options['password'];
if (!is_callable($password)) {
throw new SaslException('The password option must be callable. It will be passed the username and challenge');
}
$expectedDigest = $password($username, $this->context->get('challenge'));
$expectedDigest = ($options->getPasswordCallback())($username, $this->context->getString('challenge'));

$this->context->setIsAuthenticated($expectedDigest === $digest);
$this->context->setIsComplete(true);
Expand Down
Loading
Loading