PNEncryption::encrypt()   B
last analyzed

Complexity

Conditions 8
Paths 21

Size

Total Lines 71
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 8
eloc 33
c 1
b 1
f 0
nc 21
nop 1
dl 0
loc 71
rs 8.1475

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\PNServer;
5
6
use SKien\PNServer\Utils\NistCurve;
7
8
/**
9
 * Class to perform the encryption of the payload.
10
 *
11
 * parts of the code are based on the web-push-php package contributetd by
12
 * Louis Lagrange / Minishlink <br/>
13
 *  https://github.com/web-push-libs/web-push-php <br/>
14
 * and from package spomky-labs/jose <br/>
15
 *  https://github.com/Spomky-Labs/Jose <br/>
16
 *
17
 * thanks to Matt Gaunt and Mat Scale <br/>
18
 *  https://web-push-book.gauntface.com/downloads/web-push-book.pdf <br/>
19
 *  https://developers.google.com/web/updates/2016/03/web-push-encryption <br/>
20
 *
21
 * @package PNServer
22
 * @author Stefanius <[email protected]>
23
 * @copyright MIT License - see the LICENSE file for details
24
 */
25
class PNEncryption
26
{
27
    use PNServerHelper;
28
29
    /** max length of the payload                   */
30
    const MAX_PAYLOAD_LENGTH = 4078;
31
    /** max compatible length of the payload        */
32
    const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052;
33
34
    /** @var string public key from subscription        */
35
    protected string $strSubscrKey = '';
36
    /** @var string subscription authenthication code   */
37
    protected string $strSubscrAuth = '';
38
    /** @var string encoding 'aesgcm' / 'aes128gcm'     */
39
    protected string $strEncoding = '';
40
    /** @var string payload to encrypt                  */
41
    protected string $strPayload = '';
42
    /** @var string local generated public key          */
43
    protected string $strLocalPublicKey = '';
44
    /** @var \GMP   local generated private key         */
45
    protected \GMP $gmpLocalPrivateKey;
46
    /** @var string generated salt                      */
47
    protected string $strSalt = '';
48
    /** @var string last error msg                          */
49
    protected string $strError = '';
50
51
    /**
52
     * @param string $strSubscrKey      public key from subscription
53
     * @param string $strSubscrAuth     subscription authenthication code
54
     * @param string $strEncoding       encoding (default: 'aesgcm')
55
     */
56
    public function __construct(string $strSubscrKey, string $strSubscrAuth, string $strEncoding = 'aesgcm')
57
    {
58
        $this->strSubscrKey = self::decodeBase64URL($strSubscrKey);
59
        $this->strSubscrAuth = self::decodeBase64URL($strSubscrAuth);
60
        $this->strEncoding = $strEncoding;
61
        $this->strError = '';
62
    }
63
64
    /**
65
     * encrypt the payload.
66
     * @param string $strPayload
67
     * @return string|false encrypted string at success, false on any error
68
     */
69
    public function encrypt(string $strPayload)
70
    {
71
        $this->strError = '';
72
        $this->strPayload = $strPayload;
73
        $strContent = false;
74
75
        // there's nothing to encrypt without payload...
76
        if (strlen($strPayload) == 0) {
77
            // it's OK - just set content-length of request to 0!
78
            return '';
79
        }
80
81
        if ($this->strEncoding !== 'aesgcm' && $this->strEncoding !== 'aes128gcm') {
82
            $this->strError = "Encoding '" . $this->strEncoding . "' is not supported!";
83
            return false;
84
        }
85
86
        if (mb_strlen($this->strSubscrKey, '8bit') !== 65) {
87
            $this->strError = "Invalid client public key length!";
88
            return false;
89
        }
90
91
        try {
92
            // create random salt and local key pair
93
            $this->strSalt = \random_bytes(16);
94
            if (!$this->createLocalKey()) {
95
                return false;
96
            }
97
98
            // create shared secret between local private key and public subscription key
99
            $strSharedSecret = $this->getSharedSecret();
100
101
            // context and pseudo random key (PRK) to create content encryption key (CEK) and nonce
102
            /*
103
             * A nonce is a value that prevents replay attacks as it should only be used once.
104
             * The content encryption key (CEK) is the key that will ultimately be used toencrypt
105
             * our payload.
106
             * @link https://en.wikipedia.org/wiki/Cryptographic_nonce
107
             */
108
            $context = $this->createContext();
109
            $prk = $this->getPRK($strSharedSecret);
110
111
            // derive the encryption key
112
            $cekInfo = $this->createInfo($this->strEncoding, $context);
113
            $cek = self::hkdf($this->strSalt, $prk, $cekInfo, 16);
114
115
            // and the nonce
116
            $nonceInfo = $this->createInfo('nonce', $context);
117
            $nonce = self::hkdf($this->strSalt, $prk, $nonceInfo, 12);
118
119
            // pad payload ... from now payload converted to binary string
120
            $strPayload = $this->padPayload($strPayload, self::MAX_COMPATIBILITY_PAYLOAD_LENGTH);
121
122
            // encrypt
123
            // "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
124
            $strTag = '';
125
            $strEncrypted = openssl_encrypt($strPayload, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $strTag);
126
127
            // base64URL encode salt and local public key (for aes128gcm they are needed in binary form)
128
            if ($this->strEncoding === 'aesgcm') {
129
                $this->strSalt = self::encodeBase64URL($this->strSalt);
130
                $this->strLocalPublicKey = self::encodeBase64URL($this->strLocalPublicKey);
131
            }
132
133
            $strContent = $this->getContentCodingHeader() . $strEncrypted . $strTag;
134
        } catch (\RuntimeException $e) {
135
            $this->strError = $e->getMessage();
136
            $strContent = false;
137
        }
138
139
        return $strContent;
140
    }
141
142
    /**
143
     * Get headers for previous encrypted payload.
144
     * Already existing headers (e.g. the VAPID-signature) can be passed through the input param
145
     * and will be merged with the additional headers for the encryption
146
     *
147
     * @param array<string,string> $aHeaders   existing headers to merge with
148
     * @return array<string,string>
149
     */
150
    public function getHeaders(?array $aHeaders = null) : array
151
    {
152
        if (!$aHeaders) {
153
            $aHeaders = array();
154
        }
155
        if (strlen($this->strPayload) > 0) {
156
            $aHeaders['Content-Type'] = 'application/octet-stream';
157
            $aHeaders['Content-Encoding'] = $this->strEncoding;
158
            if ($this->strEncoding === "aesgcm") {
159
                $aHeaders['Encryption'] = 'salt=' . $this->strSalt;
160
                if (isset($aHeaders['Crypto-Key'])) {
161
                    $aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey . ';' . $aHeaders['Crypto-Key'];
162
                } else {
163
                    $aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey;
164
                }
165
            }
166
        }
167
        return $aHeaders;
168
    }
169
170
    /**
171
     * @return string last error
172
     */
173
    public function getError() : string
174
    {
175
        return $this->strError;
176
    }
177
178
    /**
179
     * create local public/private key pair using prime256v1 curve
180
     * @return bool
181
     */
182
    private function createLocalKey() : bool
183
    {
184
        $bSucceeded = false;
185
        $keyResource = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
186
        if ($keyResource !== false) {
187
            $details = \openssl_pkey_get_details($keyResource);
188
            \openssl_pkey_free($keyResource);
189
190
            if ($details !== false) {
191
                $strLocalPublicKey  = '04';
192
                $strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['x']), 16), 16), 64, '0', STR_PAD_LEFT);
