Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
HaveIBeenPwnedProvider
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
4 / 4
16
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
 getDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCompromised
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 queryApiForSuffix
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Integrations\Infrastructure\Provider;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Domain\Contract\CompromisedPasswordProviderInterface;
12use App\Modules\Integrations\Infrastructure\Security\SsrfProtectionClient;
13use App\Modules\Integrations\Infrastructure\Security\SsrfProtectionClientInterface;
14use Throwable;
15
16/**
17 * HaveIBeenPwned (HIBP) Password Security Provider.
18 *
19 * Implements NIST SP 800-63B ยง5.1.1.2 and OWASP ASVS v5 V2.1.7 using k-Anonymity SHA-1 prefix
20 * queries against the Pwned Passwords API with local offline blacklist resilience.
21 */
22final readonly class HaveIBeenPwnedProvider implements CompromisedPasswordProviderInterface
23{
24    public const string DRIVER_NAME = 'haveibeenpwned';
25    private const string DEFAULT_API_URL = 'https://api.pwnedpasswords.com/range/';
26    private const int DEFAULT_TIMEOUT_SECONDS = 3;
27
28    /**
29     * Top high-risk breached passwords blacklist for instant offline rejection.
30     *
31     * @var list<string>
32     */
33    private const array OFFLINE_BLACKLIST = [
34        'password', 'password123', '123456', '12345678', '123456789', '1234567890',
35        'qwerty', 'qwerty123', 'admin', 'admin123', 'welcome', 'welcome123',
36        'letmein', 'monkey', 'dragon', 'master', 'sunshine', 'princess', 'football',
37        'iloveyou', 'ammonly', 'ammonly123', 'changeit', 'changeme', 'pass1234',
38    ];
39
40    public function __construct(
41        private SsrfProtectionClientInterface $httpClient = new SsrfProtectionClient()
42    ) {
43    }
44
45    public function getDriver(): string
46    {
47        return self::DRIVER_NAME;
48    }
49
50    /**
51     * {@inheritdoc}
52     */
53    public function isCompromised(string $password, ?string $apiUrl = null, array $config = []): bool
54    {
55        $normalized = trim($password);
56        if ($normalized === '') {
57            return false;
58        }
59
60        $offlineFallback = (bool) ($config['offline_fallback'] ?? true);
61        if ($offlineFallback && in_array(strtolower($normalized), self::OFFLINE_BLACKLIST, true)) {
62            return true;
63        }
64
65        if (!empty($config['mock_mode'])) {
66            return str_contains(strtolower($normalized), 'compromised')
67                || str_contains(strtolower($normalized), 'pwned');
68        }
69
70        $sha1 = strtoupper(sha1($normalized));
71        $prefix = substr($sha1, 0, 5);
72        $suffix = substr($sha1, 5);
73        $baseUrl = !empty($apiUrl) ? $apiUrl : self::DEFAULT_API_URL;
74        $timeout = (int) ($config['timeout_seconds'] ?? self::DEFAULT_TIMEOUT_SECONDS);
75
76        return $this->queryApiForSuffix($baseUrl, $prefix, $suffix, $timeout);
77    }
78
79    /**
80     * Queries the k-Anonymity endpoint and checks whether the hash suffix exists.
81     */
82    private function queryApiForSuffix(string $baseUrl, string $prefix, string $suffix, int $timeout): bool
83    {
84        $url = rtrim($baseUrl, '/') . '/' . $prefix;
85
86        try {
87            $response = $this->httpClient->get(
88                url: $url,
89                headers: [
90                    'User-Agent' => 'Ammonly-Security-Agent/1.0',
91                    'Accept'     => 'text/plain',
92                ],
93                timeout: $timeout
94            );
95
96            if ($response['status'] !== 200 || empty($response['body'])) {
97                return false;
98            }
99
100            $lines = explode("\n", str_replace("\r", '', $response['body']));
101            foreach ($lines as $line) {
102                $parts = explode(':', trim($line));
103                if (count($parts) >= 2 && hash_equals($parts[0], $suffix)) {
104                    return true;
105                }
106            }
107
108            return false;
109        } catch (Throwable) {
110            return false;
111        }
112    }
113}