Passed
Branch refactoring (9be877)
by Fabian
16:03
created

DigestMD5::parseChallenge()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 32
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 15
nc 5
nop 1
dl 0
loc 32
rs 9.2222
c 0
b 0
f 0
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 Richard Heyes <[email protected]>
38
 */
39
40
namespace Fabiang\SASL\Authentication;
41
42
use Fabiang\SASL\Exception\InvalidArgumentException;
43
use Fabiang\SASL\Exception\RuntimeException;
44
45
/**
46
 * Implmentation of DIGEST-MD5 SASL mechanism
47
 *
48
 * @author Richard Heyes <[email protected]>
49
 */
50
class DigestMD5 extends AbstractAuthentication implements ChallengeAuthenticationInterface
51
{
52
    /**
53
     * Provides the (main) client response for DIGEST-MD5
54
     * requires a few extra parameters than the other
55
     * mechanisms, which are unavoidable.
56
     *
57
     * @param  string $challenge The digest challenge sent by the server
58
     * @return string|false      The digest response (NOT base64 encoded)
59
     */
60
    public function createResponse(?string $challenge = null): string|false
61
    {
62
        $parsedChallenge = $this->parseChallenge($challenge);
63
        $authzidString = '';
64
65
        $authcid  = $this->options->getAuthcid();
66
        $secret   = $this->options->getSecret();
67
        $service  = $this->options->getService();
68
        $hostname = $this->options->getHostname();
69
70
        if ($authcid === null || $secret === null || $service === null || $hostname === null
71
            || $authcid === '' || $secret === '' || $service === '' || $hostname === '') {
72
            return false;
73
        }
74
75
        $authzid  = $this->options->getAuthzid();
76
        if ($authzid !== null && $authzid !== '') {
77
            $authzidString = 'authzid="' . $authzid . '",';
78
        }
79
80
        if (!empty($parsedChallenge)) {
81
            $cnonce         = $this->generateCnonce();
82
            $digestUri      = sprintf('%s/%s', $service, $hostname);
83
            $responseValue = $this->getResponseValue(
84
                $authcid,
85
                $secret,
86
                $parsedChallenge['realm'],
87
                $parsedChallenge['nonce'],
88
                $cnonce,
89
                $digestUri,
90
                $authzid
91
            );
92
93
            $realm  = '';
94
            if ($parsedChallenge['realm']) {
95
                $realm = 'realm="' . $parsedChallenge['realm'] . '",';
96
            }
97
98
            return sprintf(
99
                'username="%s",%s%snonce="%s",cnonce="%s",nc=00000001,qop=auth,digest-uri="%s",'
100
                . 'response=%s,maxbuf=%d',
101
                $authcid,
102
                $realm,
103
                $authzidString,
104
                $parsedChallenge['nonce'],
105
                $cnonce,
106
                $digestUri,
107
                $responseValue,
108
                $parsedChallenge['maxbuf']
109
            );
110
        }
111
112
        throw new InvalidArgumentException('Invalid digest challenge');
113
    }
114
115
    /**
116
     * Parses and verifies the digest challenge*
117
     *
118
     * @param  string|null $challenge The digest challenge
119
     * @return array             The parsed challenge as an assoc
120
     *                           array in the form "directive => value".
121
     */
122
    private function parseChallenge(?string $challenge): array
123
    {
124
        if ($challenge === null || $challenge === '') {
125
            return [];
126
        }
127
128
        /**
129
         * Defaults and required directives
130
         */
131
        $tokens  = [
132
            'realm'  => '',
133
            'maxbuf' => 65536,
134
        ];
135
136
        $matches = [];
137
        while (preg_match('/^(?<key>[a-z-]+)=(?<value>"[^"]+(?<!\\\)"|[^,]+)/i', $challenge, $matches)) {
138
            $match = $matches[0];
139
            $key   = $matches['key'];
140
            $value = $matches['value'];
141
142
            $this->checkToken($tokens, $key, $value);
143
144
            // Remove the just parsed directive from the challenge
145
            $challenge = substr($challenge, strlen($match) + 1);
146
        }
147
148
        // Required: nonce, algorithm
149
        if (empty($tokens['nonce']) || empty($tokens['algorithm'])) {
150
            return [];
151
        }
152
153
        return $tokens;
154
    }
155
156
    /**
157
     * Check found token.
158
     */
159
    private function checkToken(array &$tokens, string $key, string $value): void
160
    {
161
        // Ignore these as per rfc2831
162
        if ($key !== 'opaque' && $key !== 'domain') {
163
            if (!empty($tokens[$key])) {
164
                // Allowed multiple "realm" and "auth-param"
165
                if ('realm' === $key || 'auth-param' === $key) {
166
                    // we don't support multiple realms yet
167
                    if ('realm' === $key) {
168
                        throw new RuntimeException('Multiple realms are not supported');
169
                    }
170
171
                    $tokens[$key] = (array) $tokens[$key];
172
                    $tokens[$key][] = $this->trim($value);
173
174
                    // Any other multiple instance = failure
175
                } else {
176
                    return;
177
                }
178
            } else {
179
                $tokens[$key] = $this->trim($value);
180
            }
181
        }
182
    }
183
184
    private function trim(string $string): string
185
    {
186
        return trim($string, '"');
187
    }
188
189
    /**
190
     * Creates the response= part of the digest response
191
     *
192
     * @param  string $authcid    Authentication id (username)
193
     * @param  string $secret     Secret/Password
194
     * @param  string $realm      Realm as provided by the server
195
     * @param  string $nonce      Nonce as provided by the server
196
     * @param  string $cnonce     Client nonce
197
     * @param  string $digest_uri The digest-uri= value part of the response
198
     * @param  string $authzid    Authorization id
199
     * @return string             The response= part of the digest response
200
     */
201
    private function getResponseValue(
202
        string $authcid,
203
        #[\SensitiveParameter]
204
        string $secret,
205
        string $realm,
206
        string $nonce,
207
        string $cnonce,
208
        string $digest_uri,
209
        ?string $authzid = null
210
    ): string {
211
        $pack = pack('H32', md5(sprintf('%s:%s:%s', $authcid, $realm, $secret)));
212
213
        if ($authzid === null || $authzid === '') {
214
            $A1 = sprintf('%s:%s:%s', $pack, $nonce, $cnonce);
215
        } else {
216
            $A1 = sprintf('%s:%s:%s:%s', $pack, $nonce, $cnonce, $authzid);
217
        }
218
219
        $A2 = 'AUTHENTICATE:' . $digest_uri;
220
        return md5(sprintf('%s:%s:00000001:%s:auth:%s', md5($A1), $nonce, $cnonce, md5($A2)));
221
    }
222
}
223