Completed
Push — master ( 37faaa...541bbf )
by Raffael
10:18 queued 06:30
created

HostManager::setOptions()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 23
ccs 0
cts 17
cp 0
rs 9.2408
c 0
b 0
f 0
cc 5
crap 30
nc 5
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * balloon
7
 *
8
 * @copyright   Copryright (c) 2012-2019 gyselroth GmbH (https://gyselroth.com)
9
 * @license     GPL-3.0 https://opensource.org/licenses/GPL-3.0
10
 */
11
12
namespace Balloon\App\Wopi;
13
14
use GuzzleHttp\ClientInterface as GuzzleHttpClientInterface;
15
use InvalidArgumentException;
16
use phpseclib\Crypt\RSA;
17
use phpseclib\Math\BigInteger;
18
use Psr\Log\LoggerInterface;
19
use Psr\SimpleCache\CacheInterface;
20
21
class HostManager
22
{
23
    /**
24
     * Discovery path.
25
     */
26
    public const DISCOVERY_PATH = '/hosting/discovery';
27
28
    /**
29
     * Hosts.
30
     *
31
     * @var array
32
     */
33
    protected $hosts = [];
34
35
    /**
36
     * Client url.
37
     *
38
     * @var string
39
     */
40
    protected $client_url;
41
42
    /**
43
     * HTTP client.
44
     *
45
     * @var GuzzleHttpClientInterface
46
     */
47
    protected $client;
48
49
    /**
50
     * Logger.
51
     *
52
     * @var LoggerInterface
53
     */
54
    protected $logger;
55
56
    /**
57
     * Cache.
58
     *
59
     * @var CacheInterface
60
     */
61
    protected $cache;
62
63
    /**
64
     * Cache ttl.
65
     *
66
     * @var int
67
     */
68
    protected $cache_ttl = 84600;
69
70
    /**
71
     * Validate proof.
72
     *
73
     * @var bool
74
     */
75
    protected $validate_proof = true;
76
77
    /**
78
     * Hosts.
79
     */
80
    public function __construct(GuzzleHttpClientInterface $client, CacheInterface $cache, LoggerInterface $logger, array $config = [])
81
    {
82
        $this->client = $client;
83
        $this->cache = $cache;
84
        $this->logger = $logger;
85
        $this->setOptions($config);
86
    }
87
88
    /**
89
     * Set options.
90
     */
91
    public function setOptions(array $config = []): HostManager
92
    {
93
        foreach ($config as $option => $value) {
94
            switch ($option) {
95
                case 'hosts':
96
                    $this->hosts = (array) $value;
97
98
                    break;
99
                case 'cache_ttl':
100
                    $this->cache_ttl = (int) $value;
101
102
                break;
103
                case 'validate_proof':
104
                    $this->validate_proof = (bool) $value;
105
106
                break;
107
                default:
108
                    throw new InvalidArgumentexception('invalid option '.$option.' given');
109
            }
110
        }
111
112
        return $this;
113
    }
114
115
    /**
116
     * Get session by id.
117
     */
118
    public function getHosts(): array
119
    {
120
        $result = [];
121
122
        foreach ($this->hosts as $url) {
123
            if (!isset($url['url']) || !isset($url['name']) || !isset($url['wopi_url'])) {
124
                $this->logger->error('skip wopi host entry, either name, wopi_url or url not set', [
125
                    'category' => get_class($this),
126
                ]);
127
128
                continue;
129
            }
130
131
            try {
132
                $discovery = $this->fetchDiscovery($url['url']);
133
134
                if (isset($url['replace']['to'], $url['replace']['from'])) {
135
                    if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
136
                        $url['replace']['to'] = str_replace('{protocol}', $_SERVER['HTTP_X_FORWARDED_PROTO'], $url['replace']['to']);
137
                    }
138
139
                    if (isset($_SERVER['HTTP_X_FORWARDED_HOST'])) {
140
                        $url['replace']['to'] = str_replace('{host}', $_SERVER['HTTP_X_FORWARDED_HOST'], $url['replace']['to']);
141
                    }
142
143
                    $discovery = preg_replace($url['replace']['from'], $url['replace']['to'], $discovery);
144
                }
145
146
                $discovery = json_decode($discovery, true, 512, JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
147
                $result[] = [
148
                    'url' => $url['url'],
149
                    'name' => $url['name'],
150
                    'wopi_url' => $url['wopi_url'],
151
                    'discovery' => $discovery,
152
                ];
153
            } catch (\Exception $e) {
154
                $this->logger->error('failed to fetch wopi discovery document [{url}]', [
155
                    'category' => get_class($this),
156
                    'url' => $url,
157
                    'exception' => $e,
158
                ]);
159
            }
160
        }
161
162
        return $result;
163
    }
164
165
    /**
166
     * Verify wopi proof key.
167
     *
168
     * @see https://wopi.readthedocs.io/en/latest/scenarios/proofkeys.html
169
     * @see https://github.com/microsoft/Office-Online-Test-Tools-and-Documentation/blob/master/samples/python/proof_keys/__init__.py
170
     */
171
    public function verifyWopiProof(array $data): bool
172
    {
173
        if ($this->validate_proof === false) {
174
            $this->logger->debug('skip wopi proof validation, validate_proof is disabled', [
175
                'category' => get_class($this),
176
            ]);
177
178
            return true;
179
        }
180
181
        foreach ($this->getHosts() as $host) {
182
            if (!isset($host['discovery']['proof-key']['@attributes']['modulus'])) {
183
                $this->logger->debug('skip wopi proof validation, no public keys for wopi host [{host}] provided', [
184
                    'category' => get_class($this),
185
                    'host' => $host['url'],
186
                ]);
187
188
                continue;
189
            }
190
191
            $this->logger->debug('start wopi proof validation for host [{host}]', [
192
                'category' => get_class($this),
193
                'host' => $host['url'],
194
                'data' => $data,
195
            ]);
196
197
            $keys = $host['discovery']['proof-key']['@attributes'];
198
            $pub_key_old = new RSA();
199
            $pub_key_old->setSignatureMode(RSA::SIGNATURE_PKCS1);
200
            $pub_key_old->setHash('sha256');
201
            $key_old = [
202
                'n' => new BigInteger(base64_decode($keys['oldmodulus']), 256),
203
                'e' => new BigInteger(base64_decode($keys['oldexponent']), 256),
204
            ];
205
            $pub_key_old->loadKey($key_old);
206
207
            $pub_key = new RSA();
208
            $pub_key->setSignatureMode(RSA::SIGNATURE_PKCS1);
209
            $pub_key->setHash('sha256');
210
            $key = [
211
                'n' => new BigInteger(base64_decode($keys['modulus']), 256),
212
                'e' => new BigInteger(base64_decode($keys['exponent']), 256),
213
            ];
214
215
            $pub_key->loadKey($key);
216
217
            //php string is already a byte array
218
            $token_bytes = $data['access-token'];
219
            //pack number of token bytes into 32 bit, big endian byte order => 4bytes
220
            $token_length_bytes = pack('N*', strlen($token_bytes));
221
            //php string is already a byte array, specs require url all upper case
222
            $url_bytes = strtoupper($data['host-url']);
223
            //pack number of url bytes into 32 bit, big endian byte order => 4bytes
224
            $url_length_bytes = pack('N*', strlen($url_bytes));
225
            //pack timestamp into 64 bit, big endian byte order => 8bytes
226
            $timestamp_bytes = pack('J*', (int) $data['timestamp']);
227
            //pack number of url bytes into 32 bit, big endian byte order => 4bytes
228
            $timestamp_length_bytes = pack('N*', strlen($timestamp_bytes));
229
230
            $expected = implode('', [
231
                $token_length_bytes,
232
                $token_bytes,
233
                $url_length_bytes,
234
                $url_bytes,
235
                $timestamp_length_bytes,
236
                $timestamp_bytes,
237
            ]);
238
239
            if ($pub_key->verify($expected, base64_decode($data['proof'])) ||
240
              $pub_key->verify($expected, base64_decode($data['proof-old'])) ||
241
              $pub_key_old->verify($expected, base64_decode($data['proof']))) {
242
                $this->logger->debug('wopi proof signature matches', [
243
                    'category' => get_class($this),
244
                ]);
245
246
                return true;
247
            }
248
        }
249
250
        throw new Exception\WopiProofValidationFailed('wopi signature validation failed');
251
    }
252
253
    /**
254
     * Fetch discovery.
255
     */
256
    protected function fetchDiscovery(string $url): string
257
    {
258
        $discovery = $url.self::DISCOVERY_PATH;
259
260
        $key = md5($discovery);
261
262
        if ($this->cache->has($key)) {
263
            return $this->cache->get($key);
264
        }
265
266
        $this->logger->debug('wopi discovery not found in cache, fetch wopi discovery [{url}]', [
267
            'category' => get_class($this),
268
            'url' => $url,
269
        ]);
270
271
        $response = $this->client->request(
272
            'GET',
273
            $discovery
274
        );
275
276
        $body = $response->getBody()->getContents();
277
        $body = json_encode(simplexml_load_string($body), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
278
        $result = $this->cache->set($key, $body, $this->cache_ttl);
279
280
        $this->logger->debug('stored wopi discovery [{url}] in cache for [{ttl}]s', [
281
            'category' => get_class($this),
282
            'url' => $discovery,
283
            'ttl' => $this->cache_ttl,
284
            'result' => $result,
285
        ]);
286
287
        return $body;
288
    }
289
}
290