Issues (10)

src/Algorithm/Shamir.php (9 issues)

Labels
Severity
1
<?php
2
3
namespace TQ\Shamir\Algorithm;
4
5
use OutOfRangeException;
6
use RuntimeException;
7
use TQ\Shamir\Random\Generator;
8
use TQ\Shamir\Random\PhpGenerator;
9
10
/**
11
 * Class Shamir
12
 *
13
 * Based on "Shamir's Secret Sharing class" from Kenny Millington
14
 *
15
 * @link    https://www.kennynet.co.uk/misc/shamir.class.txt
16
 *
17
 * @package TQ\Shamir\Algorithm
18
 */
19
class Shamir implements Algorithm, RandomGeneratorAware
20
{
21
    /**
22
     * Calculation base (decimal)
23
     *
24
     * Changing this will invalid all previously created keys.
25
     *
26
     * @const   string
27
     */
28
    protected const DECIMAL = '0123456789';
29
30
    /**
31
     * Target base characters to be used in passwords (shares)
32
     *
33
     * The more characters are used, the shorter the shares might get.
34
     * Changing this will invalid all previously created keys.
35
     *
36
     * @const   string
37
     */
38
    protected const CHARS = '0123456789abcdefghijklmnopqrstuvwxyz.,:;-+*#%';
39
40
    /**
41
     * Character to fill up the secret keys
42
     *
43
     * @const   string
44
     */
45
    protected const PAD_CHAR = '=';
46
47
    /**
48
     * Prime number has to be greater than the maximum number of shares possible
49
     *
50
     * @var float
51
     */
52
    protected $prime = 257;
53
54
    /**
55
     * Chunk size in bytes
56
     *
57
     * The secret will be divided equally. This value defines the chunk size and
58
     * how many bytes will get encoded at once.
59
     *
60
     * @var int
61
     */
62
    protected $chunkSize = 1;
63
64
    /**
65
     * The random generator
66
     *
67
     * @var Generator
68
     */
69
    protected $randomGenerator;
70
71
    /**
72
     * Maximum number of shares required
73
     *
74
     * @var float
75
     */
76
    protected $maxShares = 3;
77
78
    /**
79
     * @inheritdoc
80
     */
81 19
    public function getRandomGenerator(): Generator
82
    {
83 19
        if (!$this->randomGenerator) {
84 14
            $this->randomGenerator = new PhpGenerator();
85
        }
86
87 19
        return $this->randomGenerator;
88
    }
89
90
    /**
91
     * @inheritdoc
92
     */
93 63
    public function setRandomGenerator(Generator $generator): Shamir
94
    {
95 63
        $this->randomGenerator = $generator;
96
97 63
        return $this;
98
    }
99
100
    /**
101
     * Returns chunk size in bytes
102
     */
103 14
    public function getChunkSize(): int
104
    {
105 14
        return $this->chunkSize;
106
    }
107
108
    /**
109
     * Sets chunk size in bytes
110
     *
111
     * If maximum shares have been set already, the chunk
112
     * size might have been set with it. It is not possible
113
     * to set a smaller size than required by shares.
114
     *
115
     * @see
116
     * @param  int  $chunkSize  Size in number of bytes
117
     * @throws OutOfRangeException
118
     */
119 36
    public function setChunkSize(int $chunkSize): Shamir
120
    {
121 36
        $primeNumber = [1 => 257, 65537, 16777259, 4294967311, 1099511627791, 281474976710677, 72057594037928017];
122
123 36
        if (!isset($primeNumber[$chunkSize])) {
124 4
            throw new OutOfRangeException(
125 4
                'Chunk size with '.$chunkSize.' bytes is not allowed. Use 1 to '.count($primeNumber).'.'
126 4
            );
127
        }
128
129 32
        $this->chunkSize = $chunkSize;
130
        // if chunk size has been set already, we will only increase it, if necessary
131 32
        $this->prime = $primeNumber[$chunkSize];
132
133 32
        return $this;
134
    }
135
136
    /**
137
     * Configure encoding parameters
138
     *
139
     * Depending on the number of required shares, we need to change
140
     * prime number, key length, chunk size and more.
141
     *
142
     * If the chunk size has been set already, it will be changed, if
143
     * it is smaller than the necessary size.
144
     *
145
     * @see    setChunkSize()
146
     * @param  int  $max  Maximum number of keys needed
147
     * @throws OutOfRangeException
148
     */
149 19
    protected function setMaxShares(int $max): Shamir
150
    {
151
        // the prime number has to be larger, than the maximum number
152
        // representable by the number of bytes. so we always need one
153
        // byte more for encryption. if someone wants to use 256 shares,
154
        // we could encrypt 256 with a single byte, but due to encrypting
155
        // with a bigger prime number, we will need to use 2 bytes.
156
157
        // max possible number of shares is the maximum number of bytes
158
        // possible to be represented with max integer, but we always need
159
        // to save one byte for encryption.
160 19
        $maxPossible = 1 << (PHP_INT_SIZE - 1) * 8;
161
162 19
        if ($max > $maxPossible) {
163
            // we are unable to provide more bytes-1 as supported by OS
164
            // because the prime number need to be higher than that, but
165
            // this would exceed OS integer range.
166
            throw new OutOfRangeException(
167
                'Number of required keys has to be below '.number_format($maxPossible).'.'
168
            );
169
        }
170
171
        // calculate how many bytes we need to represent the number of shares.
172
        // e.g., everything less than 256 needs only a single byte.
173 19
        $chunkSize = (int)ceil(log($max, 2) / 8);
174
        // if chunk size has been set already, we will only increase it, if necessary
175 19
        $chunkSize = max($chunkSize, $this->chunkSize);
176
177 19
        if ($chunkSize > $this->chunkSize) {
178 2
            $this->setChunkSize($chunkSize);
179
        }
180
181 19
        $this->maxShares = $max;
182
183 19
        return $this;
184
    }
185
186
    /**
187
     * Calculate modulo of any given number using prime
188
     *
189
     * @return int     Module of number
190
     */
191 18
    protected function modulo(string $number): int
192
    {
193 18
        $modulo = bcmod($number, $this->prime);
194
195 18
        return ($modulo < 0) ? bcadd($modulo, $this->prime) : $modulo;
196
    }
197
198
    /**
199
     * Returns decomposition of the greatest common divisor of a and b
200
     */
201 18
    protected function gcdD(int $a, string $b): array
202
    {
203 18
        if ($b === '0') {
204 18
            return [$a, 1, 0];
205
        }
206
207 18
        $div    = floor(bcdiv($a, $b));
0 ignored issues
show
bcdiv($a, $b) of type null|string is incompatible with the type double|integer expected by parameter $num of floor(). ( Ignorable by Annotation )

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

207
        $div    = floor(/** @scrutinizer ignore-type */ bcdiv($a, $b));
Loading history...
208 18
        $mod    = bcmod($a, $b);
209 18
        $decomp = $this->gcdD($b, $mod);
0 ignored issues
show
$b of type string is incompatible with the type integer expected by parameter $a of TQ\Shamir\Algorithm\Shamir::gcdD(). ( Ignorable by Annotation )

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

209
        $decomp = $this->gcdD(/** @scrutinizer ignore-type */ $b, $mod);
Loading history...
210
211 18
        return [$decomp[0], $decomp[2], $decomp[1] - $decomp[2] * $div];
212
    }
213
214
    /**
215
     * Calculates the inverse modulo
216
     */
217 18
    protected function inverseModulo(int $number): string
218
    {
219 18
        $mod = bcmod($number, $this->prime);
220 18
        $r   = $this->gcdD($this->prime, abs($mod));
0 ignored issues
show
$mod of type null|string is incompatible with the type double|integer expected by parameter $num of abs(). ( Ignorable by Annotation )

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

220
        $r   = $this->gcdD($this->prime, abs(/** @scrutinizer ignore-type */ $mod));
Loading history...
$this->prime of type double is incompatible with the type integer expected by parameter $a of TQ\Shamir\Algorithm\Shamir::gcdD(). ( Ignorable by Annotation )

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

220
        $r   = $this->gcdD(/** @scrutinizer ignore-type */ $this->prime, abs($mod));
Loading history...
221 18
        $r   = ($mod < 0) ? -$r[2] : $r[2];
222
223 18
        return bcmod(bcadd($this->prime, $r), $this->prime);
224
    }
225
226
    /**
227
     * Calculates the reverse coefficients
228
     *
229
     * @throws RuntimeException
230
     */
231 18
    protected function reverseCoefficients(array $keyX, int $threshold): array
232
    {
233 18
        $coefficients = [];
234
235 18
        for ($i = 0; $i < $threshold; $i++) {
236 18
            $temp = 1;
237 18
            for ($j = 0; $j < $threshold; $j++) {
238 18
                if ($i !== $j) {
239 18
                    $temp = $this->modulo(
240 18
                        bcmul(bcmul(-$temp, $keyX[$j]), $this->inverseModulo($keyX[$i] - $keyX[$j]))
241 18
                    );
242
                }
243
            }
244
245 18
            if ($temp === 0) {
246
                /* Repeated share */
247
                throw new RuntimeException('Repeated share detected - cannot compute reverse-coefficients');
248
            }
249
250 18
            $coefficients[] = $temp;
251
        }
252
253 18
        return $coefficients;
254
    }
255
256
    /**
257
     * Generate random coefficients
258
     *
259
     * @param  int  $threshold  Number of coefficients needed
260
     */
261 18
    protected function generateCoefficients(int $threshold): array
262
    {
263 18
        $coefficients = [];
264 18
        for ($i = 0; $i < $threshold - 1; $i++) {
265
            do {
266
                // the random number has to be positive integer != 0
267 18
                $random = abs($this->getRandomGenerator()->getRandomInt());
268 18
            } while ($random < 1);
269 18
            $coefficients[] = $this->modulo($random);
270
        }
271
272 18
        return $coefficients;
273
    }
274
275
    /**
276
     * Calculate y values of polynomial curve using horner's method
277
     *
278
     * Horner converts a polynomial formula like
279
     * 11 + 7x - 5x^2 - 4x^3 + 2x^4
280
     * into a more efficient formula
281
     * 11 + x * ( 7 + x * ( -5 + x * ( -4 + x * 2 ) ) )
282
     *
283
     * @see    https://en.wikipedia.org/wiki/Horner%27s_method
284
     * @param  int    $xCoordinate   X coordinate
285
     * @param  array  $coefficients  Polynomial coefficients
286
     * @return int                   Y coordinate
287
     */
288 18
    protected function hornerMethod(int $xCoordinate, array $coefficients): int
289
    {
290 18
        $yCoordinate = 0;
291 18
        foreach ($coefficients as $coefficient) {
292 18
            $yCoordinate = $this->modulo($xCoordinate * $yCoordinate + $coefficient);
293
        }
294
295 18
        return $yCoordinate;
296
    }
297
298
    /**
299
     * Converts from $fromBaseInput to $toBaseInput
300
     */
301 39
    protected static function convBase(string $numberInput, string $fromBaseInput, string $toBaseInput): string
302
    {
303 39
        if ($fromBaseInput === $toBaseInput) {
304 3
            return $numberInput;
305
        }
306 36
        $fromBase  = str_split($fromBaseInput, 1);
307 36
        $toBase    = str_split($toBaseInput, 1);
308 36
        $number    = str_split($numberInput, 1);
309 36
        $fromLen   = strlen($fromBaseInput);
310 36
        $toLen     = strlen($toBaseInput);
311 36
        $numberLen = strlen($numberInput);
312 36
        $retVal    = '';
313 36
        if ($toBaseInput === '0123456789') {
314 27
            $retVal = 0;
315 27
            for ($i = 1; $i <= $numberLen; $i++) {
316 27
                $retVal = bcadd(
317 27
                    $retVal,
318 27
                    bcmul(array_search($number[$i - 1], $fromBase, true), bcpow($fromLen, $numberLen - $i))
0 ignored issues
show
It seems like $fromBase can also be of type true; however, parameter $haystack of array_search() does only seem to accept array, 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

318
                    bcmul(array_search($number[$i - 1], /** @scrutinizer ignore-type */ $fromBase, true), bcpow($fromLen, $numberLen - $i))
Loading history...
319 27
                );
320
            }
321
322 27
            return $retVal;
323
        }
324 27
        if ($fromBaseInput !== '0123456789') {
325
            $base10 = self::convBase($numberInput, $fromBaseInput, '0123456789');
326
        } else {
327 27
            $base10 = $numberInput;
328
        }
329 27
        if ($base10 < strlen($toBaseInput)) {
330 21
            return $toBase[$base10];
331
        }
332 24
        while ($base10 !== '0') {
333 24
            $retVal = $toBase[bcmod($base10, $toLen)].$retVal;
334 24
            $base10 = bcdiv($base10, $toLen, 0);
335
        }
336
337 24
        return $retVal;
338
    }
339
340
    /**
341
     * Unpack a binary string and convert it into decimals
342
     *
343
     * Convert each chunk of a binary data into decimal numbers.
344
     *
345
     * @param  string  $string  Binary string
346
     * @return array            Array with decimal converted numbers
347
     */
348 18
    protected function unpack(string $string): array
349
    {
350 18
        $chunk  = 0;
351 18
        $int    = '';
352 18
        $return = [];
353 18
        foreach (unpack('C*', $string) as $byte) {
354 18
            $int = bcadd($int, bcmul($byte, bcpow(2, $chunk * 8)));
355 18
            if (++$chunk === $this->chunkSize) {
356 18
                $return[] = $int;
357 18
                $chunk    = 0;
358 18
                $int      = '';
359
            }
360
        }
361 18
        if ($chunk > 0) {
362 7
            $return[] = $int;
363
        }
364
365 18
        return $return;
366
    }
367
368
    /**
369
     * Returns maximum length of converted string to new base
370
     *
371
     * Calculate the maximum length of a string, which can be
372
     * represented with the number of given bytes and convert
373
     * its base.
374
     *
375
     * @param  int  $bytes  Bytes used to represent a string
376
     * @return int          Number of chars
377
     */
378 18
    protected function maxKeyLength(int $bytes): int
379
    {
380 18
        $maxInt    = bcpow(2, $bytes * 8);
381 18
        $converted = self::convBase($maxInt, self::DECIMAL, self::CHARS);
382
383 18
        return strlen($converted);
384
    }
385
386
    /**
387
     * Divide secret into chunks and calculate coordinates
388
     *
389
     * @param  string  $secret     Secret
390
     * @param  int     $shares     Number of parts to share
391
     * @param  int     $threshold  Minimum number of shares required for decryption
392
     */
393 18
    protected function divideSecret(string $secret, int $shares, int $threshold): array
394
    {
395
        // divide secret into chunks, which we encrypt one by one
396 18
        $result = [];
397
398 18
        foreach ($this->unpack($secret) as $bytes) {
399 18
            $coeffs   = $this->generateCoefficients($threshold);
400 18
            $coeffs[] = $bytes;
401
402
            // go through x coordinates and calculate y value
403 18
            for ($x = 1; $x <= $shares; $x++) {
404
                // use horner method to calculate y value
405 18
                $result[] = $this->hornerMethod($x, $coeffs);
406
            }
407
        }
408
409 18
        return $result;
410
    }
411
412
    /**
413
     * @inheritdoc
414
     */
415 19
    public function share($secret, $shares, $threshold = 2): array
416
    {
417 19
        $this->setMaxShares($shares);
418
419
        // check if number of shares is less than our prime, otherwise we have a security problem
420 19
        if ($shares >= $this->prime || $shares < 1) {
421
            throw new OutOfRangeException('Number of shares has to be between 1 and '.$this->prime.'.');
422
        }
423
424 19
        if ($shares < $threshold) {
425 1
            throw new OutOfRangeException('Threshold has to be between 1 and '.$shares.'.');
426
        }
427
428 18
        if (strpos(self::CHARS, self::PAD_CHAR) !== false) {
429
            throw new OutOfRangeException('Padding character must not be part of possible encryption chars.');
430
        }
431
432
        // divide secret into chunks, which we encrypt one by one
433 18
        $result = $this->divideSecret($secret, $shares, $threshold);
434
435
        // encode number of bytes and threshold
436
437
        // calculate the maximum length of key sequence number and threshold
438 18
        $maxBaseLength = $this->maxKeyLength($this->chunkSize);
439
        // in order to do a correct padding to the converted base, we need to use the first char of the base
440 18
        $paddingChar = substr(self::CHARS, 0, 1);
441
        // define prefix number using the number of bytes (hex), and a left padded string used for threshold (base converted)
442 18
        $fixPrefixFormat = '%x%'.$paddingChar.$maxBaseLength.'s';
443
        // prefix is going to be the same for all keys
444 18
        $prefix = sprintf($fixPrefixFormat, $this->chunkSize, self::convBase($threshold, self::DECIMAL, self::CHARS));
445
446
        // convert y coordinates into hexadecimals shares
447 18
        $passwords = [];
448 18
        $secretLen = strlen($secret);
449
        // calculate how many bytes, we need to cut off during recovery
450 18
        if ($secretLen % $this->chunkSize > 0) {
451 7
            $tail = str_repeat(self::PAD_CHAR, $this->chunkSize - $secretLen % $this->chunkSize);
452
        } else {
453 12
            $tail = '';
454
        }
455
456 18
        $chunks = ceil($secretLen / $this->chunkSize);
457 18
        for ($i = 0; $i < $shares; ++$i) {
458 18
            $sequence = self::convBase(($i + 1), self::DECIMAL, self::CHARS);
459 18
            $key      = sprintf($prefix.'%'.$paddingChar.$maxBaseLength.'s', $sequence);
460
461 18
            for ($j = 0; $j < $chunks; ++$j) {
462 18
                $key .= str_pad(
463 18
                    self::convBase($result[$j * $shares + $i], self::DECIMAL, self::CHARS),
464 18
                    $maxBaseLength,
465 18
                    $paddingChar,
466 18
                    STR_PAD_LEFT
467 18
                );
468
            }
469 18
            $passwords[] = $key.$tail;
470
        }
471
472 18
        return $passwords;
473
    }
474
475
    /**
476
     * Decode and merge secret chunks
477
     *
478
     * @param  array  $keyX       Keys for X coordinates
479
     * @param  array  $keyY       Keys for Y coordinates
480
     * @param  int    $bytes      Chunk size in bytes
481
     * @param  int    $keyLen     Key length in chunks
482
     * @param  int    $threshold  Minimum number of shares required for decryption
483
     */
484 18
    protected function joinSecret(array $keyX, array $keyY, int $bytes, int $keyLen, int $threshold): string
485
    {
486 18
        $coefficients = $this->reverseCoefficients($keyX, $threshold);
487
488 18
        $secret = '';
489 18
        for ($i = 0; $i < $keyLen; $i++) {
490 18
            $temp = 0;
491 18
            for ($j = 0; $j < $threshold; $j++) {
492 18
                $temp = $this->modulo(
493 18
                    bcadd($temp, bcmul($keyY[$j * $keyLen + $i], $coefficients[$j]))
494 18
                );
495
            }
496
            // convert each byte back into char
497 18
            for ($byte = 1; $byte <= $bytes; ++$byte) {
498 18
                $char   = bcmod($temp, 256);
499 18
                $secret .= chr($char);
0 ignored issues
show
$char of type null|string is incompatible with the type integer expected by parameter $codepoint of chr(). ( Ignorable by Annotation )

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

499
                $secret .= chr(/** @scrutinizer ignore-type */ $char);
Loading history...
500 18
                $temp   = bcdiv(bcsub($temp, $char), 256);
501
            }
502
        }
503
504 18
        return $secret;
505
    }
506
507
    /**
508
     * @inheritdoc
509
     */
510 18
    public function recover(array $keys): string
511
    {
512 18
        if (!count($keys)) {
513
            throw new RuntimeException('No keys given.');
514
        }
515
516 18
        $keyX      = [];
517 18
        $keyY      = [];
518 18
        $keyLen    = null;
519 18
        $threshold = null;
520
521
        // analyse first key
522 18
        $key = reset($keys);
523
        // first we need to find out the bytes to predict threshold and sequence length
524 18
        $bytes = hexdec(substr($key, 0, 1));
525 18
        $this->setChunkSize($bytes);
0 ignored issues
show
It seems like $bytes can also be of type double; however, parameter $chunkSize of TQ\Shamir\Algorithm\Shamir::setChunkSize() does only seem to accept integer, 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

525
        $this->setChunkSize(/** @scrutinizer ignore-type */ $bytes);
Loading history...
526
        // calculate the maximum length of key sequence number and threshold
527 18
        $maxBaseLength = $this->maxKeyLength($bytes);
528
        // define key format: bytes (hex), threshold, sequence, and key (except of bytes, all is base converted)
529 18
        $keyFormat = '%1x%'.$maxBaseLength.'s%'.$maxBaseLength.'s%s';
530
531 18
        foreach ($keys as $key) {
532
            // remove trailing padding characters
533 18
            $key = str_replace(self::PAD_CHAR, '', $key);
534
535
            // extract "public" information of key: bytes, threshold, sequence
536
537 18
            [$keyBytes, $keyThreshold, $keySequence, $key] = sscanf($key, $keyFormat);
538 18
            $keyThreshold = (int)self::convBase($keyThreshold, self::CHARS, self::DECIMAL);
539 18
            $keySequence  = (int)self::convBase($keySequence, self::CHARS, self::DECIMAL);
540
541 18
            if ($threshold === null) {
542 18
                $threshold = $keyThreshold;
543
544 18
                if ($threshold > count($keys)) {
545 18
                    throw new RuntimeException('Not enough keys to disclose secret.');
546
                }
547 18
            } elseif ($threshold !== $keyThreshold || $bytes != hexdec($keyBytes)) {
548
                throw new RuntimeException('Given keys are incompatible.');
549
            }
550
551 18
            $keyX[] = $keySequence;
552 18
            if ($keyLen === null) {
553 18
                $keyLen = strlen($key);
554 18
            } elseif ($keyLen !== strlen($key)) {
555
                throw new RuntimeException('Given keys vary in key length.');
556
            }
557 18
            for ($i = 0; $i < $keyLen; $i += $maxBaseLength) {
558 18
                $keyY[] = self::convBase(substr($key, $i, $maxBaseLength), self::CHARS, self::DECIMAL);
559
            }
560
        }
561
562 18
        $keyLen /= $maxBaseLength;
563 18
        $secret = $this->joinSecret($keyX, $keyY, $bytes, $keyLen, $threshold);
0 ignored issues
show
It seems like $threshold can also be of type null; however, parameter $threshold of TQ\Shamir\Algorithm\Shamir::joinSecret() does only seem to accept integer, 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

563
        $secret = $this->joinSecret($keyX, $keyY, $bytes, $keyLen, /** @scrutinizer ignore-type */ $threshold);
Loading history...
It seems like $keyLen can also be of type null; however, parameter $keyLen of TQ\Shamir\Algorithm\Shamir::joinSecret() does only seem to accept integer, 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

563
        $secret = $this->joinSecret($keyX, $keyY, $bytes, /** @scrutinizer ignore-type */ $keyLen, $threshold);
Loading history...
564
565
        // remove padding from secret (NULL bytes);
566 18
        $padCount = substr_count(reset($keys), self::PAD_CHAR);
567 18
        if ($padCount) {
568 7
            $secret = substr($secret, 0, -1 * $padCount);
569
        }
570
571 18
        return $secret;
572
    }
573
}
574