Passed
Branch refactoring (2fa5c6)
by Fabian
14:03
created

SCRAM   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 236
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 94
dl 0
loc 236
rs 10
c 0
b 0
f 0
wmc 29

12 Methods

Rating   Name   Duplication   Size   Complexity  
A getHashAlgo() 0 3 1
A getAuthMessage() 0 3 1
A verify() 0 18 4
A getSaltedPassword() 0 3 1
A createResponse() 0 16 4
A formatName() 0 3 1
A downgradeProtection() 0 8 2
A getCnonce() 0 3 1
B generateResponse() 0 49 8
A generateInitialResponse() 0 10 2
A __construct() 0 22 2
A hi() 0 10 2
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Sasl library.
7
 *
8
 * Copyright (c) 2002-2003 Richard Heyes,
9
 *               2014-2025 Fabian Grutschus
10
 * All rights reserved.
11
 *
12
 * Redistribution and use in source and binary forms, with or without
13
 * modification, are permitted provided that the following conditions
14
 * are met:
15
 *
16
 * o Redistributions of source code must retain the above copyright
17
 *   notice, this list of conditions and the following disclaimer.
18
 * o Redistributions in binary form must reproduce the above copyright
19
 *   notice, this list of conditions and the following disclaimer in the
20
 *   documentation and/or other materials provided with the distribution.|
21
 * o The names of the authors may not be used to endorse or promote
22
 *   products derived from this software without specific prior written
23
 *   permission.
24
 *
25
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
26
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
27
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
28
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
29
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
30
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
31
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
32
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
33
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
34
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
 *
37
 * @author Jehan <[email protected]>
38
 */
39
40
namespace Fabiang\SASL\Authentication;
41
42
use Closure;
43
use Fabiang\SASL\Authentication\AbstractAuthentication;
44
use Fabiang\SASL\Options;
45
use Fabiang\SASL\Exception\InvalidArgumentException;
46
47
/**
48
 * Implementation of SCRAM-* SASL mechanisms.
49
 * SCRAM mechanisms have 3 main steps (initial response, response to the server challenge, then server signature
50
 * verification) which keep state-awareness. Therefore a single class instanciation must be done
51
 * and reused for the whole authentication process.
52
 *
53
 * @author Jehan <[email protected]>
54
 */
