Passed
Push — develop ( 735bdf...e6d6b0 )
by Fabian
02:08
created

SCRAM::generateResponse()   B

Complexity

Conditions 8
Paths 7

Size

Total Lines 53
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 8.0023

Importance

Changes 7
Bugs 1 Features 1
Metric Value
cc 8
eloc 33
c 7
b 1
f 1
nc 7
nop 2
dl 0
loc 53
ccs 29
cts 30
cp 0.9667
crap 8.0023
rs 8.1475

How to fix   Long Method   

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
            . "(,(?<additionalAttributes>.*))?$#";
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);
175
176
        //forbidden by RFC 5802
177 4
        if(isset($additionalAttributes['m']))
178
            return false;
179 4
180 4
        $nonce = $matches['nonce'];
181
        $salt  = base64_decode($matches['salt']);
182 2
        if (!$salt) {
183
            // Invalid Base64.
184
            return false;
185 2
        }
186 2
        $i = intval($matches['iteration']);
187 2
188 2
        $cnonce = substr($nonce, 0, strlen($this->cnonce));
189 2
        if ($cnonce !== $this->cnonce) {
190 2
            // Invalid challenge! Are we under attack?
191 2
            return false;
192 2
        }
193 2
194 2
        //SSDP hash
195 2
        if (!empty($additionalAttributes['d'])) {
196
            if (!$this->downgradeProtection($additionalAttributes['d'])) {
197 2
                return false;
198
            }
199
        }
200
201
        $channelBinding       = 'c=' . base64_encode($this->gs2Header);
202
        $finalMessage         = $channelBinding . ',r=' . $nonce;
203
        $saltedPassword       = $this->hi($password, $salt, $i);
204
        $this->saltedPassword = $saltedPassword;
205
        $clientKey            = call_user_func($this->hmac, $saltedPassword, "Client Key", true);
206
        $storedKey            = call_user_func($this->hash, $clientKey, true);
207 2
        $authMessage          = $this->firstMessageBare . ',' . $challenge . ',' . $finalMessage;
208
        $this->authMessage    = $authMessage;
209 2
        $clientSignature      = call_user_func($this->hmac, $storedKey, $authMessage, true);
210 2
        $clientProof          = $clientKey ^ $clientSignature;
211 2
        $proof                = ',p=' . base64_encode($clientProof);
212 2
213 2
        return $finalMessage . $proof;
214 2
    }
215
216 2
    /**
217
     * @param string $expectedDowngradeProtectionHash
218
     * @return bool
219
     */
220
    private function downgradeProtection($expectedDowngradeProtectionHash)
221
    {
222
        if ($this->options->getDowngradeProtection() === null) {
223
            return true;
224
        }
225
226
        $actualDgPHash = base64_encode(call_user_func($this->hash, $this->generateDowngradeProtectionVerification()));
227
        return $expectedDowngradeProtectionHash === $actualDgPHash;
228 4
    }
229
230 4
    /**
231
     * Hi() call, which is essentially PBKDF2 (RFC-2898) with HMAC-H() as the pseudorandom function.
232 4
     *
233 4
     * @param string $str  The string to hash.
234
     * @param string $salt The salt value.
235 2
     * @param int $i The   iteration count.
236
     */
237
    private function hi($str, $salt, $i)
238 2
    {
239 2
        $int1   = "\0\0\0\1";
240 2
        $ui     = call_user_func($this->hmac, $str, $salt . $int1, true);
241 2
        $result = $ui;
242
        for ($k = 1; $k < $i; $k++) {
243 2
            $ui     = call_user_func($this->hmac, $str, $ui, true);
244
            $result = $result ^ $ui;
245
        }
246
        return $result;
247
    }
248
249 2
    /**
250
     * This will parse all non-fixed-position additional SCRAM attributes (the optional ones and the m-attribute)
251 2
     * @param array $matches The array returned by our regex match, MUST contain an 'additionalAttributes' key
252
     * @return array
253
     */
254 2
    private function parseAdditionalAttributes($matches)
255
    {
256 2
        $additionalAttributes=array();
257
        $tail=explode(',', $matches['additionalAttributes']);
258
        foreach($tail as $entry)
259 2
        {
260
            $entry=explode("=", $entry, 2);
261 2
            $additionalAttributes[$entry[0]] = $entry[1];
262
        }
263
        return $additionalAttributes;
264 2
    }
265
266 2
    /**
267
     * SCRAM has also a server verification step. On a successful outcome, it will send additional data which must
268
     * absolutely be checked against this function. If this fails, the entity which we are communicating with is
269
     * probably not the server as it has not access to your ServerKey.
270
     *
271
     * @param string $data The additional data sent along a successful outcome.
272
     * @return bool Whether the server has been authenticated.
273
     * If false, the client must close the connection and consider to be under a MITM attack.
274
     */
275
    public function verify($data)
276
    {
277
        $verifierRegexp = '#^v=((?:[A-Za-z0-9/+]{4})*(?:[A-Za-z0-9/+]{3}=|[A-Za-z0-9/+]{2}==)?)(,(?<additionalAttributes>.*))?$#';
278
279
        $matches = array();
280
        if (!isset($this->saltedPassword, $this->authMessage) || !preg_match($verifierRegexp, $data, $matches)) {
281
            // This cannot be an outcome, you never sent the challenge's response.
282
            return false;
283
        }
284
285
        $additionalAttributes = $this->parseAdditionalAttributes($matches);
286
287
        //forbidden by RFC 5802
288
        if(isset($additionalAttributes['m']))
289
            return false;
290
291
        $verifier                = $matches[1];
292
        $proposedServerSignature = base64_decode($verifier);
293
        $serverKey               = call_user_func($this->hmac, $this->saltedPassword, "Server Key", true);
294
        $serverSignature         = call_user_func($this->hmac, $serverKey, $this->authMessage, true);
295
296
        return $proposedServerSignature === $serverSignature;
297
    }
298
299
    /**
300
     * @return string
301
     */
302
    public function getCnonce()
303
    {
304
        return $this->cnonce;
305
    }
306
307
    public function getSaltedPassword()
308
    {
309
        return $this->saltedPassword;
310
    }
311
312
    public function getAuthMessage()
313
    {
314
        return $this->authMessage;
315
    }
316
317
    public function getHashAlgo()
318
    {
319
        return $this->hashAlgo;
320
    }
321
}
322