Passed
Push — main ( 735bdf...5ab03f )
by Fabian
02:25
created

SCRAM::parseAdditionalAttributes()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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