SCRAM::generateResponse()   B
last analyzed

Complexity

Conditions 10
Paths 11

Size

Total Lines 59
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 10.003

Importance

Changes 7
Bugs 1 Features 1
Metric Value
cc 10
eloc 36
c 7
b 1
f 1
nc 11
nop 2
dl 0
loc 59
ccs 31
cts 32
cp 0.9688
crap 10.003
rs 7.6666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * Sasl library.
5
 *
6
 * Copyright (c) 2002-2003 Richard Heyes,
7
 *               2014-2024 Fabian Grutschus
8
 * All rights reserved.
9
 *
10
 * Redistribution and use in source and binary forms, with or without
11
 * modification, are permitted provided that the following conditions
12
 * are met:
13
 *
14
 * o Redistributions of source code must retain the above copyright
15
 *   notice, this list of conditions and the following disclaimer.
16
 * o Redistributions in binary form must reproduce the above copyright
17
 *   notice, this list of conditions and the following disclaimer in the
18
 *   documentation and/or other materials provided with the distribution.|
19
 * o The names of the authors may not be used to endorse or promote
20
 *   products derived from this software without specific prior written
21
 *   permission.
22
 *
23
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
24
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
25
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
26
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
27
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
28
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
29
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
30
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
31
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
32
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
33
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
 *
35
 * @author Jehan <[email protected]>
36
 */
37
38
namespace Fabiang\Sasl\Authentication;
39
40
use Fabiang\Sasl\Authentication\AbstractAuthentication;
41
use Fabiang\Sasl\Options;
42
use Fabiang\Sasl\Exception\InvalidArgumentException;
43
44
/**
45
 * Implementation of SCRAM-* SASL mechanisms.
46
 * SCRAM mechanisms have 3 main steps (initial response, response to the server challenge, then server signature
47
 * verification) which keep state-awareness. Therefore a single class instanciation must be done
48
 * and reused for the whole authentication process.
49
 *
50
 * @author Jehan <[email protected]>
51
 */
