Passed
Push — master ( 0010e9...022bc2 )
by Thomas
11:25
created

MetadataServiceProvider::getDescription()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 2
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace MadWizard\WebAuthn\Metadata\Provider;
4
5
use DateTimeImmutable;
6
use MadWizard\WebAuthn\Attestation\Identifier\IdentifierInterface;
7
use MadWizard\WebAuthn\Attestation\TrustAnchor\MetadataInterface;
8
use MadWizard\WebAuthn\Cache\CacheProviderInterface;
9
use MadWizard\WebAuthn\Exception\ParseException;
10
use MadWizard\WebAuthn\Exception\VerificationException;
11
use MadWizard\WebAuthn\Exception\WebAuthnException;
12
use MadWizard\WebAuthn\Format\Base64UrlEncoding;
13
use MadWizard\WebAuthn\Format\ByteBuffer;
14
use MadWizard\WebAuthn\Metadata\Source\MetadataServiceSource;
15
use MadWizard\WebAuthn\Metadata\Statement\MetadataStatement;
16
use MadWizard\WebAuthn\Metadata\Statement\MetadataToc;
17
use MadWizard\WebAuthn\Pki\ChainValidatorInterface;
18
use MadWizard\WebAuthn\Pki\Jwt\Jwt;
19
use MadWizard\WebAuthn\Pki\Jwt\JwtInterface;
20
use MadWizard\WebAuthn\Pki\Jwt\JwtValidator;
21
use MadWizard\WebAuthn\Pki\Jwt\ValidationContext;
22
use MadWizard\WebAuthn\Pki\Jwt\X5cParameterReader;
23
use MadWizard\WebAuthn\Pki\X509Certificate;
24
use MadWizard\WebAuthn\Remote\DownloaderInterface;
25
use MadWizard\WebAuthn\Server\Registration\RegistrationResultInterface;
26
use Psr\Cache\CacheItemPoolInterface;
27
use Psr\Log\LoggerAwareInterface;
28
use Psr\Log\LoggerAwareTrait;
29
use Psr\Log\NullLogger;
30
31
final class MetadataServiceProvider implements MetadataProviderInterface, LoggerAwareInterface
32
{
33
    use LoggerAwareTrait;
34
35
    /**
36
     * @var DownloaderInterface
37
     */
38
    private $downloader;
39
40
    /**
41
     * @var CacheItemPoolInterface
42
     */
43
    private $cachePool;
44
45
    /**
46
     * @var CacheProviderInterface
47
     */
48
    private $cacheProvider;
49
50
    /**
51
     * @var ChainValidatorInterface
52
     */
53
    private $chainValidator;
54
55
    /**
56
     * Cache time for specific metadata statements. Because caching of metadata statements is done by the hash of its
57
     * contents (which is also included in the TOC) in theory these items can be cached indefinitely because any update
58
     * would cause the hash in the TOC to change and trigger a new download.
59
     * Set to 1 day by default to prevent keeping stale data.
60
     */
61
    private const METADATA_HASH_CACHE_TTL = 86400;
62
63
    /**
64
     * @var MetadataServiceSource
65
     */
66
    private $mdsSource;
67
68
    public function __construct(
69
        MetadataServiceSource $mdsSource,
70
        DownloaderInterface $downloader,
71
        CacheProviderInterface $cacheProvider,
72
        ChainValidatorInterface $chainValidator
73
    ) {
74
        $this->mdsSource = $mdsSource;
75
        $this->cachePool = $cacheProvider->getCachePool('metadataService');
76
        $this->downloader = $downloader;
77
        $this->cacheProvider = $cacheProvider;
78
        $this->chainValidator = $chainValidator;
79
        $this->logger = new NullLogger();
80
    }
81
82
    private function getTokenUrl(string $url): string
83
    {
84
        $token = $this->mdsSource->getAccessToken();
85
        if ($token === null) {
86
            return $url;
87
        }
88
89
        // Only add token if host matches host of main TOC URL to prevent leaking token to other hosts.
90
        $mainHost = parse_url($this->mdsSource->getUrl(), PHP_URL_HOST);
91
        $host = parse_url($url, PHP_URL_HOST);
92
93
        if (strcasecmp($mainHost, $host) === 0) {
94
            return $url . '?' . http_build_query(['token' => $token]);
95
        }
96
        return $url;
97
    }
98
99
    public function getMetadata(IdentifierInterface $identifier, RegistrationResultInterface $registrationResult): ?MetadataInterface
100
    {
101
        $toc = $this->getCachedToc();
102
        $tocItem = $toc->findItem($identifier);
103
104
        $this->logger->debug('Searching MDS for identifier {id}.', ['id' => $identifier->toString()]);
105
106
        if ($tocItem === null) {
107
            return null;
108
        }
109
110
        $url = $tocItem->getUrl();
111
        $hash = $tocItem->getHash();
112
        if ($url === null || $hash === null) {
113
            return null;
114
        }
115
116
        $meta = $this->getMetadataItem($url, $hash);
117
        $meta->setStatusReports($tocItem->getStatusReports());
118
        return $meta;
119
    }
120
121
    private function getMetadataItem(string $url, ByteBuffer $hash): MetadataStatement    // TODO exception?
122
    {
123
        $item = $this->cachePool->getItem($hash->getHex());
124
125
        if ($item->isHit()) {
126
            $meta = $item->get();
127
        } else {
128
            $urlWithToken = $this->getTokenUrl($url);
129
            $meta = $this->downloader->downloadFile($urlWithToken);
130
            $fileHash = hash('sha256', $meta->getData(), true);
131
            if (!hash_equals($hash->getBinaryString(), $fileHash)) {
132
                throw new VerificationException(sprintf('Hash mismatch for url %s, ignoring metadata entry.', $url));
133
            }
134
135
            $item->set($meta);
136
            $item->expiresAfter(self::METADATA_HASH_CACHE_TTL);
137
            $this->cachePool->save($item);
138
        }
139
        return MetadataStatement::decodeString(Base64UrlEncoding::decode($meta->getData()));
140
    }
141
142
    private function getCachedToc()
143
    {
144
        $url = $this->getTokenUrl($this->mdsSource->getUrl());
145
        $urlHash = hash('sha256', $url);
146
        $item = $this->cachePool->getItem($urlHash);
147
148
        if ($item->isHit()) {
149
            $data = $item->get();
150
            if ($data instanceof MetadataToc) {
151
                if ($data->getNextUpdate() > new DateTimeImmutable()) {           // TODO: abstract time for unit tests
152
                    return $data;
153
                }
154
            }
155
        }
156
157
        $data = $this->downloadToc($url);
158
159
        if ($data !== null) {
160
            $item->set($data);
161
            $item->expiresAt($data->getNextUpdate());
162
            $this->cachePool->save($item);
163
        }
164
        return $data;
165
    }
166
167
    private function downloadToc($url): MetadataToc
168
    {
169
        $a = $this->downloader->downloadFile($url);
170
        if (!in_array(strtolower($a->getContentType()), ['application/octet-stream', 'application/jose'])) {
171
            throw new ParseException('Unexpected mime type.');
172
        }
173
174
        //error_log( md5($url) . " " . $url);
175
        //file_put_contents(__DIR__ . "/" . md5($url), $a->getData());
176
        $jwt = new Jwt($a->getData());
177
178
        $x5cParam = X5cParameterReader::getX5cParameter($jwt);
179
        if ($x5cParam === null) {
180
            throw new ParseException('MDS has no x5c certificate chain in header.');
181
        }
182
183
        $jwtValidator = new JwtValidator();
184
        $context = new ValidationContext(JwtInterface::ES_AND_RSA, $x5cParam->getCoseKey());
185
        try {
186
            $claims = $jwtValidator->validate($jwt, $context);
187
        } catch (WebAuthnException $e) {
188
            throw new VerificationException('Failed to verify JWT.', 0, $e);
189
        }
190
191
        if (!$this->chainValidator->validateChain(X509Certificate::fromPem($this->mdsSource->getRootCert()), ...array_reverse($x5cParam->getCertificates()))) {
192
            throw new VerificationException('Failed to verify x5c chain in JWT.');
193
        }
194
        return MetadataToc::fromJson($claims);
195
    }
196
197
    public function getDescription(): string
198
    {
199
        return sprintf('Metadata service url=%s', $this->mdsSource->getUrl());
200
    }
201
}
202