55
class SCRAM extends AbstractAuthentication implements ChallengeAuthenticationInterface, VerificationInterface
56
{
57
    private string $hashAlgo;
58
    private Closure $hash;
59
    private Closure $hmac;
60
    private ?string $gs2Header = null;
61
    private ?string $cnonce = null;
62
    private string $firstMessageBare;
63
    private string $saltedPassword;
64
    private string $authMessage;
65
66
    /**
67
     * Construct a SCRAM-H client where 'H' is a cryptographic hash function.
68
     *
69
     * @link http://www.iana.org/assignments/hash-function-text-names/hash-function-text-names.xml "Hash Function
70
     * Textual Names" format of core PHP hash function.
71
     * @param Options $options
72
     * @param string  $hash The name cryptographic hash function 'H' as registered by IANA in the "Hash Function Textual
73
     * Names" registry.
74
     * @throws InvalidArgumentException
75
     */
76
    public function __construct(Options $options, string $hash)
77
    {
78
        parent::__construct($options);
79
80
        // Though I could be strict, I will actually also accept the naming used in the PHP core hash framework.
81
        // For instance "sha1" is accepted, while the registered hash name should be "SHA-1".
82
        $normalizedHash = strtolower(preg_replace('#^sha-(\d+)#i', 'sha\1', $hash));
83
84
        $hashAlgos = hash_algos();
85
        if (!in_array($normalizedHash, $hashAlgos)) {
86
            throw new InvalidArgumentException("Invalid SASL mechanism type '$hash'");
87
        }
88
89
        $this->hash = function ($data) use ($normalizedHash) {
90
            return hash($normalizedHash, $data, true);
91
        };
92
93
        $this->hmac = function ($key, $str, $raw) use ($normalizedHash) {
94
            return hash_hmac($normalizedHash, $str, $key, $raw);
95
        };
96
97
        $this->hashAlgo = $normalizedHash;
98
    }
99
100
    /**
101
     * Provides the (main) client response for SCRAM-H.
102
     *
103
     * @param  string $challenge The challenge sent by the server.
104
     * If the challenge is null or an empty string, the result will be the "initial response".
105
     * @return string|false      The response (binary, NOT base64 encoded)
106
     */
107
    public function createResponse(?string $challenge = null): string|false
108
    {
109
        $authcid = $this->formatName($this->options->getAuthcid());
0 ignored issues
show
Bug introduced by
It seems like $this->options->getAuthcid() can also be of type null; however, parameter $username of Fabiang\SASL\Authentication\SCRAM::formatName() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

109
        $authcid = $this->formatName(/** @scrutinizer ignore-type */ $this->options->getAuthcid());
Loading history...
110
        if (empty($authcid)) {
111
            return false;
112
        }
113
114
        $authzid = $this->options->getAuthzid();
115
        if (!empty($authzid)) {
116
            $authzid = $this->formatName($authzid);
117
        }
118
119
        if (empty($challenge)) {
120
            return $this->generateInitialResponse($authcid, $authzid);
121
        } else {
122
            return $this->generateResponse($challenge, $this->options->getSecret());
0 ignored issues
show
Bug introduced by
It seems like $this->options->getSecret() can also be of type null; however, parameter $password of Fabiang\SASL\Authenticat...RAM::generateResponse() does only seem to accept false|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

122
            return $this->generateResponse($challenge, /** @scrutinizer ignore-type */ $this->options->getSecret());
Loading history...
123
        }
124
    }
125
126
    /**
127
     * Prepare a name for inclusion in a SCRAM response.
128
     *
129
     * @param string $username a name to be prepared.
130
     * @return string the reformated name.
131
     */
132
    private function formatName(string $username): string
133
    {
134
        return str_replace(['=', ','], ['=3D', '=2C'], $username);
135
    }
136
137
    /**
138
     * Generate the initial response which can be either sent directly in the first message or as a response to an empty
139
     * server challenge.
140
     *
141
     * @param string $authcid Prepared authentication identity.
142
     * @param string $authzid Prepared authorization identity.
143
     * @return string The SCRAM response to send.
144
     */
145
    private function generateInitialResponse(string $authcid, ?string $authzid): string
146
    {
147
        $gs2CbindFlag   = 'n,';
148
        $this->gs2Header = $gs2CbindFlag . (!empty($authzid) ? 'a=' . $authzid : '') . ',';
149
150
        // I must generate a client nonce and "save" it for later comparison on second response.
151
        $this->cnonce = $this->generateCnonce();
152
153
        $this->firstMessageBare = 'n=' . $authcid . ',r=' . $this->cnonce;
154
        return $this->gs2Header . $this->firstMessageBare;
155
    }
156
157
    /**
158
     * Parses and verifies a non-empty SCRAM challenge.
159
     *
160
     * @param  string $challenge The SCRAM challenge
161
     * @return string|false      The response to send; false in case of wrong challenge or if an initial response has
162
     * not been generated first.
163
     */
164
    private function generateResponse(string $challenge, string|false $password)
165
    {
166
        $matches = [];
167
168
        $serverMessageRegexp = "#^r=(?<nonce>[\x21-\x2B\x2D-\x7E/]+)"
169
            . ",s=(?<salt>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)"
170
            . ",i=(?<iteration>[0-9]*)"
171
            . "(?:,d=(?<downgradeProtection>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)))?"
172
            . "(,[A-Za-z]=[^,])*$#";
173
174
        if (empty($this->cnonce) ||
175
            empty($this->gs2Header) ||
176
            !preg_match($serverMessageRegexp, $challenge, $matches)) {
177
            return false;
178
        }
179
180
        $nonce = $matches['nonce'];
181
        $salt  = base64_decode($matches['salt']);
182
        if (!$salt) {
183
            // Invalid Base64.
184
            return false;
185
        }
186
        $i = intval($matches['iteration']);
187
188
        $cnonce = substr($nonce, 0, strlen($this->cnonce));
189
        if ($cnonce !== $this->cnonce) {
190
            // Invalid challenge! Are we under attack?
191
            return false;
192
        }
193
194
        if (!empty($matches['downgradeProtection'])) {
195
            if (!$this->downgradeProtection($matches['downgradeProtection'])) {
196
                return false;
197
            }
198
        }
199
200
        $channelBinding       = 'c=' . base64_encode($this->gs2Header);
201
        $finalMessage         = $channelBinding . ',r=' . $nonce;
202
        $saltedPassword       = $this->hi($password, $salt, $i);
0 ignored issues
show
Bug introduced by
It seems like $password can also be of type false; however, parameter $str of Fabiang\SASL\Authentication\SCRAM::hi() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

202
        $saltedPassword       = $this->hi(/** @scrutinizer ignore-type */ $password, $salt, $i);
Loading history...
203
        $this->saltedPassword = $saltedPassword;
204
        $clientKey            = call_user_func($this->hmac, $saltedPassword, "Client Key", true);
205
        $storedKey            = call_user_func($this->hash, $clientKey, true);
206
        $authMessage          = $this->firstMessageBare . ',' . $challenge . ',' . $finalMessage;
207
        $this->authMessage    = $authMessage;
208
        $clientSignature      = call_user_func($this->hmac, $storedKey, $authMessage, true);
209
        $clientProof          = $clientKey ^ $clientSignature;
210
        $proof                = ',p=' . base64_encode($clientProof);
211
212
        return $finalMessage . $proof;
213
    }
214
215
    private function downgradeProtection(string $expectedDowngradeProtectionHash): bool
216
    {
217
        if ($this->options->getDowngradeProtection() === null) {
218
            return true;
219
        }
220
221
        $actualDgPHash = base64_encode(call_user_func($this->hash, $this->generateDowngradeProtectionVerification()));
222
        return $expectedDowngradeProtectionHash === $actualDgPHash;
223
    }
224
225
    /**
226
     * Hi() call, which is essentially PBKDF2 (RFC-2898) with HMAC-H() as the pseudorandom function.
227
     *
228
     * @param string $str  The string to hash.
229
     * @param string $salt The salt value.
230
     * @param int $i The   iteration count.
231
     */
232
    private function hi(string $str, string $salt, int $i): string
233
    {
234
        $int1   = "\0\0\0\1";
235
        $ui     = call_user_func($this->hmac, $str, $salt . $int1, true);
236
        $result = $ui;
237
        for ($k = 1; $k < $i; $k++) {
238
            $ui     = call_user_func($this->hmac, $str, $ui, true);
239
            $result = $result ^ $ui;
240
        }
241
        return $result;
242
    }
243
244
    /**
245
     * SCRAM has also a server verification step. On a successful outcome, it will send additional data which must
246
     * absolutely be checked against this function. If this fails, the entity which we are communicating with is
247
     * probably not the server as it has not access to your ServerKey.
248
     *
249
     * @param string $data The additional data sent along a successful outcome.
250
     * @return bool Whether the server has been authenticated.
251
     * If false, the client must close the connection and consider to be under a MITM attack.
252
     */
253
    public function verify(string $data): bool
254
    {
255
        $verifierRegexp = '#^v=(?<verifier>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)$#';
256
257
        $matches = [];
258
        if (empty($this->saltedPassword) ||
259
            empty($this->authMessage) ||
260
            !preg_match($verifierRegexp, $data, $matches)) {
261
            // This cannot be an outcome, you never sent the challenge's response.
262
            return false;
263
        }
264
265
        $verifier                = $matches['verifier'];
266
        $proposedServerSignature = base64_decode($verifier);
267
        $serverKey               = call_user_func($this->hmac, $this->saltedPassword, "Server Key", true);
268
        $serverSignature         = call_user_func($this->hmac, $serverKey, $this->authMessage, true);
269
270
        return $proposedServerSignature === $serverSignature;
271
    }
272
273
    public function getCnonce(): ?string
274
    {
275
        return $this->cnonce;
276
    }
277
278
    public function getSaltedPassword(): string
279
    {
280
        return $this->saltedPassword;
281
    }
282
283
    public function getAuthMessage(): string
284
    {
285
        return $this->authMessage;
286
    }
287
288
    public function getHashAlgo(): string
289
    {
290
        return $this->hashAlgo;
291
    }
292
}
293