52
class SCRAM extends AbstractAuthentication implements ChallengeAuthenticationInterface, VerificationInterface
53
{
54
    private $hashAlgo;
55
    private $hash;
56
    private $hmac;
57
    private $gs2Header;
58
    private $cnonce;
59
    private $firstMessageBare;
60
    private $saltedPassword;
61
    private $authMessage;
62
63
    /**
64
     * Construct a SCRAM-H client where 'H' is a cryptographic hash function.
65
     *
66
     * @link http://www.iana.org/assignments/hash-function-text-names/hash-function-text-names.xml "Hash Function
67
     * Textual Names" format of core PHP hash function.
68
     * @param Options $options
69
     * @param string  $hash The name cryptographic hash function 'H' as registered by IANA in the "Hash Function Textual
70
     * Names" registry.
71
     * @throws InvalidArgumentException
72
     */
73 8
    public function __construct(Options $options, $hash)
74
    {
75 8
        parent::__construct($options);
76
77
        // Though I could be strict, I will actually also accept the naming used in the PHP core hash framework.
78
        // For instance "sha1" is accepted, while the registered hash name should be "SHA-1".
79 8
        $normalizedHash = strtolower(preg_replace('#^sha-(\d+)#i', 'sha\1', $hash));
80
81 8
        $hashAlgos = hash_algos();
82 8
        if (!in_array($normalizedHash, $hashAlgos)) {
83 2
            throw new InvalidArgumentException("Invalid SASL mechanism type '$hash'");
84
        }
85
86 8
        $this->hash = function ($data) use ($normalizedHash) {
87 2
            return hash($normalizedHash, $data, true);
88
        };
89
90 8
        $this->hmac = function ($key, $str, $raw) use ($normalizedHash) {
91 2
            return hash_hmac($normalizedHash, $str, $key, $raw);
92
        };
93
94 8
        $this->hashAlgo = $normalizedHash;
95
    }
96
97
    /**
98
     * Provides the (main) client response for SCRAM-H.
99
     *
100
     * @param  string $challenge The challenge sent by the server.
101
     * If the challenge is null or an empty string, the result will be the "initial response".
102
     * @return string|false      The response (binary, NOT base64 encoded)
103
     */
104 6
    public function createResponse($challenge = null)
105
    {
106 6
        $authcid = $this->formatName($this->options->getAuthcid());
107 6
        if (empty($authcid)) {
108 2
            return false;
109
        }
110
111 4
        $authzid = $this->options->getAuthzid();
112 4
        if (!empty($authzid)) {
113 4
            $authzid = $this->formatName($authzid);
114
        }
115
116 4
        if (empty($challenge)) {
117 4
            return $this->generateInitialResponse($authcid, $authzid);
118
        } else {
119 2
            return $this->generateResponse($challenge, $this->options->getSecret());
120
        }
121
    }
122
123
    /**
124
     * Prepare a name for inclusion in a SCRAM response.
125
     *
126
     * @param string $username a name to be prepared.
127
     * @return string the reformated name.
128
     */
129 2
    private function formatName($username)
130
    {
131 2
        return str_replace(array('=', ','), array('=3D', '=2C'), $username);
132
    }
133
134
    /**
135
     * Generate the initial response which can be either sent directly in the first message or as a response to an empty
136
     * server challenge.
137
     *
138
     * @param string $authcid Prepared authentication identity.
139
     * @param string $authzid Prepared authorization identity.
140
     * @return string The SCRAM response to send.
141
     */
142 2
    private function generateInitialResponse($authcid, $authzid)
143
    {
144 2
        $gs2CbindFlag   = 'n,';
145 2
        $this->gs2Header = $gs2CbindFlag . (!empty($authzid) ? 'a=' . $authzid : '') . ',';
146
147
        // I must generate a client nonce and "save" it for later comparison on second response.
148 2
        $this->cnonce = $this->generateCnonce();
149
150 2
        $this->firstMessageBare = 'n=' . $authcid . ',r=' . $this->cnonce;
151 2
        return $this->gs2Header . $this->firstMessageBare;
152
    }
153
154
    /**
155
     * Parses and verifies a non-empty SCRAM challenge.
156
     *
157
     * @param  string $challenge The SCRAM challenge
158
     * @return string|false      The response to send; false in case of wrong challenge or if an initial response has
159
     * not been generated first.
160
     */
161 6
    private function generateResponse($challenge, $password)
162
    {
163 6
        $matches = array();
164
165
        $serverMessageRegexp = "#^r=(?<nonce>[\x21-\x2B\x2D-\x7E/]+)"
166
            . ",s=(?<salt>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)"
167
            . ",i=(?<iteration>[0-9]*)"
168 6
            . "(?<additionalAttr>(?:,[A-Za-z]=[^,]+)*)$#";
169 2
170
        if (!isset($this->cnonce, $this->gs2Header) || !preg_match($serverMessageRegexp, $challenge, $matches)) {
171 4
            return false;
172 4
        }
173 4
174
        $additionalAttributes = $this->parseAdditionalAttributes($matches['additionalAttr']);
175
176
        //forbidden by RFC 5802
177 4
        if (isset($additionalAttributes['m'])) {
178
            return false;
179 4
        }
180 4
181
        $nonce = $matches['nonce'];
182 2
        $salt  = base64_decode($matches['salt']);
183
        if (!$salt) {
184
            // Invalid Base64.
185 2
            return false;
186 2
        }
187 2
        $i = intval($matches['iteration']);
188 2
189 2
        $cnonce = substr($nonce, 0, strlen($this->cnonce));
190 2
        if ($cnonce !== $this->cnonce) {
191 2
            // Invalid challenge! Are we under attack?
192 2
            return false;
193 2
        }
194 2
195 2
        if (! empty($additionalAttributes['h'])) {
196
            if (! $this->downgradeProtection($additionalAttributes['h'], "\x1f", "\x1e")) {
197 2
                return false;
198
            }
199
        }
200
201
        if (! empty($additionalAttributes['d'])) {
202
            if (! $this->downgradeProtection($additionalAttributes['d'], '|', ',')) {
203
                return false;
204
            }
205
        }
206
207 2
        $channelBinding       = 'c=' . base64_encode($this->gs2Header);
208
        $finalMessage         = $channelBinding . ',r=' . $nonce;
209 2
        $saltedPassword       = $this->hi($password, $salt, $i);
210 2
        $this->saltedPassword = $saltedPassword;
211 2
        $clientKey            = call_user_func($this->hmac, $saltedPassword, "Client Key", true);
212 2
        $storedKey            = call_user_func($this->hash, $clientKey, true);
213 2
        $authMessage          = $this->firstMessageBare . ',' . $challenge . ',' . $finalMessage;
214 2
        $this->authMessage    = $authMessage;
215
        $clientSignature      = call_user_func($this->hmac, $storedKey, $authMessage, true);
216 2
        $clientProof          = $clientKey ^ $clientSignature;
217
        $proof                = ',p=' . base64_encode($clientProof);
218
219
        return $finalMessage . $proof;
220
    }
221
222
    /**
223
     * @param string $expectedDowngradeProtectionHash
224
     * @param string $groupDelimiter
225
     * @param string $delimiter
226
     * @return bool
227
     */
228 4
    private function downgradeProtection($expectedDowngradeProtectionHash, $groupDelimiter, $delimiter)
229
    {
230 4
        if ($this->options->getDowngradeProtection() === null) {
231
            return true;
232 4
        }
233 4
234
        $actualDgPHash = base64_encode(
235 2
            call_user_func(
236
                $this->hash,
237
                $this->generateDowngradeProtectionVerification($groupDelimiter, $delimiter)
238 2
            )
239 2
        );
240 2
        return $expectedDowngradeProtectionHash === $actualDgPHash;
241 2
    }
242
243 2
    /**
244
     * Hi() call, which is essentially PBKDF2 (RFC-2898) with HMAC-H() as the pseudorandom function.
245
     *
246
     * @param string $str  The string to hash.
247
     * @param string $salt The salt value.
248
     * @param int $i The   iteration count.
249 2
     */
250
    private function hi($str, $salt, $i)
251 2
    {
252
        $int1   = "\0\0\0\1";
253
        $ui     = call_user_func($this->hmac, $str, $salt . $int1, true);
254 2
        $result = $ui;
255
        for ($k = 1; $k < $i; $k++) {
256 2
            $ui     = call_user_func($this->hmac, $str, $ui, true);
257
            $result = $result ^ $ui;
258
        }
259 2
        return $result;
260
    }
261 2
262
    /**
263
     * This will parse all non-fixed-position additional SCRAM attributes (the optional ones and the m-attribute)
264 2
     *
265
     * @param string $additionalAttributes 'additionalAttributes' string
266 2
     * @return array
267
     */
268
    private function parseAdditionalAttributes($additionalAttributes)
269
    {
270
        if ($additionalAttributes == "") {
271
            return array();
272
        }
273
274
        $return = array();
275
        $tail = explode(',', $additionalAttributes);
276
277
        foreach ($tail as $entry) {
278
            $entry = explode("=", $entry, 2);
279
            if (count($entry) > 1) {
280
                $return[$entry[0]] = $entry[1];
281
            }
282
        }
283
        return $return;
284
    }
285
286
    /**
287
     * SCRAM has also a server verification step. On a successful outcome, it will send additional data which must
288
     * absolutely be checked against this function. If this fails, the entity which we are communicating with is
289
     * probably not the server as it has not access to your ServerKey.
290
     *
291
     * @param string $data The additional data sent along a successful outcome.
292
     * @return bool Whether the server has been authenticated.
293
     * If false, the client must close the connection and consider to be under a MITM attack.
294
     */
295
    public function verify($data)
296
    {
297
        $verifierRegexp = '#^v=(?<verifier>(?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)'
298
            . '(?<additionalAttr>(?:,[A-Za-z]=[^,]+)*)$#';
299
300
        $matches = array();
301
        if (!isset($this->saltedPassword, $this->authMessage) || !preg_match($verifierRegexp, $data, $matches)) {
302
            // This cannot be an outcome, you never sent the challenge's response.
303
            return false;
304
        }
305
306
        $additionalAttribute = $this->parseAdditionalAttributes($matches['additionalAttr']);
307
308
        if (isset($additionalAttribute['m'])) {
309
            return false;
310
        }
311
312
        $verifier                = $matches['verifier'];
313
        $proposedServerSignature = base64_decode($verifier);
314
        $serverKey               = call_user_func($this->hmac, $this->saltedPassword, "Server Key", true);
315
        $serverSignature         = call_user_func($this->hmac, $serverKey, $this->authMessage, true);
316
317
        return $proposedServerSignature === $serverSignature;
318
    }
319
320
    /**
321
     * @return string
322
     */
323
    public function getCnonce()
324
    {
325
        return $this->cnonce;
326
    }
327
328
    public function getSaltedPassword()
329
    {
330
        return $this->saltedPassword;
331
    }
332
333
    public function getAuthMessage()
334
    {
335
        return $this->authMessage;
336
    }
337
338
    public function getHashAlgo()
339
    {
340
        return $this->hashAlgo;
341
    }
342
}
343