1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Mfonte\Base62x; |
4
|
|
|
|
5
|
|
|
use Exception; |
6
|
|
|
use Mfonte\Base62x\Exception\InvalidParam; |
7
|
|
|
use Mfonte\Base62x\Exception\CryptException; |
8
|
|
|
use Mfonte\Base62x\Exception\DecodeException; |
9
|
|
|
use Mfonte\Base62x\Exception\EncodeException; |
10
|
|
|
use Mfonte\Base62x\Encoding\Base62x as Encoder; |
11
|
|
|
use Mfonte\Base62x\Encryption\Crypt as Crypter; |
12
|
|
|
use Mfonte\Base62x\Compression\Gzip\GzipCompression as GzipCompressor; |
13
|
|
|
use Mfonte\Base62x\Compression\Huffman\HuffmanCoding as HuffmanCompressor; |
14
|
|
|
|
15
|
|
|
class Base62x |
16
|
|
|
{ |
17
|
|
|
const MODE_ENCODE = 1; |
18
|
|
|
const MODE_DECODE = 2; |
19
|
|
|
|
20
|
|
|
protected $_validCompressionAlgorithms = ['gzip' => ['zlib', 'deflate', 'gzip'], 'huffman']; |
21
|
|
|
|
22
|
|
|
/** |
23
|
|
|
* The mode: encode or decode. |
24
|
|
|
* |
25
|
|
|
* @var int |
26
|
|
|
*/ |
27
|
|
|
protected $mode; |
28
|
|
|
|
29
|
|
|
/** |
30
|
|
|
* The payload to be encoded or decoded. |
31
|
|
|
* |
32
|
|
|
* @var mixed |
33
|
|
|
*/ |
34
|
|
|
protected $payload; |
35
|
|
|
|
36
|
|
|
/** |
37
|
|
|
* Wheter the payload needs to be compressed prior of encoding. Defaults to null. |
38
|
|
|
* |
39
|
|
|
* @var string |
40
|
|
|
*/ |
41
|
|
|
protected $compressAlgorithm = null; |
42
|
|
|
|
43
|
|
|
/** |
44
|
|
|
* The compression mode related to the compression algo. Defaults to null. |
45
|
|
|
* |
46
|
|
|
* @var string |
47
|
|
|
*/ |
48
|
|
|
protected $compressEncoding = null; |
49
|
|
|
|
50
|
|
|
/** |
51
|
|
|
* The encryption method (algorithm) to be used in case of password-protected encoding. |
52
|
|
|
* This variable *must* be a valid method supported in openssl_get_cipher_methods(). |
53
|
|
|
* |
54
|
|
|
* @var string |
55
|
|
|
*/ |
56
|
|
|
protected $cryptMethod; |
57
|
|
|
|
58
|
|
|
/** |
59
|
|
|
* The encrypt/decrypt key (password) to be used to protect/unprotect the encoding. |
60
|
|
|
* |
61
|
|
|
* @var mixed |
62
|
|
|
*/ |
63
|
|
|
protected $cryptKey; |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* Wheter the payload needs to be decompressed after the decoding. Defaults to false. |
67
|
|
|
* |
68
|
|
|
* @var string |
69
|
|
|
*/ |
70
|
|
|
protected $decompressAlgorithm = null; |
71
|
|
|
|
72
|
|
|
/** |
73
|
|
|
* The compression mode related to the compression algo. Defaults to null. |
74
|
|
|
* |
75
|
|
|
* @var string |
76
|
|
|
*/ |
77
|
|
|
protected $decompressEncoding = null; |
78
|
|
|
|
79
|
|
|
/** |
80
|
|
|
* @param mixed $payload |
81
|
|
|
* |
82
|
|
|
* @return \Mfonte\Base62x\Base62x |
83
|
|
|
*/ |
84
|
|
|
public static function encode($payload): self |
85
|
|
|
{ |
86
|
|
|
return new self(self::MODE_ENCODE, $payload); |
87
|
|
|
} |
88
|
|
|
|
89
|
|
|
/** |
90
|
|
|
* @param mixed $payload |
91
|
|
|
* |
92
|
|
|
* @return \Mfonte\Base62x\Base62x |
93
|
|
|
*/ |
94
|
|
|
public static function decode($payload): self |
95
|
|
|
{ |
96
|
|
|
return new self(self::MODE_DECODE, $payload); |
97
|
|
|
} |
98
|
|
|
|
99
|
|
|
public function __construct($mode, $payload) |
100
|
|
|
{ |
101
|
|
|
$this->mode = $mode; |
102
|
|
|
|
103
|
|
|
if (empty($payload)) { |
104
|
|
|
throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be empty'); |
105
|
|
|
} elseif (\is_resource($payload)) { |
106
|
|
|
throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be a resource'); |
107
|
|
|
} elseif (\is_object($payload)) { |
108
|
|
|
throw new InvalidParam('payload', __FUNCTION__, __CLASS__, 'The payload cannot be an object'); |
109
|
|
|
} elseif (\is_array($payload)) { |
110
|
|
|
// if the payload is an array, perform here and now the translation to a serialized string |
111
|
|
|
$payload = serialize($payload); |
112
|
|
|
} |
113
|
|
|
|
114
|
|
|
$this->payload = $payload; |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
/** |
118
|
|
|
* Sets the compression type and encoding. |
119
|
|
|
* |
120
|
|
|
* @param string $algo A valid compression algorithm as seen on $_validCompressionAlgorithms |
121
|
|
|
* @param string $encoding A valid compression encoding as seen on $_validCompressionAlgorithms |
122
|
|
|
* |
123
|
|
|
* @return \Mfonte\Base62x\Base62x |
124
|
|
|
*/ |
125
|
|
|
public function compress($algo = 'gzip', $encoding = 'gzip'): self |
126
|
|
|
{ |
127
|
|
|
// sanity check for bad $algo |
128
|
|
|
if ( |
129
|
|
|
!\array_key_exists($algo, $this->_validCompressionAlgorithms) && |
130
|
|
|
!\in_array($algo, $this->_validCompressionAlgorithms, true) |
131
|
|
|
) { |
132
|
|
|
throw new InvalidParam('algo', __FUNCTION__, __CLASS__); |
133
|
|
|
} |
134
|
|
|
|
135
|
|
|
// sanity check for bad $encoding |
136
|
|
|
if ( |
137
|
|
|
\array_key_exists($algo, $this->_validCompressionAlgorithms) && |
138
|
|
|
\is_array($this->_validCompressionAlgorithms[$algo]) && |
139
|
|
|
!\in_array($encoding, $this->_validCompressionAlgorithms[$algo], true) |
140
|
|
|
) { |
141
|
|
|
throw new InvalidParam('encoding', __FUNCTION__, __CLASS__); |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
// make sure we nullify the $encoding if we don't have one |
145
|
|
|
if ( |
146
|
|
|
!\array_key_exists($algo, $this->_validCompressionAlgorithms) || |
147
|
|
|
!\is_array($this->_validCompressionAlgorithms[$algo]) |
148
|
|
|
) { |
149
|
|
|
$encoding = null; |
150
|
|
|
} |
151
|
|
|
|
152
|
|
|
$this->compressAlgorithm = $algo; |
153
|
|
|
$this->compressEncoding = $encoding; |
154
|
|
|
|
155
|
|
|
return $this; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
/** |
159
|
|
|
* As the decompression is done automagically via the "magic string" at the beginning of the |
160
|
|
|
* encoded payload, this method is pointless. |
161
|
|
|
* It is present only as a reference. |
162
|
|
|
* |
163
|
|
|
* @return \Mfonte\Base62x\Base62x |
164
|
|
|
*/ |
165
|
|
|
public function decompress(): self |
166
|
|
|
{ |
167
|
|
|
return $this; |
168
|
|
|
} |
169
|
|
|
|
170
|
|
|
/** |
171
|
|
|
* Sets the encryption key (password) and method (algorithm). |
172
|
|
|
* |
173
|
|
|
* @param string $key A password for your encoded base62x output string |
174
|
|
|
* @param string $method A valid openssl cypher method as supported in your environment (openssl_get_cipher_methods) |
175
|
|
|
* |
176
|
|
|
* @return \Mfonte\Base62x\Base62x |
177
|
|
|
*/ |
178
|
|
|
public function encrypt(string $key, string $method = 'aes-128-ctr'): self |
179
|
|
|
{ |
180
|
|
|
if (!\function_exists('openssl_get_cipher_methods')) { |
181
|
|
|
throw new CryptException('openssl_get_cipher_methods unsupported in your PHP installation'); |
182
|
|
|
} |
183
|
|
|
if (!\in_array(mb_strtolower($method), openssl_get_cipher_methods(), true)) { |
184
|
|
|
throw new CryptException('Encryption method "'.$method.'" is either unsupported in your PHP installation or not a valid encryption algorithm.'); |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
$this->cryptMethod = mb_strtolower($method); |
188
|
|
|
$this->cryptKey = $key; |
189
|
|
|
|
190
|
|
|
return $this; |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* Sets the encryption key (password) and method (algorithm). |
195
|
|
|
* |
196
|
|
|
* @see self::encrypt |
197
|
|
|
*/ |
198
|
|
|
public function decrypt(string $key, string $method = 'aes-128-ctr'): self |
199
|
|
|
{ |
200
|
|
|
return $this->encrypt($key, $method); |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
/** |
204
|
|
|
* Gets the encoded or decoded mixed variable originally passed as $payload to instance. |
205
|
|
|
* |
206
|
|
|
* @return mixed |
207
|
|
|
*/ |
208
|
|
|
public function get() |
209
|
|
|
{ |
210
|
|
|
$retval = null; |
211
|
|
|
switch ($this->mode) { |
212
|
|
|
case self::MODE_ENCODE: |
213
|
|
|
$retval = $this->_encode($this->payload); |
214
|
|
|
break; |
215
|
|
|
|
216
|
|
|
case self::MODE_DECODE: |
217
|
|
|
$retval = $this->_decode($this->payload); |
218
|
|
|
|
219
|
|
|
// decoded payload can be a serialized array: if so, we return the original representation |
220
|
|
|
if ($this->_isSerializedString($retval) && ($unserialized = @unserialize($retval)) !== false) { |
221
|
|
|
$retval = $unserialized; |
222
|
|
|
} |
223
|
|
|
break; |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
return $retval; |
227
|
|
|
} |
228
|
|
|
|
229
|
|
|
/** |
230
|
|
|
* Performs the actual Base62x encoding. |
231
|
|
|
*/ |
232
|
|
|
private function _encode(string $payload): string |
233
|
|
|
{ |
234
|
|
|
if ($this->cryptKey && $this->cryptMethod) { |
235
|
|
|
$payload = $this->_performEncryption($payload); |
236
|
|
|
} |
237
|
|
|
if ($this->compressAlgorithm) { |
238
|
|
|
$payload = $this->_performCompress($payload); |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
$encoded = Encoder::encode($payload); |
242
|
|
|
if (empty($encoded)) { |
243
|
|
|
throw new EncodeException(); |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
return $encoded; |
247
|
|
|
} |
248
|
|
|
|
249
|
|
|
/** |
250
|
|
|
* Performs the actual Base62x decoding. |
251
|
|
|
*/ |
252
|
|
|
private function _decode(string $payload): string |
253
|
|
|
{ |
254
|
|
|
$decoded = Encoder::decode($payload); |
255
|
|
|
if (empty($decoded)) { |
256
|
|
|
throw new DecodeException(); |
257
|
|
|
} |
258
|
|
|
|
259
|
|
|
// remove the magic string for Compression |
260
|
|
|
$data = $this->_getCompressionFootprintAndSanitizePayload($decoded); |
261
|
|
|
|
262
|
|
|
if ($data['compression_algo']) { |
263
|
|
|
$decoded = $this->_performUncompress($data['payload'], $data['compression_algo'], $data['compression_encoding']); |
264
|
|
|
} |
265
|
|
|
|
266
|
|
|
// eventually perform decryption |
267
|
|
|
if ($this->cryptKey && $this->cryptMethod) { |
268
|
|
|
$decoded = $this->_performDecryption($decoded); |
269
|
|
|
} |
270
|
|
|
|
271
|
|
|
return $decoded; |
272
|
|
|
} |
273
|
|
|
|
274
|
|
|
/** |
275
|
|
|
* Performs the actual compress before chaining it into the Base62x encoder. |
276
|
|
|
*/ |
277
|
|
|
private function _performCompress(string $payload): string |
278
|
|
|
{ |
279
|
|
|
$compressed = null; |
280
|
|
|
switch ($this->compressAlgorithm) { |
281
|
|
|
case 'gzip': |
282
|
|
|
$compressed = GzipCompressor::encode($payload, $this->compressEncoding); |
283
|
|
|
break; |
284
|
|
|
case 'huffman': |
285
|
|
|
$compressed = HuffmanCompressor::encode($payload, HuffmanCompressor::createCodeTree($payload)); |
286
|
|
|
break; |
287
|
|
|
} |
288
|
|
|
|
289
|
|
|
if (empty($compressed)) { |
290
|
|
|
throw new EncodeException(); |
291
|
|
|
} |
292
|
|
|
|
293
|
|
|
// create the compression footprint, to avoid the decompress() on Base62x::decode() |
294
|
|
|
$footprint = $this->_createCompressionFootprint(); |
295
|
|
|
|
296
|
|
|
return $footprint.$compressed; |
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
/** |
300
|
|
|
* Decompresses the payload, that was prior compressed using one of the available compression types. |
301
|
|
|
*/ |
302
|
|
|
private function _performUncompress(string $compressed_payload, string $compression_algo, ?string $compression_encoding): string |
303
|
|
|
{ |
304
|
|
|
switch ($compression_algo) { |
305
|
|
|
case 'gzip': |
306
|
|
|
$payload = GzipCompressor::decode($compressed_payload, $compression_encoding); |
307
|
|
|
break; |
308
|
|
|
case 'huffman': |
309
|
|
|
$payload = HuffmanCompressor::decode($compressed_payload); |
310
|
|
|
break; |
311
|
|
|
default: |
312
|
|
|
$payload = ''; |
313
|
|
|
} |
314
|
|
|
|
315
|
|
|
return (string) $payload; |
316
|
|
|
} |
317
|
|
|
|
318
|
|
|
/** |
319
|
|
|
* Performs the actual encryption before chaining it into the Base62x encoder. |
320
|
|
|
*/ |
321
|
|
|
private function _performEncryption(string $payload): ?string |
322
|
|
|
{ |
323
|
|
|
try { |
324
|
|
|
$crypt = new Crypter([ |
325
|
|
|
'key' => $this->cryptKey, |
326
|
|
|
'method' => $this->cryptMethod, |
327
|
|
|
]); |
328
|
|
|
|
329
|
|
|
return $crypt->cipher($payload)->encrypt(); |
330
|
|
|
} catch (Exception $ex) { |
331
|
|
|
throw new CryptException('Cannot encrypt the payload: '.$ex->getMessage()); |
332
|
|
|
} |
333
|
|
|
} |
334
|
|
|
|
335
|
|
|
/** |
336
|
|
|
* Decrypts the payload, that was prior encrypted using the on-board encrypter. |
337
|
|
|
*/ |
338
|
|
|
private function _performDecryption(string $payload): string |
339
|
|
|
{ |
340
|
|
|
if (empty($this->cryptKey)) { |
341
|
|
|
throw new CryptException('Cannot decrypt the payload without a valid cryptKey'); |
342
|
|
|
} |
343
|
|
|
if (empty($this->cryptMethod)) { |
344
|
|
|
throw new CryptException('Cannot decrypt the payload without a valid cryptMethod'); |
345
|
|
|
} |
346
|
|
|
|
347
|
|
|
try { |
348
|
|
|
$crypt = new Crypter([ |
349
|
|
|
'key' => $this->cryptKey, |
350
|
|
|
'method' => $this->cryptMethod, |
351
|
|
|
]); |
352
|
|
|
|
353
|
|
|
$decrypted = $crypt->cipher($payload)->decrypt(); |
354
|
|
|
if ($decrypted === false) { |
355
|
|
|
throw new CryptException('Cannot decrypt the payload: result from cipher()->decrypt() is false'); |
356
|
|
|
} |
357
|
|
|
|
358
|
|
|
return $decrypted; |
359
|
|
|
} catch (Exception $ex) { |
360
|
|
|
throw new CryptException('Cannot decrypt the payload: '.$ex->getMessage()); |
361
|
|
|
} |
362
|
|
|
} |
363
|
|
|
|
364
|
|
|
/** |
365
|
|
|
* Prepares a "magic string" that will be appendend at beginning of the compressed payload, |
366
|
|
|
* prior of chaining it into the Base62x encoder. |
367
|
|
|
* Doing so, the decode method will automagically uncompress the encoded payload, so the subsequent "decode" |
368
|
|
|
* can understand which compression algo+encoding was originally used. |
369
|
|
|
*/ |
370
|
|
|
private function _createCompressionFootprint(): string |
371
|
|
|
{ |
372
|
|
|
return '[MFB62X.COMPRESS.'.base64_encode(implode(',', [$this->compressAlgorithm, $this->compressEncoding])).']'; |
373
|
|
|
} |
374
|
|
|
|
375
|
|
|
/** |
376
|
|
|
* Gets the decoded Base26x string, and checks if it needs decompression, |
377
|
|
|
* by analyzing its "compression footprint" placed at the very beginning of the payload. |
378
|
|
|
*/ |
379
|
|
|
private function _getCompressionFootprintAndSanitizePayload(string $payload): array |
380
|
|
|
{ |
381
|
|
|
$compression_algo = $compression_encoding = null; |
382
|
|
|
$pos_start = mb_strpos($payload, '[MFB62X.COMPRESS.'); |
383
|
|
|
$pos_end = mb_strpos($payload, ']'); |
384
|
|
|
|
385
|
|
|
if ($pos_start === 0 && $pos_end > 0) { |
386
|
|
|
$footprint = mb_substr($payload, 0, $pos_end + 1); |
387
|
|
|
$compression_footprint = str_replace(['[', 'MFB62X.COMPRESS.', ']'], '', $footprint); |
388
|
|
|
|
389
|
|
|
$compression_params = @base64_decode($compression_footprint, true); |
390
|
|
|
if ($compression_params && \count(explode(',', $compression_params)) === 2) { |
391
|
|
|
$compression_params = explode(',', $compression_params); |
392
|
|
|
$compression_algo = $compression_params[0]; |
393
|
|
|
$compression_encoding = $compression_params[1]; |
394
|
|
|
} |
395
|
|
|
|
396
|
|
|
// clean the payload, removing the compression footprint |
397
|
|
|
$payload = mb_substr($payload, $pos_end + 1); |
398
|
|
|
} |
399
|
|
|
|
400
|
|
|
// some sanity checks to avoid tampering with the payload and cause bad behaviour or worse |
401
|
|
|
// sanity check for bad $algo |
402
|
|
|
if ( |
403
|
|
|
$compression_algo && |
404
|
|
|
!\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) && |
405
|
|
|
!\in_array($compression_algo, $this->_validCompressionAlgorithms, true) |
406
|
|
|
) { |
407
|
|
|
throw new DecodeException(); |
408
|
|
|
} |
409
|
|
|
|
410
|
|
|
// sanity check for bad $encoding |
411
|
|
|
if ( |
412
|
|
|
$compression_algo && |
413
|
|
|
$compression_encoding && |
414
|
|
|
\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) && |
415
|
|
|
\is_array($this->_validCompressionAlgorithms[$compression_algo]) && |
416
|
|
|
!\in_array($compression_encoding, $this->_validCompressionAlgorithms[$compression_algo], true) |
417
|
|
|
) { |
418
|
|
|
throw new DecodeException(); |
419
|
|
|
} |
420
|
|
|
|
421
|
|
|
// make sure we nullify the $encoding if we don't have one |
422
|
|
|
if ( |
423
|
|
|
$compression_algo && |
424
|
|
|
(!\array_key_exists($compression_algo, $this->_validCompressionAlgorithms) || |
425
|
|
|
!\is_array($this->_validCompressionAlgorithms[$compression_algo])) |
426
|
|
|
) { |
427
|
|
|
$compression_encoding = null; |
428
|
|
|
} |
429
|
|
|
|
430
|
|
|
return [ |
431
|
|
|
'payload' => $payload, |
432
|
|
|
'compression_algo' => $compression_algo, |
433
|
|
|
'compression_encoding' => ($compression_encoding && mb_strlen($compression_encoding) > 0) ? $compression_encoding : null, |
434
|
|
|
]; |
435
|
|
|
} |
436
|
|
|
|
437
|
|
|
/** |
438
|
|
|
* Checks whether the $data argument is a serialized string, i.e. an array serialized with native PHP's serialize(). |
439
|
|
|
* |
440
|
|
|
* @param mixed $data (should always be a string) |
441
|
|
|
* @param bool $strict Whether to perform a strict analysis or not |
442
|
|
|
*/ |
443
|
|
|
private function _isSerializedString($data, $strict = true): bool |
444
|
|
|
{ |
445
|
|
|
// If it isn't a string, it isn't serialized. |
446
|
|
|
if (!\is_string($data)) { |
447
|
|
|
return false; |
448
|
|
|
} |
449
|
|
|
$data = trim($data); |
450
|
|
|
if ($data == 'N;') { |
451
|
|
|
return true; |
452
|
|
|
} |
453
|
|
|
if (mb_strlen($data) < 4) { |
454
|
|
|
return false; |
455
|
|
|
} |
456
|
|
|
if ($data[1] !== ':') { |
457
|
|
|
return false; |
458
|
|
|
} |
459
|
|
|
if ($strict) { |
460
|
|
|
$lastc = mb_substr($data, -1); |
461
|
|
|
if ($lastc !== ';' && $lastc !== '}') { |
462
|
|
|
return false; |
463
|
|
|
} |
464
|
|
|
} else { |
465
|
|
|
$semicolon = mb_strpos($data, ';'); |
466
|
|
|
$brace = mb_strpos($data, '}'); |
467
|
|
|
// Either ; or } must exist. |
468
|
|
|
if ($semicolon === false && $brace === false) { |
469
|
|
|
return false; |
470
|
|
|
} |
471
|
|
|
// But neither must be in the first X characters. |
472
|
|
|
if ($semicolon !== false && $semicolon < 3) { |
473
|
|
|
return false; |
474
|
|
|
} |
475
|
|
|
if ($brace !== false && $brace < 4) { |
476
|
|
|
return false; |
477
|
|
|
} |
478
|
|
|
} |
479
|
|
|
$token = $data[0]; |
480
|
|
|
switch ($token) { |
481
|
|
|
case 's': |
482
|
|
|
if ($strict) { |
483
|
|
|
if (mb_substr($data, -2, 1) !== '"') { |
484
|
|
|
return false; |
485
|
|
|
} |
486
|
|
|
} elseif (mb_strpos($data, '"') === false) { |
487
|
|
|
return false; |
488
|
|
|
} |
489
|
|
|
// Or else fall through. |
490
|
|
|
// no break |
491
|
|
|
case 'a': |
492
|
|
|
case 'O': |
493
|
|
|
return (bool) preg_match("/^{$token}:[0-9]+:/s", $data); |
494
|
|
|
case 'b': |
495
|
|
|
case 'i': |
496
|
|
|
case 'd': |
497
|
|
|
$end = $strict ? '$' : ''; |
498
|
|
|
|
499
|
|
|
return (bool) preg_match("/^{$token}:[0-9.E+-]+;$end/", $data); |
500
|
|
|
} |
501
|
|
|
|
502
|
|
|
return false; |
503
|
|
|
} |
504
|
|
|
} |