Skip to content

Commit

Permalink
Implemented downgrade protection for SCRAM (Fix #12)
Browse files Browse the repository at this point in the history
  • Loading branch information
fabiang committed Apr 2, 2024
1 parent 4929d7d commit 6b0bd77
Show file tree
Hide file tree
Showing 21 changed files with 609 additions and 62 deletions.
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export XDEBUG_MODE=coverage
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.gitattributes export-ignore
.scrutinizer.yml export-ignore
.github/ export-ignore
.envrc export-ignore
/tests export-ignore
/Vagrantfile export-ignore
phpunit.xml.dist export-ignore
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/behat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Wait for XMPP to become available
uses: iFaxity/wait-on-action@v1
with:
resource: tcp:localhost:15222
resource: tcp:localhost:5222
timeout: 1800000
interval: 10000
delay: 60000
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The PHP SASL Authentification Library.
[![PHP Version Require](http://poser.pugx.org/fabiang/sasl/require/php)](https://packagist.org/packages/fabiang/sasl)
[![Latest Stable Version](https://poser.pugx.org/fabiang/sasl/v/stable.svg)](https://packagist.org/packages/fabiang/sasl)
[![Total Downloads](https://poser.pugx.org/fabiang/sasl/downloads.svg)](https://packagist.org/packages/fabiang/sasl)
[![License](https://poser.pugx.org/fabiang/sasl/license.svg)](https://packagist.org/packages/fabiang/sasl)
[![License](https://poser.pugx.org/fabiang/sasl/license.svg)](https://packagist.org/packages/fabiang/sasl)
[![Unit Tests](https://github.com/fabiang/sasl/actions/workflows/unit.yml/badge.svg?branch=develop)](https://github.com/fabiang/sasl/actions/workflows/unit.yml)
[![Integration Tests](https://github.com/fabiang/sasl/actions/workflows/behat.yml/badge.svg?branch=develop)](https://github.com/fabiang/sasl/actions/workflows/behat.yml)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fabiang/sasl/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/fabiang/sasl/?branch=develop)
Expand Down Expand Up @@ -70,6 +70,25 @@ $mechanism->verify($data);

If the method returns false you should disconnect.

### SCRAM downgrade protection

To enable [downgrade protection for SCRAM](https://xmpp.org/extensions/xep-0474.html), you'll need to pass
the allowed authentication mechanisms and channel-binding types via options to the factory:

```php
$mechanism = $factory->factory('SCRAM-SHA-1', array(
'authcid' => 'username',
'secret' => 'password',
'authzid' => 'authzid', // optional. Username to proxy as
'service' => 'servicename', // optional. Name of the service
'hostname' => 'hostname', // optional. Hostname of the service
'downgrade_protection' => array( // optional. When `null` downgrade protection string from server won't be validated
'allowed_mechanisms' => array('SCRAM-SHA-1-PLUS', 'SCRAM-SHA-1'), // allowed mechanisms by the server
'allowed_channel_bindings' => array('tls-unique', 'tls-exporter', 'tls-server-end-point'), // allowed channel-binding types by the server
),
));
```

### Required options

List of options required by authentication mechanisms.
Expand Down
5 changes: 3 additions & 2 deletions behat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ default:
contexts:
- Fabiang\Sasl\Behat\XMPPContext:
- localhost
- 15222
- 5222
- localhost
- testuser
- testpass
- "%paths.base%/tests/log/features/"
- tlsv1.2
- Fabiang\Sasl\Behat\POP3Context:
- localhost
- 11110
- 1110
- vmail
- pass
- "%paths.base%/tests/log/features/"
Expand Down
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
version: '3'
services:
xmpp:
image: ejabberd/ecs:${EJABBERD_VERSION:-23.10}
image: ejabberd/ecs:${EJABBERD_VERSION:-24.02}
volumes:
- "./tests/config/ejabberd/ejabberd.yml:/home/ejabberd/conf/ejabberd.yml"
- "./tests/config/ejabberd/ejabberd.db:/home/ejabberd/database/ejabberd.db"
ports:
- "15222:5222"
- "5222:5222"
- "5223:5223"
environment:
- CTL_ON_CREATE=register testuser localhost testpass

Expand All @@ -17,4 +18,4 @@ services:
- "./tests/config/dovecot/users:/etc/dovecot/users"
- "./tests/config/dovecot/auth-passwdfile.conf.ext:/etc/dovecot/auth-passwdfile.conf.ext"
ports:
- "11110:110"
- "1110:110"
40 changes: 40 additions & 0 deletions src/Authentication/AbstractAuthentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,44 @@ protected function generateCnonce()

return base64_encode($cnonce);
}

/**
* Generate downgrade protection string
*
* @return string
*/
protected function generateDowngradeProtectionVerification()
{
$downgradeProtectionOptions = $this->options->getDowngradeProtection();

$allowedMechanisms = $downgradeProtectionOptions->getAllowedMechanisms();
$allowedChannelBindings = $downgradeProtectionOptions->getAllowedChannelBindings();

if (count($allowedMechanisms) === 0 && count($allowedChannelBindings) === 0) {
return '';
}

usort($allowedMechanisms, array($this, 'sortOctetCollation'));
usort($allowedChannelBindings, array($this, 'sortOctetCollation'));

$protect = implode(',', $allowedMechanisms);
if (count($allowedChannelBindings) > 0) {
$protect .= '|' . implode(',', $allowedChannelBindings);
}
return $protect;
}

/**
* @param string $a
* @param string $b
* @return int
* @link https://datatracker.ietf.org/doc/html/rfc4790#page-22
*/
private function sortOctetCollation($a, $b)
{
if ($a == $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
}
}
36 changes: 30 additions & 6 deletions src/Authentication/SCRAM.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,26 +162,36 @@ private function generateResponse($challenge, $password)
{
$matches = array();

$serverMessageRegexp = "#^r=([\x21-\x2B\x2D-\x7E/]+)"
. ",s=((?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)"
. ",i=([0-9]*)(,[A-Za-z]=[^,])*$#";
$serverMessageRegexp = "#^r=(?<nonce>[\x21-\x2B\x2D-\x7E/]+)"
. ",s=(?<salt>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)"
. ",i=(?<iteration>[0-9]*)"
. "(?:,d=(?<downgradeProtection>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)))?"
. "(,[A-Za-z]=[^,])*$#";

if (!isset($this->cnonce, $this->gs2Header) || !preg_match($serverMessageRegexp, $challenge, $matches)) {
return false;
}
$nonce = $matches[1];
$salt = base64_decode($matches[2]);

$nonce = $matches['nonce'];
$salt = base64_decode($matches['salt']);
if (!$salt) {
// Invalid Base64.
return false;
}
$i = intval($matches[3]);
$i = intval($matches['iteration']);

$cnonce = substr($nonce, 0, strlen($this->cnonce));
if ($cnonce !== $this->cnonce) {
// Invalid challenge! Are we under attack?
return false;
}

if (!empty($matches['downgradeProtection'])) {
if (!$this->downgradeProtection($matches['downgradeProtection'])) {
return false;
}
}

$channelBinding = 'c=' . base64_encode($this->gs2Header);
$finalMessage = $channelBinding . ',r=' . $nonce;
$saltedPassword = $this->hi($password, $salt, $i);
Expand All @@ -197,6 +207,20 @@ private function generateResponse($challenge, $password)
return $finalMessage . $proof;
}

/**
* @param string $expectedDowngradeProtectionHash
* @return bool
*/
private function downgradeProtection($expectedDowngradeProtectionHash)
{
if ($this->options->getDowngradeProtection() === null) {
return true;
}

$actualDgPHash = base64_encode(call_user_func($this->hash, $this->generateDowngradeProtectionVerification()));
return $expectedDowngradeProtectionHash === $actualDgPHash;
}

/**
* Hi() call, which is essentially PBKDF2 (RFC-2898) with HMAC-H() as the pseudorandom function.
*
Expand Down
37 changes: 30 additions & 7 deletions src/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@

namespace Fabiang\Sasl;

use Fabiang\Sasl\Options\DowngradeProtectionOptions;

/**
* Options object for Sasl.
*
Expand Down Expand Up @@ -79,6 +81,11 @@ class Options
*/
protected $hostname;

/**
* @var DowngradeProtectionOptions|null
*/
protected $downgradeProtection;

/**
* Constructor.
*
Expand All @@ -87,14 +94,22 @@ class Options
* @param string $authzid authorization identity (username to proxy as)
* @param string $service service name
* @param string $hostname service hostname
* @param DowngradeProtectionOptions $downgradeProtection Options for SCRAM-SHA*'s downgrade protection
*/
public function __construct($authcid, $secret = null, $authzid = null, $service = null, $hostname = null)
{
$this->authcid = $authcid;
$this->secret = $secret;
$this->authzid = $authzid;
$this->service = $service;
$this->hostname = $hostname;
public function __construct(
$authcid,
$secret = null,
$authzid = null,
$service = null,
$hostname = null,
DowngradeProtectionOptions $downgradeProtection = null
) {
$this->authcid = $authcid;
$this->secret = $secret;
$this->authzid = $authzid;
$this->service = $service;
$this->hostname = $hostname;
$this->downgradeProtection = $downgradeProtection;
}

public function getAuthcid()
Expand All @@ -121,4 +136,12 @@ public function getHostname()
{
return $this->hostname;
}

/**
* @return DowngradeProtectionOptions|null
*/
public function getDowngradeProtection()
{
return $this->downgradeProtection;
}
}
73 changes: 73 additions & 0 deletions src/Options/DowngradeProtectionOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/**
* Sasl library.
*
* Copyright (c) 2002-2003 Richard Heyes,
* 2014-2023 Fabian Grutschus
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* o Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* o Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.|
* o The names of the authors may not be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @author Fabian Grutschus <[email protected]>
*/

namespace Fabiang\Sasl\Options;

class DowngradeProtectionOptions
{
/**
* @var array
*/
private $allowedMechanisms = array();

/**
* @var array
*/
private $allowedChannelBindings = array();

public function __construct(array $allowedMechanisms, array $allowedChannelBindings)
{
$this->allowedMechanisms = $allowedMechanisms;
$this->allowedChannelBindings = $allowedChannelBindings;
}

/**
* @return array
*/
public function getAllowedMechanisms()
{
return $this->allowedMechanisms;
}

/**
* @return array
*/
public function getAllowedChannelBindings()
{
return $this->allowedChannelBindings;
}
}
Loading

0 comments on commit 6b0bd77

Please sign in to comment.