JWEBuilder::getEncryptedKey()   B
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 8.9777
c 0
b 0
f 0
cc 6
nc 6
nop 6
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * The MIT License (MIT)
7
 *
8
 * Copyright (c) 2014-2019 Spomky-Labs
9
 *
10
 * This software may be modified and distributed under the terms
11
 * of the MIT license.  See the LICENSE file for details.
12
 */
13
14
namespace Jose\Component\Encryption;
15
16
use Base64Url\Base64Url;
17
use InvalidArgumentException;
18
use Jose\Component\Core\AlgorithmManager;
19
use Jose\Component\Core\JWK;
20
use Jose\Component\Core\Util\JsonConverter;
21
use Jose\Component\Core\Util\KeyChecker;
22
use Jose\Component\Encryption\Algorithm\ContentEncryptionAlgorithm;
23
use Jose\Component\Encryption\Algorithm\KeyEncryption\DirectEncryption;
24
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyAgreement;
25
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyAgreementWithKeyWrapping;
26
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyEncryption;
27
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyWrapping;
28
use Jose\Component\Encryption\Algorithm\KeyEncryptionAlgorithm;
29
use Jose\Component\Encryption\Compression\CompressionMethod;
30
use Jose\Component\Encryption\Compression\CompressionMethodManager;
31
use LogicException;
32
use RuntimeException;
33
34
class JWEBuilder
35
{
36
    /**
37
     * @var null|string
38
     */
39
    protected $payload;
40
41
    /**
42
     * @var null|string
43
     */
44
    protected $aad;
45
46
    /**
47
     * @var array
48
     */
49
    protected $recipients = [];
50
51
    /**
52
     * @var array
53
     */
54
    protected $sharedProtectedHeader = [];
55
56
    /**
57
     * @var array
58
     */
59
    protected $sharedHeader = [];
60
61
    /**
62
     * @var AlgorithmManager
63
     */
64
    private $keyEncryptionAlgorithmManager;
65
66
    /**
67
     * @var AlgorithmManager
68
     */
69
    private $contentEncryptionAlgorithmManager;
70
71
    /**
72
     * @var CompressionMethodManager
73
     */
74
    private $compressionManager;
75
76
    /**
77
     * @var null|CompressionMethod
78
     */
79
    private $compressionMethod;
80
81
    /**
82
     * @var null|ContentEncryptionAlgorithm
83
     */
84
    private $contentEncryptionAlgorithm;
85
86
    /**
87
     * @var null|string
88
     */
89
    private $keyManagementMode;
90
91
    public function __construct(AlgorithmManager $keyEncryptionAlgorithmManager, AlgorithmManager $contentEncryptionAlgorithmManager, CompressionMethodManager $compressionManager)
92
    {
93
        $this->keyEncryptionAlgorithmManager = $keyEncryptionAlgorithmManager;
94
        $this->contentEncryptionAlgorithmManager = $contentEncryptionAlgorithmManager;
95
        $this->compressionManager = $compressionManager;
96
    }
97
98
    /**
99
     * Reset the current data.
100
     *
101
     * @return JWEBuilder
102
     */
103
    public function create(): self
104
    {
105
        $this->payload = null;
106
        $this->aad = null;
107
        $this->recipients = [];
108
        $this->sharedProtectedHeader = [];
109
        $this->sharedHeader = [];
110
        $this->compressionMethod = null;
111
        $this->contentEncryptionAlgorithm = null;
112
        $this->keyManagementMode = null;
113
114
        return $this;
115
    }
116
117
    /**
118
     * Returns the key encryption algorithm manager.
119
     */
120
    public function getKeyEncryptionAlgorithmManager(): AlgorithmManager
121
    {
122
        return $this->keyEncryptionAlgorithmManager;
123
    }
124
125
    /**
126
     * Returns the content encryption algorithm manager.
127
     */
128
    public function getContentEncryptionAlgorithmManager(): AlgorithmManager
129
    {
130
        return $this->contentEncryptionAlgorithmManager;
131
    }
132
133
    /**
134
     * Returns the compression method manager.
135
     */
136
    public function getCompressionMethodManager(): CompressionMethodManager
137
    {
138
        return $this->compressionManager;
139
    }
140
141
    /**
142
     * Set the payload of the JWE to build.
143
     *
144
     * @throws InvalidArgumentException if the payload is not encoded in UTF-8
145
     *
146
     * @return JWEBuilder
147
     */
148
    public function withPayload(string $payload): self
149
    {
150
        if ('UTF-8' !== mb_detect_encoding($payload, 'UTF-8', true)) {
151
            throw new InvalidArgumentException('The payload must be encoded in UTF-8');
152
        }
153
        $clone = clone $this;
154
        $clone->payload = $payload;
155
156
        return $clone;
157
    }
158
159
    /**
160
     * Set the Additional Authenticated Data of the JWE to build.
161
     *
162
     * @return JWEBuilder
163
     */
164
    public function withAAD(?string $aad): self
165
    {
166
        $clone = clone $this;
167
        $clone->aad = $aad;
168
169
        return $clone;
170
    }
171
172
    /**
173
     * Set the shared protected header of the JWE to build.
174
     *
175
     * @return JWEBuilder
176
     */
177
    public function withSharedProtectedHeader(array $sharedProtectedHeader): self
178
    {
179
        $this->checkDuplicatedHeaderParameters($sharedProtectedHeader, $this->sharedHeader);
180
        foreach ($this->recipients as $recipient) {
181
            $this->checkDuplicatedHeaderParameters($sharedProtectedHeader, $recipient->getHeader());
182
        }
183
        $clone = clone $this;
184
        $clone->sharedProtectedHeader = $sharedProtectedHeader;
185
186
        return $clone;
187
    }
188
189
    /**
190
     * Set the shared header of the JWE to build.
191
     *
192
     * @return JWEBuilder
193
     */
194
    public function withSharedHeader(array $sharedHeader): self
195
    {
196
        $this->checkDuplicatedHeaderParameters($this->sharedProtectedHeader, $sharedHeader);
197
        foreach ($this->recipients as $recipient) {
198
            $this->checkDuplicatedHeaderParameters($sharedHeader, $recipient->getHeader());
199
        }
200
        $clone = clone $this;
201
        $clone->sharedHeader = $sharedHeader;
202
203
        return $clone;
204
    }
205
206
    /**
207
     * Adds a recipient to the JWE to build.
208
     *
209
     * @throws InvalidArgumentException if key management modes are incompatible
210
     * @throws InvalidArgumentException if the compression method is invalid
211
     *
212
     * @return JWEBuilder
213
     */
214
    public function addRecipient(JWK $recipientKey, array $recipientHeader = []): self
215
    {
216
        $this->checkDuplicatedHeaderParameters($this->sharedProtectedHeader, $recipientHeader);
217
        $this->checkDuplicatedHeaderParameters($this->sharedHeader, $recipientHeader);
218
        $clone = clone $this;
219
        $completeHeader = array_merge($clone->sharedHeader, $recipientHeader, $clone->sharedProtectedHeader);
220
        $clone->checkAndSetContentEncryptionAlgorithm($completeHeader);
221
        $keyEncryptionAlgorithm = $clone->getKeyEncryptionAlgorithm($completeHeader);
222
        if (null === $clone->keyManagementMode) {
223
            $clone->keyManagementMode = $keyEncryptionAlgorithm->getKeyManagementMode();
224
        } else {
225
            if (!$clone->areKeyManagementModesCompatible($clone->keyManagementMode, $keyEncryptionAlgorithm->getKeyManagementMode())) {
226
                throw new InvalidArgumentException('Foreign key management mode forbidden.');
227
            }
228
        }
229
230
        $compressionMethod = $clone->getCompressionMethod($completeHeader);
231
        if (null !== $compressionMethod) {
232
            if (null === $clone->compressionMethod) {
233
                $clone->compressionMethod = $compressionMethod;
234
            } elseif ($clone->compressionMethod->name() !== $compressionMethod->name()) {
235
                throw new InvalidArgumentException('Incompatible compression method.');
236
            }
237
        }
238
        if (null === $compressionMethod && null !== $clone->compressionMethod) {
239
            throw new InvalidArgumentException('Inconsistent compression method.');
240
        }
241
        $clone->checkKey($keyEncryptionAlgorithm, $recipientKey);
242
        $clone->recipients[] = [
243
            'key' => $recipientKey,
244
            'header' => $recipientHeader,
245
            'key_encryption_algorithm' => $keyEncryptionAlgorithm,
246
        ];
247
248
        return $clone;
249
    }
250
251
    /**
252
     * Builds the JWE.
253
     *
254
     * @throws LogicException if no payload is set
255
     * @throws LogicException if there are no recipient
256
     */
257
    public function build(): JWE
258
    {
259
        if (null === $this->payload) {
260
            throw new LogicException('Payload not set.');
261
        }
262
        if (0 === \count($this->recipients)) {
263
            throw new LogicException('No recipient.');
264
        }
265
266
        $additionalHeader = [];
267
        $cek = $this->determineCEK($additionalHeader);
268
269
        $recipients = [];
270
        foreach ($this->recipients as $recipient) {
271
            $recipient = $this->processRecipient($recipient, $cek, $additionalHeader);
272
            $recipients[] = $recipient;
273
        }
274
275
        if (0 !== \count($additionalHeader) && 1 === \count($this->recipients)) {
276
            $sharedProtectedHeader = array_merge($additionalHeader, $this->sharedProtectedHeader);
277
        } else {
278
            $sharedProtectedHeader = $this->sharedProtectedHeader;
279
        }
280
        $encodedSharedProtectedHeader = 0 === \count($sharedProtectedHeader) ? '' : Base64Url::encode(JsonConverter::encode($sharedProtectedHeader));
281
282
        list($ciphertext, $iv, $tag) = $this->encryptJWE($cek, $encodedSharedProtectedHeader);
283
284
        return new JWE($ciphertext, $iv, $tag, $this->aad, $this->sharedHeader, $sharedProtectedHeader, $encodedSharedProtectedHeader, $recipients);
285
    }
286
287
    /**
288
     * @throws InvalidArgumentException if the content encryption algorithm is not valid
289
     */
290
    private function checkAndSetContentEncryptionAlgorithm(array $completeHeader): void
291
    {
292
        $contentEncryptionAlgorithm = $this->getContentEncryptionAlgorithm($completeHeader);
293
        if (null === $this->contentEncryptionAlgorithm) {
294
            $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
295
        } elseif ($contentEncryptionAlgorithm->name() !== $this->contentEncryptionAlgorithm->name()) {
296
            throw new InvalidArgumentException('Inconsistent content encryption algorithm');
297
        }
298
    }
299
300
    /**
301
     * @throws InvalidArgumentException if the key encryption algorithm is not valid
302
     */
303
    private function processRecipient(array $recipient, string $cek, array &$additionalHeader): Recipient
304
    {
305
        $completeHeader = array_merge($this->sharedHeader, $recipient['header'], $this->sharedProtectedHeader);
306
        $keyEncryptionAlgorithm = $recipient['key_encryption_algorithm'];
307
        if (!$keyEncryptionAlgorithm instanceof KeyEncryptionAlgorithm) {
308
            throw new InvalidArgumentException('The key encryption algorithm is not valid');
309
        }
310
        $encryptedContentEncryptionKey = $this->getEncryptedKey($completeHeader, $cek, $keyEncryptionAlgorithm, $additionalHeader, $recipient['key'], $recipient['sender_key'] ?? null);
311
        $recipientHeader = $recipient['header'];
312
        if (0 !== \count($additionalHeader) && 1 !== \count($this->recipients)) {
313
            $recipientHeader = array_merge($recipientHeader, $additionalHeader);
314
            $additionalHeader = [];
315
        }
316
317
        return new Recipient($recipientHeader, $encryptedContentEncryptionKey);
318
    }
319
320
    /**
321
     * @throws InvalidArgumentException if the content encryption algorithm is not valid
322
     */
323
    private function encryptJWE(string $cek, string $encodedSharedProtectedHeader): array
324
    {
325
        if (!$this->contentEncryptionAlgorithm instanceof ContentEncryptionAlgorithm) {
326
            throw new InvalidArgumentException('The content encryption algorithm is not valid');
327
        }
328
        $iv_size = $this->contentEncryptionAlgorithm->getIVSize();
329
        $iv = $this->createIV($iv_size);
330
        $payload = $this->preparePayload();
331
        $tag = null;
332
        $ciphertext = $this->contentEncryptionAlgorithm->encryptContent($payload, $cek, $iv, $this->aad, $encodedSharedProtectedHeader, $tag);
333
334
        return [$ciphertext, $iv, $tag];
335
    }
336
337
    /**
338
     * @return string
339
     */
340
    private function preparePayload(): ?string
341
    {
342
        $prepared = $this->payload;
343
        if (null === $this->compressionMethod) {
344
            return $prepared;
345
        }
346
347
        return $this->compressionMethod->compress($prepared);
348
    }
349
350
    /**
351
     * @throws InvalidArgumentException if the key encryption algorithm is not supported
352
     */
353
    private function getEncryptedKey(array $completeHeader, string $cek, KeyEncryptionAlgorithm $keyEncryptionAlgorithm, array &$additionalHeader, JWK $recipientKey, ?JWK $senderKey): ?string
354
    {
355
        if ($keyEncryptionAlgorithm instanceof KeyEncryption) {
356
            return $this->getEncryptedKeyFromKeyEncryptionAlgorithm($completeHeader, $cek, $keyEncryptionAlgorithm, $recipientKey, $additionalHeader);
357
        }
358
        if ($keyEncryptionAlgorithm instanceof KeyWrapping) {
359
            return $this->getEncryptedKeyFromKeyWrappingAlgorithm($completeHeader, $cek, $keyEncryptionAlgorithm, $recipientKey, $additionalHeader);
360
        }
361
        if ($keyEncryptionAlgorithm instanceof KeyAgreementWithKeyWrapping) {
362
            return $this->getEncryptedKeyFromKeyAgreementAndKeyWrappingAlgorithm($completeHeader, $cek, $keyEncryptionAlgorithm, $additionalHeader, $recipientKey, $senderKey);
363
        }
364
        if ($keyEncryptionAlgorithm instanceof KeyAgreement) {
365
            return null;
366
        }
367
        if ($keyEncryptionAlgorithm instanceof DirectEncryption) {
368
            return null;
369
        }
370
371
        throw new InvalidArgumentException('Unsupported key encryption algorithm.');
372
    }
373
374
    /**
375
     * @throws InvalidArgumentException if the content encryption algorithm is invalid
376
     */
377
    private function getEncryptedKeyFromKeyAgreementAndKeyWrappingAlgorithm(array $completeHeader, string $cek, KeyAgreementWithKeyWrapping $keyEncryptionAlgorithm, array &$additionalHeader, JWK $recipientKey, ?JWK $senderKey): string
378
    {
379
        if (null === $this->contentEncryptionAlgorithm) {
380
            throw new InvalidArgumentException('Invalid content encryption algorithm');
381
        }
382
383
        return $keyEncryptionAlgorithm->wrapAgreementKey($recipientKey, $senderKey, $cek, $this->contentEncryptionAlgorithm->getCEKSize(), $completeHeader, $additionalHeader);
384
    }
385
386
    private function getEncryptedKeyFromKeyEncryptionAlgorithm(array $completeHeader, string $cek, KeyEncryption $keyEncryptionAlgorithm, JWK $recipientKey, array &$additionalHeader): string
387
    {
388
        return $keyEncryptionAlgorithm->encryptKey($recipientKey, $cek, $completeHeader, $additionalHeader);
389
    }
390
391
    private function getEncryptedKeyFromKeyWrappingAlgorithm(array $completeHeader, string $cek, KeyWrapping $keyEncryptionAlgorithm, JWK $recipientKey, array &$additionalHeader): string
392
    {
393
        return $keyEncryptionAlgorithm->wrapKey($recipientKey, $cek, $completeHeader, $additionalHeader);
394
    }
395
396
    /**
397
     * @throws InvalidArgumentException if the content encryption algorithm is invalid
398
     * @throws InvalidArgumentException if the key type is not valid
399
     * @throws InvalidArgumentException if the key management mode is not supported
400
     */
401
    private function checkKey(KeyEncryptionAlgorithm $keyEncryptionAlgorithm, JWK $recipientKey): void
402
    {
403
        if (null === $this->contentEncryptionAlgorithm) {
404
            throw new InvalidArgumentException('Invalid content encryption algorithm');
405
        }
406
407
        KeyChecker::checkKeyUsage($recipientKey, 'encryption');
408
        if ('dir' !== $keyEncryptionAlgorithm->name()) {
409
            KeyChecker::checkKeyAlgorithm($recipientKey, $keyEncryptionAlgorithm->name());
410
        } else {
411
            KeyChecker::checkKeyAlgorithm($recipientKey, $this->contentEncryptionAlgorithm->name());
412
        }
413
    }
414
415
    private function determineCEK(array &$additionalHeader): string
416
    {
417
        if (null === $this->contentEncryptionAlgorithm) {
418
            throw new InvalidArgumentException('Invalid content encryption algorithm');
419
        }
420
421
        switch ($this->keyManagementMode) {
422
            case KeyEncryption::MODE_ENCRYPT:
423
            case KeyEncryption::MODE_WRAP:
424
                return $this->createCEK($this->contentEncryptionAlgorithm->getCEKSize());
425
            case KeyEncryption::MODE_AGREEMENT:
426
                if (1 !== \count($this->recipients)) {
427
                    throw new LogicException('Unable to encrypt for multiple recipients using key agreement algorithms.');
428
                }
429
                /** @var JWK $key */
430
                $recipientKey = $this->recipients[0]['key'];
431
                $senderKey = $this->recipients[0]['sender_key'] ?? null;
432
                $algorithm = $this->recipients[0]['key_encryption_algorithm'];
433
                if (!$algorithm instanceof KeyAgreement) {
434
                    throw new InvalidArgumentException('Invalid content encryption algorithm');
435
                }
436
                $completeHeader = array_merge($this->sharedHeader, $this->recipients[0]['header'], $this->sharedProtectedHeader);
437
438
                return $algorithm->getAgreementKey($this->contentEncryptionAlgorithm->getCEKSize(), $this->contentEncryptionAlgorithm->name(), $recipientKey, $senderKey, $completeHeader, $additionalHeader);
439
            case KeyEncryption::MODE_DIRECT:
440
                if (1 !== \count($this->recipients)) {
441
                    throw new LogicException('Unable to encrypt for multiple recipients using key agreement algorithms.');
442
                }
443
                /** @var JWK $key */
444
                $key = $this->recipients[0]['key'];
445
                if ('oct' !== $key->get('kty')) {
446
                    throw new RuntimeException('Wrong key type.');
447
                }
448
449
                return Base64Url::decode($key->get('k'));
450
            default:
451
                throw new InvalidArgumentException(sprintf('Unsupported key management mode "%s".', $this->keyManagementMode));
452
        }
453
    }
454
455
    private function getCompressionMethod(array $completeHeader): ?CompressionMethod
456
    {
457
        if (!\array_key_exists('zip', $completeHeader)) {
458
            return null;
459
        }
460
461
        return $this->compressionManager->get($completeHeader['zip']);
462
    }
463
464
    private function areKeyManagementModesCompatible(string $current, string $new): bool
465
    {
466
        $agree = KeyEncryptionAlgorithm::MODE_AGREEMENT;
467
        $dir = KeyEncryptionAlgorithm::MODE_DIRECT;
468
        $enc = KeyEncryptionAlgorithm::MODE_ENCRYPT;
469
        $wrap = KeyEncryptionAlgorithm::MODE_WRAP;
470
        $supportedKeyManagementModeCombinations = [$enc.$enc => true, $enc.$wrap => true, $wrap.$enc => true, $wrap.$wrap => true, $agree.$agree => false, $agree.$dir => false, $agree.$enc => false, $agree.$wrap => false, $dir.$agree => false, $dir.$dir => false, $dir.$enc => false, $dir.$wrap => false, $enc.$agree => false, $enc.$dir => false, $wrap.$agree => false, $wrap.$dir => false];
471
472
        if (\array_key_exists($current.$new, $supportedKeyManagementModeCombinations)) {
473
            return $supportedKeyManagementModeCombinations[$current.$new];
474
        }
475
476
        return false;
477
    }
478
479
    private function createCEK(int $size): string
480
    {
481
        return random_bytes($size / 8);
482
    }
483
484
    private function createIV(int $size): string
485
    {
486
        return random_bytes($size / 8);
487
    }
488
489
    /**
490
     * @throws InvalidArgumentException if the header parameter "alg" is missing
491
     * @throws InvalidArgumentException if the header parameter "alg" is not supported or not a key encryption algorithm
492
     */
493
    private function getKeyEncryptionAlgorithm(array $completeHeader): KeyEncryptionAlgorithm
494
    {
495
        if (!isset($completeHeader['alg'])) {
496
            throw new InvalidArgumentException('Parameter "alg" is missing.');
497
        }
498
        $keyEncryptionAlgorithm = $this->keyEncryptionAlgorithmManager->get($completeHeader['alg']);
499
        if (!$keyEncryptionAlgorithm instanceof KeyEncryptionAlgorithm) {
500
            throw new InvalidArgumentException(sprintf('The key encryption algorithm "%s" is not supported or not a key encryption algorithm instance.', $completeHeader['alg']));
501
        }
502
503
        return $keyEncryptionAlgorithm;
504
    }
505
506
    /**
507
     * @throws InvalidArgumentException if the header parameter "enc" is missing
508
     * @throws InvalidArgumentException if the header parameter "enc" is not supported or not a content encryption algorithm
509
     */
510
    private function getContentEncryptionAlgorithm(array $completeHeader): ContentEncryptionAlgorithm
511
    {
512
        if (!isset($completeHeader['enc'])) {
513
            throw new InvalidArgumentException('Parameter "enc" is missing.');
514
        }
515
        $contentEncryptionAlgorithm = $this->contentEncryptionAlgorithmManager->get($completeHeader['enc']);
516
        if (!$contentEncryptionAlgorithm instanceof ContentEncryptionAlgorithm) {
517
            throw new InvalidArgumentException(sprintf('The content encryption algorithm "%s" is not supported or not a content encryption algorithm instance.', $completeHeader['enc']));
518
        }
519
520
        return $contentEncryptionAlgorithm;
521
    }
522
523
    /**
524
     * @throws InvalidArgumentException if the header contains duplicated entries
525
     */
526
    private function checkDuplicatedHeaderParameters(array $header1, array $header2): void
527
    {
528
        $inter = array_intersect_key($header1, $header2);
529
        if (0 !== \count($inter)) {
530
            throw new InvalidArgumentException(sprintf('The header contains duplicated entries: %s.', implode(', ', array_keys($inter))));
531
        }
532
    }
533
}
534