193
                $strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['y']), 16), 16), 64, '0', STR_PAD_LEFT);
194
                $strLocalPublicKey = hex2bin($strLocalPublicKey);
195
                if ($strLocalPublicKey !== false) {
196
                    $this->strLocalPublicKey = $strLocalPublicKey;
197
                }
198
                $this->gmpLocalPrivateKey = gmp_init(bin2hex($details['ec']['d']), 16);
0 ignored issues
show
Documentation Bug introduced by
It seems like gmp_init(bin2hex($details['ec']['d']), 16) can also be of type resource. However, the property $gmpLocalPrivateKey is declared as type GMP. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
199
                $bSucceeded = true;
200
            }
201
        }
202
        if (!$bSucceeded) {
203
            $this->strError = 'openssl: ' . \openssl_error_string();
204
        }
205
        return $bSucceeded;
206
    }
207
208
    /**
209
     * build shared secret from user public key and local private key using prime256v1 curve
210
     * @return string
211
     */
212
    private function getSharedSecret() : string
213
    {
214
215
        $curve = NistCurve::curve256();
216
217
        $x = '';
218
        $y = '';
219
        self::getXYFromPublicKey($this->strSubscrKey, $x, $y);
220
221
        $strSubscrKeyPoint = $curve->getPublicKeyFrom(\gmp_init(bin2hex($x), 16), \gmp_init(bin2hex($y), 16));
0 ignored issues
show
Bug introduced by
It seems like gmp_init(bin2hex($x), 16) can also be of type resource; however, parameter $x of SKien\PNServer\Utils\Curve::getPublicKeyFrom() does only seem to accept GMP, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

221
        $strSubscrKeyPoint = $curve->getPublicKeyFrom(/** @scrutinizer ignore-type */ \gmp_init(bin2hex($x), 16), \gmp_init(bin2hex($y), 16));
Loading history...
Bug introduced by
It seems like gmp_init(bin2hex($y), 16) can also be of type resource; however, parameter $y of SKien\PNServer\Utils\Curve::getPublicKeyFrom() does only seem to accept GMP, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

221
        $strSubscrKeyPoint = $curve->getPublicKeyFrom(\gmp_init(bin2hex($x), 16), /** @scrutinizer ignore-type */ \gmp_init(bin2hex($y), 16));
Loading history...
222
223
        // get shared secret from user public key and local private key
224
        $strSharedSecret = $curve->mul($strSubscrKeyPoint, $this->gmpLocalPrivateKey);
225
        $strSharedSecret = $strSharedSecret->getX();
226
        $strSharedSecret = hex2bin(str_pad(\gmp_strval($strSharedSecret, 16), 64, '0', STR_PAD_LEFT));
227
228
        return ($strSharedSecret !== false ? $strSharedSecret : '');
229
    }
