Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
137 / 137
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
PwnedPassword
100.00% covered (success)
100.00%
137 / 137
100.00% covered (success)
100.00%
12 / 12
38
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 hashSnippet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rangeFromHash
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 ntlmRangeFromHash
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 paddedRangeFromHash
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 paddedNtlmRangeFromHash
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 rangeDataFromHash
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 paddedRangeDataFromHash
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 stripZeroMatchesData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getRangeData
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getRangeDataWithPadding
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Icawebdesign\Hibp\Password;
6
7use Exception;
8use GuzzleHttp\ClientInterface;
9use GuzzleHttp\Exception\ClientException;
10use GuzzleHttp\Exception\GuzzleException;
11use GuzzleHttp\Exception\RequestException;
12use Icawebdesign\Hibp\Exception\PaddingHashCollisionException;
13use Icawebdesign\Hibp\HibpHttp;
14use Icawebdesign\Hibp\Traits\HibpConfig;
15use Illuminate\Support\Collection;
16use Psr\Http\Message\ResponseInterface;
17
18use function explode;
19use function strtoupper;
20use function substr;
21
22class PwnedPassword implements PwnedPasswordInterface
23{
24    use HibpConfig;
25
26    protected ClientInterface $client;
27
28    public int $statusCode;
29
30    public function __construct(HibpHttp $hibpHttp)
31    {
32        $this->client = $hibpHttp->client();
33    }
34
35    /**
36     * @param string $uri
37     * @param array<string, mixed> $options
38     *
39     * @return ResponseInterface
40     * @throws GuzzleException
41     */
42    private function getRequest(string $uri, array $options = []): ResponseInterface
43    {
44        return $this->client->request(
45            'GET',
46            $uri,
47            $options,
48        );
49    }
50
51    private function hashSnippet(string $hash): string
52    {
53        return substr($hash, offset: 0, length: 5);
54    }
55
56    /**
57     * @param string $hash
58     * @param array<string, mixed> $options
59     *
60     * @return int
61     * @throws GuzzleException|Exception
62     */
63    public function rangeFromHash(string $hash, array $options = []): int
64    {
65        $hash = strtoupper($hash);
66        $hashSnippet = $this->hashSnippet(hash: $hash);
67
68        try {
69            $response = $this->getRequest(
70                uri: "{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}",
71                options: $options,
72            );
73        } catch (ClientException $exception) {
74            $this->statusCode = $exception->getCode();
75
76            throw match ($exception->getCode()) {
77                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
78                default => $exception,
79            };
80        }
81
82        $this->statusCode = $response->getStatusCode();
83
84        $match = $this->getRangeData(response: $response, hash: $hash)->collapse();
85
86        if ($match->has($hash)) {
87            return $match->get($hash)->count;
88        }
89
90        return 0;
91    }
92
93    /**
94     * @param string $hash
95     * @param array<string, mixed> $options
96     *
97     * @return int
98     * @throws GuzzleException|Exception
99     */
100    public function ntlmRangeFromHash(string $hash, array $options = []): int
101    {
102        $hash = strtoupper($hash);
103        $hashSnippet = $this->hashSnippet(hash: $hash);
104
105        try {
106            $response = $this->getRequest(
107                uri: "{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}?mode=ntlm",
108                options: $options,
109            );
110        } catch (ClientException $exception) {
111            $this->statusCode = $exception->getCode();
112
113            throw match ($exception->getCode()) {
114                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
115                default => $exception,
116            };
117        }
118
119        $this->statusCode = $response->getStatusCode();
120
121        $match = $this->getRangeData(response: $response, hash: $hash)->collapse();
122
123        if ($match->has($hash)) {
124            return $match->get($hash)->count;
125        }
126
127        return 0;
128    }
129
130    /**
131     * @param string $hash
132     * @param array<string, mixed> $options
133     *
134     * @return int
135     * @throws GuzzleException|Exception
136     */
137    public function paddedRangeFromHash(string $hash, array $options = []): int
138    {
139        $hash = strtoupper($hash);
140        $hashSnippet = $this->hashSnippet(hash: $hash);
141
142        if (array_key_exists('headers', $options)) {
143            $options['headers']['Add-Padding'] = 'true';
144        } else {
145            $options['headers'] = ['Add-Padding' => 'true'];
146        }
147
148        try {
149            $response = $this->getRequest(
150                uri: sprintf("{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}"),
151                options: $options,
152            );
153        } catch (ClientException $exception) {
154            $this->statusCode = $exception->getCode();
155
156            throw match ($exception->getCode()) {
157                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
158                default => $exception,
159            };
160        }
161
162        $this->statusCode = $response->getStatusCode();
163
164        $match = $this->getRangeDataWithPadding(response: $response, hash: $hash)->collapse();
165
166        if ($match->has($hash)) {
167            return $match->get($hash)->count;
168        }
169
170        return 0;
171    }
172
173    /**
174     * @param string $hash
175     * @param array<string, mixed> $options
176     *
177     * @return int
178     * @throws GuzzleException|Exception
179     */
180    public function paddedNtlmRangeFromHash(string $hash, array $options = []): int
181    {
182        $hash = strtoupper($hash);
183        $hashSnippet = $this->hashSnippet(hash: $hash);
184
185        if (array_key_exists('headers', $options)) {
186            $options['headers']['Add-Padding'] = 'true';
187        } else {
188            $options['headers'] = ['Add-Padding' => 'true'];
189        }
190
191        try {
192            $response = $this->getRequest(
193                uri: "{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}?mode=ntlm",
194                options: $options,
195            );
196        } catch (ClientException $exception) {
197            $this->statusCode = $exception->getCode();
198
199            throw match ($exception->getCode()) {
200                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
201                default => $exception,
202            };
203        }
204
205        $this->statusCode = $response->getStatusCode();
206
207        $match = $this->getRangeDataWithPadding(response: $response, hash: $hash)->collapse();
208
209        if ($match->has($hash)) {
210            return $match->get($hash)->count;
211        }
212
213        return 0;
214    }
215
216    /**
217     * @param string $hash
218     * @param array<string, mixed> $options
219     *
220     * @return Collection<int, string>
221     * @throws GuzzleException|Exception
222     */
223    public function rangeDataFromHash(string $hash, array $options = []): Collection
224    {
225        $hash = strtoupper($hash);
226        $hashSnippet = $this->hashSnippet(hash: $hash);
227
228        try {
229            $response = $this->getRequest(
230                uri: "{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}",
231                options: $options,
232            );
233        } catch (ClientException $exception) {
234            $this->statusCode = $exception->getCode();
235
236            throw match ($exception->getCode()) {
237                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
238                default => $exception,
239            };
240        }
241
242        $this->statusCode = $response->getStatusCode();
243
244        return $this->getRangeData(response: $response, hash: $hash)->collapse();
245    }
246
247    /**
248     * @param string $hash
249     * @param array<string, mixed> $options
250     *
251     * @return Collection<int, string>
252     * @throws GuzzleException|Exception
253     */
254    public function paddedRangeDataFromHash(string $hash, array $options = []): Collection
255    {
256        $hash = strtoupper($hash);
257        $hashSnippet = $this->hashSnippet(hash: $hash);
258
259        try {
260            $response = $this->getRequest(
261                uri: "{$this->pwnedPasswordsApiRoot}/range/{$hashSnippet}",
262                options: $options,
263            );
264        } catch (ClientException $exception) {
265            $this->statusCode = $exception->getCode();
266
267            throw match ($exception->getCode()) {
268                400 => new RequestException($exception->getMessage(), $exception->getRequest()),
269                default => $exception,
270            };
271        }
272
273        $this->statusCode = $response->getStatusCode();
274
275        return $this->getRangeDataWithPadding(response: $response, hash: $hash)->collapse();
276    }
277
278    /**
279     * @param Collection<string, PwnedPasswordEntity> $data
280     * @param string $hash
281     *
282     * @return Collection<string, PwnedPasswordEntity>
283     */
284    public static function stripZeroMatchesData(Collection $data, string $hash): Collection
285    {
286        $hash = strtoupper($hash);
287
288        return $data->filter(static function (PwnedPasswordEntity $pwnedPassword) use ($hash) {
289            if ($pwnedPassword->hashSnippet === $hash && $pwnedPassword->count === 0) {
290                throw new PaddingHashCollisionException('Padding hash collision');
291            }
292
293            return $pwnedPassword->count > 0;
294        });
295    }
296
297    /**
298     * @param ResponseInterface $response
299     * @param string $hash
300     *
301     * @return Collection<int, Collection<string, PwnedPasswordEntity>>
302     */
303    public function getRangeData(
304        ResponseInterface $response,
305        string $hash,
306    ): Collection {
307        $hash = strtoupper($hash);
308        $hashSnippet = $this->hashSnippet(hash: $hash);
309
310        return Collection::make(explode("\r\n", (string)$response->getBody()))
311            ->map(static function (string $hashSuffix) use ($hashSnippet, $hash) {
312                [$suffix, $count] = explode(':', $hashSuffix);
313                $fullHash = "{$hashSnippet}{$suffix}";
314
315                return Collection::make([
316                    $fullHash => new PwnedPasswordEntity(
317                        hashSnippet: $fullHash,
318                        count: (int)$count,
319                        matched: $fullHash === $hash,
320                    ),
321                ]);
322            });
323    }
324
325    /**
326     * @param ResponseInterface $response
327     * @param string $hash
328     *
329     * @return Collection<int, Collection<string, PwnedPasswordEntity>>
330     */
331    public function getRangeDataWithPadding(
332        ResponseInterface $response,
333        string $hash,
334    ): Collection {
335        $hash = strtoupper($hash);
336        $hashSnippet = $this->hashSnippet(hash: $hash);
337
338        return Collection::make(explode("\r\n", (string)$response->getBody()))
339            ->map(static function (string $hashSuffix) use ($hashSnippet, $hash) {
340                [$suffix, $count] = explode(':', $hashSuffix);
341                $fullHash = "{$hashSnippet}{$suffix}";
342
343                return Collection::make([
344                    $fullHash => new PwnedPasswordEntity(
345                        hashSnippet: $fullHash,
346                        count: (int)$count,
347                        matched: $fullHash === $hash,
348                    ),
349                ]);
350            });
351    }
352}