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); |
|
|
|
|
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)); |
|
|
|
|
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
|
|
|
|
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 theid
property of an instance of theAccount
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.