230
231
    /**
232
     * get pseudo random key
233
     * @param string $strSharedSecret
234
     * @return string
235
     */
236
    private function getPRK(string $strSharedSecret) : string
237
    {
238
        if (!empty($this->strSubscrAuth)) {
239
            if ($this->strEncoding === "aesgcm") {
240
                $info = 'Content-Encoding: auth' . chr(0);
241
            } else {
242
                $info = "WebPush: info" . chr(0) . $this->strSubscrKey . $this->strLocalPublicKey;
243
            }
244
            $strSharedSecret = self::hkdf($this->strSubscrAuth, $strSharedSecret, $info, 32);
245
        }
246
247
        return $strSharedSecret;
248
    }
249
250
    /**
251
     * Creates a context for deriving encryption parameters.
252
     * See section 4.2 of
253
     * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
254
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
255
     *
256
     * @return null|string
257
     * @throws \ErrorException
258
     */
259
    private function createContext() : ?string
260
    {
261
        if ($this->strEncoding === "aes128gcm") {
262
            return null;
263
        }
264
265
        // This one should never happen, because it's our code that generates the key
266
        /*
267
        if (mb_strlen($this->strLocalPublicKey, '8bit') !== 65) {
268
            throw new \ErrorException('Invalid server public key length');
269
        }
270
        */
271
272
        $len = chr(0) . 'A'; // 65 as Uint16BE
273
274
        return chr(0) . $len . $this->strSubscrKey . $len . $this->strLocalPublicKey;
275
    }
