Passed
Push — master ( f5ca5a...0e4678 )
by Thomas
07:23
created

CrlCertificateStatusResolver::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 6
nc 2
nop 3
dl 0
loc 10
ccs 0
cts 7
cp 0
crap 6
rs 10
c 1
b 0
f 0
1
<?php
2
3
namespace MadWizard\WebAuthn\Pki;
4
5
use DateTimeImmutable;
6
use Exception;
7
use MadWizard\WebAuthn\Cache\CacheProviderInterface;
8
use MadWizard\WebAuthn\Exception\RemoteException;
9
use MadWizard\WebAuthn\Exception\UnsupportedException;
10
use MadWizard\WebAuthn\Exception\VerificationException;
11
use MadWizard\WebAuthn\Exception\WebAuthnException;
12
use MadWizard\WebAuthn\Remote\DownloaderInterface;
13
use Psr\Cache\CacheItemPoolInterface;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\NullLogger;
17
use Sop\X509\Certificate\Certificate;
18
19
/**
20
 * @experimental
21
 */
22
final class CrlCertificateStatusResolver implements CertificateStatusResolverInterface, LoggerAwareInterface
23
{
24
    /** @var CacheItemPoolInterface */
25
    private $cache;
26
27
    /**
28
     * @var DownloaderInterface
29
     */
30
    private $downloader;
31
32
    /**
33
     * @var LoggerInterface
34
     */
35
    private $logger;
36
37
    /**
38
     * @var bool
39
     */
40
    private $silentFailure;
41
42
    /**
43
     * @experimental
44
     */
45
    public function __construct(DownloaderInterface $downloader, CacheProviderInterface $cacheProvider, bool $silentFailure = false)
46
    {
47
        if (!class_exists(\phpseclib3\File\X509::class)) {
48
            throw new UnsupportedException('CRL support is experimental and requires a (not yet stable) phpseclib v3. Use composer require phpseclib/phpseclib 3.0.x-dev.');
49
        }
50
51
        $this->downloader = $downloader;
52
        $this->cache = $cacheProvider->getCachePool('crl');
53
        $this->logger = new NullLogger();
54
        $this->silentFailure = $silentFailure;
55
    }
56
57
    /**
58
     * @experimental
59
     */
60
    public function setLogger(LoggerInterface $logger)
61
    {
62
        $this->logger = $logger;
63
    }
64
65
    /**
66
     * @experimental
67
     */
68
    public function isRevoked(X509Certificate $subject, X509Certificate ...$caCertificates): bool
69
    {
70
        try {
71
            $cert = Certificate::fromDER($subject->asDer());
72
            $csn = $cert->tbsCertificate()->serialNumber();
73
        } catch (Exception $e) {
74
            throw new VerificationException(sprintf('Failed to parse certificate: %s', $e->getMessage()), 0, $e);
75
        }
76
77
        try {
78
            $urls = $this->getCrlUrlList($cert);
79
        } catch (VerificationException $e) {
80
            $this->logger->warning(
81
                'Failed to get CRL distribution points: {message}',
82
                [
83
                    'message' => $e->getMessage(),
84
                    'exception' => $e,
85
                ]
86
            );
87
            if ($this->silentFailure) {
88
                return false;
89
            }
90
            throw new VerificationException('Failed to get CRL distribution points: ' . $e->getMessage(), 0, $e);
91
        }
92
93
        foreach ($urls as $url) {
94
            try {
95
                $crl = $this->retrieveCrl($url, ...$caCertificates);
96
97
                if ($crl->isRevoked($csn)) {
98
                    $this->logger->warning(
99
                        'Certificate {serial} with subject "{subject}" is revoked.',
100
                        [
101
                            'serial' => $csn,
102
                            'subject' => $cert->tbsCertificate()->subject(),
103
                        ]
104
                    );
105
                    return true;
106
                }
107
            } catch (WebAuthnException $e) {
108
                if ($this->silentFailure) {
109
                    continue;
110
                }
111
                throw new VerificationException(sprintf('Failed to retrieve CRL %s:' . PHP_EOL . '%s', $url, $e->getMessage()), 0, $e);
112
            }
113
        }
114
        return false;
115
    }
116
117
    private function retrieveCrl(string $url, X509Certificate ...$caCertificates): Crl
118
    {
119
        $urlHash = hash('sha256', $url);
120
        $item = $this->cache->getItem($urlHash);
121
122
        $crlData = null;
123
        if ($item->isHit()) {
124
            $data = $item->get();
125
            if ($data['nextUpdate'] > new DateTimeImmutable()) {           // TODO: abstract time for unit tests
126
                $this->logger->debug('Using CRL from cache {url} (next update {date}).', ['url' => $url, 'date' => $data['nextUpdate']->format('Y-m-d H:i:s')]);
127
                $crlData = $data['data'];
128
            }
129
        }
130
131
        if ($crlData === null) {
132
            try {
133
                $this->logger->debug('Retrieving CRL from {url}', ['url' => $url]);
134
                $crlFile = $this->downloader->downloadFile($url);
135
            } catch (RemoteException $e) {
136
                $this->logger->warning('Failed to download CRL for certificate from {url}: {error}',
137
                    ['url' => $url, 'error' => $e->getMessage()]);
138
                throw new VerificationException(sprintf('Failed to download CRL for certificate from %s: %s', $url, $e->getMessage()));
139
            }
140
            $crlData = $crlFile->getData();
141
        }
142
143
        $crl = new Crl($crlData, ...$caCertificates);
144
145
        if (!$item->isHit()) {
146
            $item->set([
147
                'nextUpdate' => $crl->getNextUpdate(),
148
                'data' => $crlData,
149
            ]);
150
            $item->expiresAt($crl->getNextUpdate());
151
            $this->cache->save($item);
152
        }
153
        return $crl;
154
    }
155
156
    /**
157
     * @return string[]
158
     */
159
    private function getCrlUrlList(Certificate $subject): array
160
    {
161
        try {
162
            $urls = [];
163
164
            $extensions = $subject->tbsCertificate()->extensions();
165
            if ($extensions->hasCRLDistributionPoints()) {
166
                $crlDists = $extensions->crlDistributionPoints();
167
                foreach ($crlDists->distributionPoints() as $dist) {
168
                    $url = $dist->fullName()->names()->firstURI();
169
                    $scheme = parse_url($url, PHP_URL_SCHEME);
170
                    if (!in_array($scheme, ['http', 'https'], true)) {
171
                        $this->logger->warning('Ignoring non-http CRL URI {url}.', ['url' => $url]);
172
                        continue;
173
                    }
174
                    $urls[] = $url;
175
                }
176
            }
177
            return $urls;
178
        } catch (Exception $e) {
179
            throw new VerificationException('Failed to get CRL distribution points from certificate: ' . $e->getMessage(), 0, $e);
180
        }
181
    }
182
}
183