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

DigestMD5::trim()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
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            The digest response (NOT base64 encoded)
59
     */
60
    public function createResponse(?string $challenge = null): string|false
61
    {
62
        $parsedChallenge = $this->parseChallenge($challenge);
0 ignored issues
show
Bug introduced by
It seems like $challenge can also be of type null; however, parameter $challenge of Fabiang\SASL\Authenticat...stMD5::parseChallenge() 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

62
        $parsedChallenge = $this->parseChallenge(/** @scrutinizer ignore-type */ $challenge);
Loading history...
63
        $authzidString = '';
64
65
        $authcid  = $this->options->getAuthcid();
66
        $pass     = $this->options->getSecret();
67
        $authzid  = $this->options->getAuthzid();
68
        $service  = $this->options->getService();
69
        $hostname = $this->options->getHostname();
70
        if (!empty($authzid)) {
71
            $authzidString = 'authzid="' . $authzid . '",';
72
        }
73
74
        if (!empty($parsedChallenge)) {
75
            $cnonce         = $this->generateCnonce();
76
            $digestUri      = sprintf('%s/%s', $service, $hostname);
77
            $responseValue = $this->getResponseValue(
78
                $authcid,
0 ignored issues
show
Bug introduced by
It seems like $authcid can also be of type null; however, parameter $authcid of Fabiang\SASL\Authenticat...MD5::getResponseValue() 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

78
                /** @scrutinizer ignore-type */ $authcid,
Loading history...
79
                $pass,
0 ignored issues
show
Bug introduced by
It seems like $pass can also be of type null; however, parameter $pass of Fabiang\SASL\Authenticat...MD5::getResponseValue() 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

79
                /** @scrutinizer ignore-type */ $pass,
Loading history...
80
                $parsedChallenge['realm'],
81
                $parsedChallenge['nonce'],
82
                $cnonce,
83
                $digestUri,
84
                $authzid
85
            );
86
87
            $realm  = '';
88
            if ($parsedChallenge['realm']) {
89
                $realm = 'realm="' . $parsedChallenge['realm'] . '",';
90
            }
91
92
            return sprintf(
93
                'username="%s",%s%snonce="%s",cnonce="%s",nc=00000001,qop=auth,digest-uri="%s",'
94
                . 'response=%s,maxbuf=%d',
95
                $authcid,
96
                $realm,
97
                $authzidString,
98
                $parsedChallenge['nonce'],
99
                $cnonce,
100
                $digestUri,
101
                $responseValue,
102
                $parsedChallenge['maxbuf']
103
            );
104
        }
105
106
        throw new InvalidArgumentException('Invalid digest challenge');
107
    }
108
109
    /**
110
     * Parses and verifies the digest challenge*
111
     *
112
     * @param  string $challenge The digest challenge
113
     * @return array             The parsed challenge as an assoc
114
     *                           array in the form "directive => value".
115
     */
116
    private function parseChallenge(string $challenge): array
117
    {
118
        /**
119
         * Defaults and required directives
120
         */
121
        $tokens  = [
122
            'realm'  => '',
123
            'maxbuf' => 65536,
124
        ];
125
126
        $matches = [];
127
        while (preg_match('/^(?<key>[a-z-]+)=(?<value>"[^"]+(?<!\\\)"|[^,]+)/i', $challenge, $matches)) {
128
            $match = $matches[0];
129
            $key   = $matches['key'];
130
            $value = $matches['value'];
131
132
            $this->checkToken($tokens, $key, $value);
133
134
            // Remove the just parsed directive from the challenge
135
            $challenge = substr($challenge, strlen($match) + 1);
136
        }
137
138
        // Required: nonce, algorithm
139
        if (empty($tokens['nonce']) || empty($tokens['algorithm'])) {
140
            return [];
141
        }
142
143
        return $tokens;
144
    }
145
146
    /**
147
     * Check found token.
148
     */
149
    private function checkToken(array &$tokens, string $key, string $value): void
150
    {
151
        // Ignore these as per rfc2831
152
        if ($key !== 'opaque' && $key !== 'domain') {
153
            if (!empty($tokens[$key])) {
154
                // Allowed multiple "realm" and "auth-param"
155
                if ('realm' === $key || 'auth-param' === $key) {
156
                    // we don't support multiple realms yet
157
                    if ('realm' === $key) {
158
                        throw new RuntimeException('Multiple realms are not supported');
159
                    }
160
161
                    $tokens[$key] = (array) $tokens[$key];
162
                    $tokens[$key][] = $this->trim($value);
163
164
                    // Any other multiple instance = failure
165
                } else {
166
                    return;
167
                }
168
            } else {
169
                $tokens[$key] = $this->trim($value);
170
            }
171
        }
172
    }
173
174
    private function trim(string $string): string
175
    {
176
        return trim($string, '"');
177
    }
178
179
    /**
180
     * Creates the response= part of the digest response
181
     *
182
     * @param  string $authcid    Authentication id (username)
183
     * @param  string $pass       Password
184
     * @param  string $realm      Realm as provided by the server
185
     * @param  string $nonce      Nonce as provided by the server
186
     * @param  string $cnonce     Client nonce
187
     * @param  string $digest_uri The digest-uri= value part of the response
188
     * @param  string $authzid    Authorization id
189
     * @return string             The response= part of the digest response
190
     */
191
    private function getResponseValue(
192
        string $authcid,
193
        string $pass,
194
        string $realm,
195
        string $nonce,
196
        string $cnonce,
197
        string $digest_uri,
198
        ?string $authzid = null
199
    ): string {
200
        if (empty($authzid)) {
201
            $A1 = sprintf('%s:%s:%s', pack('H32', md5(sprintf('%s:%s:%s', $authcid, $realm, $pass))), $nonce, $cnonce);
202
        } else {
203
            $A1 = sprintf(
204
                '%s:%s:%s:%s',
205
                pack('H32', md5(sprintf('%s:%s:%s', $authcid, $realm, $pass))),
206
                $nonce,
207
                $cnonce,
208
                $authzid
209
            );
210
        }
211
        $A2 = 'AUTHENTICATE:' . $digest_uri;
212
        return md5(sprintf('%s:%s:00000001:%s:auth:%s', md5($A1), $nonce, $cnonce, md5($A2)));
213
    }
214
}
215