276
277
    /**
278
     * Returns an info record. See sections 3.2 and 3.3 of
279
     * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
280
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
281
     *
282
     * @param string $strType The type of the info record
283
     * @param string|null $strContext The context for the record
284
     * @return string
285
     * @throws \ErrorException
286
     */
287
    private function createInfo(string $strType, ?string $strContext) : string
288
    {
289
        if ($this->strEncoding === "aesgcm") {
290
            if (!$strContext) {
291
                throw new \ErrorException('Context must exist');
292
            }
293
294
            if (mb_strlen($strContext, '8bit') !== 135) {
295
                throw new \ErrorException('Context argument has invalid size');
296
            }
297
298
            $strInfo = 'Content-Encoding: ' . $strType . chr(0) . 'P-256' . $strContext;
299
        } else {
300
            $strInfo = 'Content-Encoding: ' . $strType . chr(0);
301
        }
302
        return $strInfo;
303
    }
304
305
    /**
306
     * get the content coding header to add to encrypted payload
307
     * @return string
308
     */
309
    private function getContentCodingHeader() : string
310
    {
311
        $strHeader = '';
312
        if ($this->strEncoding === "aes128gcm") {
313
            $strHeader = $this->strSalt
314
                . pack('N*', 4096)
315
                . pack('C*', mb_strlen($this->strLocalPublicKey, '8bit'))
316
                . $this->strLocalPublicKey;
317
        }
318
        return $strHeader;
319
    }
320
321
    /**
322
     * pad the payload.
323
     * Before we encrypt our payload, we need to define how much padding we wish toadd to
324
     * the front of the payload. The reason we’d want to add padding is that it prevents
325
     * the risk of eavesdroppers being able to determine “types” of messagesbased on the
326
     * payload size. We must add two bytes of padding to indicate the length of any
327
     * additionalpadding.
328
     *
329
     * @param string $strPayload
330
     * @param int $iMaxLengthToPad
331
     * @return string
332
     */
333
    private function padPayload(string $strPayload, int $iMaxLengthToPad = 0) : string
334
    {
335
        $iLen = mb_strlen($strPayload, '8bit');
336
        $iPad = $iMaxLengthToPad ? $iMaxLengthToPad - $iLen : 0;
337
338
        if ($this->strEncoding === "aesgcm") {
339
            $strPayload = pack('n*', $iPad) . str_pad($strPayload, $iPad + $iLen, chr(0), STR_PAD_LEFT);
340
        } elseif ($this->strEncoding === "aes128gcm") {
341
            $strPayload = str_pad($strPayload . chr(2), $iPad + $iLen, chr(0), STR_PAD_RIGHT);
342
        }
343
        return $strPayload;
344
    }
345
346
    /**
347
     * HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
348
     *
349
     * This is used to derive a secure encryption key from a mostly-secure shared
350
     * secret.
351
     *
352
     * This is a partial implementation of HKDF tailored to our specific purposes.
353
     * In particular, for us the value of N will always be 1, and thus T always
354
     * equals HMAC-Hash(PRK, info | 0x01).
355
     *
356
     * See {@link https://www.rfc-editor.org/rfc/rfc5869.txt}
357
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
358
     *
359
     * @param string $salt   A non-secret random value
360
     * @param string $ikm    Input keying material
361
     * @param string $info   Application-specific context
362
     * @param int    $length The length (in bytes) of the required output key
363
     *
364
     * @return string
365
     */
366
    private static function hkdf(string $salt, string $ikm, string $info, int $length) : string
367
    {
368
        // extract
369
        $prk = hash_hmac('sha256', $ikm, $salt, true);
370
371
        // expand
372
        return mb_substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, $length, '8bit');
373
    }
374
}
375