diff --git a/composer.json b/composer.json index cfff27d..ec5d0eb 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,9 @@ "symfony/var-dumper": "^6.4.0" }, "autoload": { + "files": [ + "src/Base32/functions.php" + ], "psr-4": { "Bakame\\Aide\\": "src/" } diff --git a/config.subsplit-publish.json b/config.subsplit-publish.json index 062b2d9..a1bb6b3 100644 --- a/config.subsplit-publish.json +++ b/config.subsplit-publish.json @@ -9,6 +9,11 @@ "name": "aide-error", "directory": "src/Error", "target": "git@github.com:bakame-php/aide-error.git" + }, + { + "name": "aide-base32", + "directory": "src/Base32", + "target": "git@github.com:bakame-php/aide-base32.git" } ] } diff --git a/src/Base32/.gitattributes b/src/Base32/.gitattributes new file mode 100644 index 0000000..adbaa59 --- /dev/null +++ b/src/Base32/.gitattributes @@ -0,0 +1,6 @@ +* text=auto + +.github export-ignore +.gitattributes export-ignore +README.md export-ignore +**/*Test.php export-ignore diff --git a/src/Base32/.github/FUNDING.yml b/src/Base32/.github/FUNDING.yml new file mode 100644 index 0000000..70fdf4b --- /dev/null +++ b/src/Base32/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [nyamsprod] diff --git a/src/Base32/.github/workflows/close-subsplit-prs.yaml b/src/Base32/.github/workflows/close-subsplit-prs.yaml new file mode 100644 index 0000000..4f80eef --- /dev/null +++ b/src/Base32/.github/workflows/close-subsplit-prs.yaml @@ -0,0 +1,17 @@ +on: + schedule: + - cron: '30 7 * * *' +jobs: + close_subsplit_prs: + runs-on: ubuntu-latest + name: Close sub-split PRs + steps: + - uses: frankdejonge/action-close-subsplit-pr@0.1.0 + with: + close_pr: 'yes' + target_branch_match: '^(?!main).+$' + message: | + Hi :wave:, + + Thank you for contributing to Aide. Unfortunately, you've sent a PR to a read-only sub-split repository. + All pull requests should be directed towards: https://github.com/bakame-php/aide diff --git a/src/Base32/Base32.php b/src/Base32/Base32.php new file mode 100644 index 0000000..46066dc --- /dev/null +++ b/src/Base32/Base32.php @@ -0,0 +1,221 @@ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=', + self::Hex => '0123456789ABCDEFGHIJKLMNOPQRSTUV=', + }; + } + + private function pattern(): string + { + return match ($this) { + self::Ascii => '/[^A-Z2-7=]/', + self::Hex => '/[^0-9A-V=]/', + }; + } + + /** + * @return array + */ + private function mapping(): array + { + return match ($this) { + self::Ascii => [ + '=' => 0b00000, + 'A' => 0b00000, + 'B' => 0b00001, + 'C' => 0b00010, + 'D' => 0b00011, + 'E' => 0b00100, + 'F' => 0b00101, + 'G' => 0b00110, + 'H' => 0b00111, + 'I' => 0b01000, + 'J' => 0b01001, + 'K' => 0b01010, + 'L' => 0b01011, + 'M' => 0b01100, + 'N' => 0b01101, + 'O' => 0b01110, + 'P' => 0b01111, + 'Q' => 0b10000, + 'R' => 0b10001, + 'S' => 0b10010, + 'T' => 0b10011, + 'U' => 0b10100, + 'V' => 0b10101, + 'W' => 0b10110, + 'X' => 0b10111, + 'Y' => 0b11000, + 'Z' => 0b11001, + '2' => 0b11010, + '3' => 0b11011, + '4' => 0b11100, + '5' => 0b11101, + '6' => 0b11110, + '7' => 0b11111, + ], + self::Hex => [ + '=' => 0b00000, + '0' => 0b00000, + '1' => 0b00001, + '2' => 0b00010, + '3' => 0b00011, + '4' => 0b00100, + '5' => 0b00101, + '6' => 0b00110, + '7' => 0b00111, + '8' => 0b01000, + '9' => 0b01001, + 'A' => 0b01010, + 'B' => 0b01011, + 'C' => 0b01100, + 'D' => 0b01101, + 'E' => 0b01110, + 'F' => 0b01111, + 'G' => 0b10000, + 'H' => 0b10001, + 'I' => 0b10010, + 'J' => 0b10011, + 'K' => 0b10100, + 'L' => 0b10101, + 'M' => 0b10110, + 'N' => 0b10111, + 'O' => 0b11000, + 'P' => 0b11001, + 'Q' => 0b11010, + 'R' => 0b11011, + 'S' => 0b11100, + 'T' => 0b11101, + 'U' => 0b11110, + 'V' => 0b11111, + ], + }; + } + + public function encode(string $decoded): string + { + if ('' === $decoded) { + return ''; + } + + $encoded = ''; + $n = 0; + $bitLen = 0; + $val = 0; + $len = strlen($decoded); + $decoded .= str_repeat(chr(0), 4); + $chars = (array) unpack('C*', $decoded); + $alphabet = $this->alphabet(); + + while ($n < $len || 0 !== $bitLen) { + if ($bitLen < 5) { + $val = $val << 8; + $bitLen += 8; + $n++; + $val += $chars[$n]; + } + $shift = $bitLen - 5; + $encoded .= ($n - (int)($bitLen > 8) > $len && 0 == $val) ? '=' : $alphabet[$val >> $shift]; + $val = $val & ((1 << $shift) - 1); + $bitLen -= 5; + } + + return $encoded; + } + + /** + * @throws Base32Exception if the encoded string is invalid + */ + public function decodeOrFail(string $encoded): string + { + if ('' === $encoded) { + return ''; + } + + if (strtoupper($encoded) !== $encoded) { + throw new Base32Exception('The encoded string contains lower-cased characters which is forbidden on strict mode.'); + } + + if (0 !== (strlen($encoded) % 8)) { + throw new Base32Exception('The encoded string length is not a multiple of 8.'); + } + + if (str_contains(rtrim($encoded, '='), '=')) { + throw new Base32Exception('A padding character is contained in the middle of the encoded string.'); + } + + if (1 !== preg_match('/^[^=]+((=){3,4}|(=){6}|=)?$/', $encoded)) { + throw new Base32Exception('The encoded string contains an invalid padding length.'); + } + + if (1 === preg_match($this->pattern(), $encoded)) { + throw new Base32Exception('The encoded string contains characters outside of the base32 '.(Base32::Hex === $this ? 'Extended Hex' : 'US-ASCII').' alphabet.'); + } + + return $this->decode($encoded); + } + + public function decode(string $encoded): string + { + $encoded = strtoupper($encoded); + $encoded = preg_replace($this->pattern(), '', $encoded); + if ('' === $encoded || null === $encoded) { + return ''; + } + + $decoded = ''; + $mapping = $this->mapping(); + $len = strlen($encoded); + $n = 0; + $bitLen = 5; + $val = $mapping[$encoded[0]]; + + while ($n < $len) { + if ($bitLen < 8) { + $val = $val << 5; + $bitLen += 5; + $n++; + $pentet = $encoded[$n] ?? '='; + if ('=' === $pentet) { + $n = $len; + } + $val += $mapping[$pentet]; + continue; + } + + $shift = $bitLen - 8; + $decoded .= chr($val >> $shift); + $val = $val & ((1 << $shift) - 1); + $bitLen -= 8; + } + + return $decoded; + } +} diff --git a/src/Base32/Base32Exception.php b/src/Base32/Base32Exception.php new file mode 100644 index 0000000..4df63aa --- /dev/null +++ b/src/Base32/Base32Exception.php @@ -0,0 +1,11 @@ + + */ + private const BASE_CLEAR_STRINGS = [ + 'Empty String' => [''], + 'Ten' => ['10'], + 'Test130' => ['test130'], + 'test' => ['test'], + 'Eight' => ['8'], + 'Zero' => ['0'], + 'Equals' => ['='], + 'Foobar' => ['foobar'], + ]; + + /** + * Vectors from RFC with cleartext => base32 pairs. + * + * @var array + */ + private const RFC_VECTORS = [ + 'ASCII' => [ + 'RFC Vector 1' => ['f', 'MY======'], + 'RFC Vector 2' => ['fo', 'MZXQ===='], + 'RFC Vector 3' => ['foo', 'MZXW6==='], + 'RFC Vector 4' => ['foob', 'MZXW6YQ='], + 'RFC Vector 5' => ['fooba', 'MZXW6YTB'], + 'RFC Vector 6' => ['foobar', 'MZXW6YTBOI======'], + 'Old Vector 1' => [' ', 'EA======'], + 'Old Vector 2' => [' ', 'EAQA===='], + 'Old Vector 3' => [' ', 'EAQCA==='], + 'Old Vector 4' => [' ', 'EAQCAIA='], + 'Old Vector 5' => [' ', 'EAQCAIBA'], + 'Old Vector 6' => [' ', 'EAQCAIBAEA======'], + ], + 'HEX' => [ + 'RFC Vector 1' => ['f', 'CO======'], + 'RFC Vector 2' => ['fo', 'CPNG===='], + 'RFC Vector 3' => ['foo', 'CPNMU==='], + 'RFC Vector 4' => ['foob', 'CPNMUOG='], + 'RFC Vector 5' => ['fooba', 'CPNMUOJ1'], + 'RFC Vector 6' => ['foobar', 'CPNMUOJ1E8======'], + ], + ]; + + /** + * @return array + */ + public static function base32decodeAsciiDataProvider(): array + { + $decodedData = [ + 'Empty String' => ['', ''], + 'All Invalid Characters' => ['', '8908908908908908'], + 'Random Integers' => [base64_decode('HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs='), 'DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===='], + 'Partial zero edge case' => ['8', 'HA======'], + ]; + + return [...$decodedData, ...self::RFC_VECTORS['ASCII']]; + } + + /** + * @return array + */ + public static function base32encodeAsciiDataProvider(): array + { + $encodeData = [ + 'Empty String' => ['', ''], + 'Random Integers' => [base64_decode('HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs='), 'DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===='], + 'Partial zero edge case' => ['8', 'HA======'], + ]; + + return [...$encodeData, ...self::RFC_VECTORS['ASCII']]; + } + + /** + * Back and forth encoding must return the same result. + * + * @return array + */ + public static function backAndForthDataProvider(): array + { + return self::BASE_CLEAR_STRINGS; + } + + /** + * @return array + */ + public static function base32decodeHexDataProvider(): array + { + $decodedData = [ + 'Empty String' => ['', ''], + 'All Invalid Characters' => ['', 'WXYXWXYZWXYZWXYZ'], + 'Random Integers' => [base64_decode('HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs='), '3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===='], + ]; + + return [...$decodedData, ...self::RFC_VECTORS['HEX']]; + } + + /** + * @return array + */ + public static function base32encodeHexDataProvider(): array + { + $encodeData = [ + 'Empty String' => ['', ''], + 'Random Integers' => [base64_decode('HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs='), '3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===='], + ]; + + return [...$encodeData, ...self::RFC_VECTORS['HEX']]; + } + + #[DataProvider('base32decodeAsciiDataProvider')] + #[Test] + public function it_will_base32_decode_on_ascii_mode(string $clear, string $base32): void + { + self::assertEquals($clear, base32_decode($base32)); + } + + #[DataProvider('base32encodeAsciiDataProvider')] + #[Test] + public function it_will_base32_encode_on_ascii_mode(string $clear, string $base32): void + { + self::assertEquals($base32, base32_encode($clear)); + } + + #[DataProvider('base32decodeHexDataProvider')] + #[Test] + public function it_will_base32_decode_on_hex_mode(string $clear, string $base32): void + { + self::assertEquals($clear, base32_decode($base32, PHP_BASE32_HEX)); + self::assertEquals($clear, base32_decode($base32, PHP_BASE32_HEX, true)); + } + + #[DataProvider('base32encodeHexDataProvider')] + #[Test] + public function it_will_base32_encode_on_hex_mode(string $clear, string $base32): void + { + self::assertEquals($base32, base32_encode($clear, PHP_BASE32_HEX)); + } + + #[DataProvider('backAndForthDataProvider')] + #[Test] + public function it_will_base32_encode_and_decode(string $clear): void + { + self::assertEquals($clear, base32_decode(base32_encode($clear))); + self::assertEquals($clear, base32_decode(base32_encode($clear, PHP_BASE32_HEX), PHP_BASE32_HEX)); + } + + #[DataProvider('invalidDecodingSequence')] + #[Test] + public function it_will_throw_on_strict_mode_with_invalid_encoded_string_on_decode(string $sequence, string $message, int $encoding): void + { + $this->expectException(Base32Exception::class); + $this->expectExceptionMessage($message); + + match ($encoding) { + PHP_BASE32_HEX => Base32::Hex->decodeOrFail($sequence), + default => Base32::Ascii->decodeOrFail($sequence), + }; + } + + #[DataProvider('invalidDecodingSequence')] + #[Test] + public function it_will_return_false_from_invalid_encoded_string_with_base32_decode_function(string $sequence, string $message, int $encoding): void + { + self::assertFalse(base32_decode($sequence, $encoding, true)); + } + + /** + * @return iterable}> + */ + public static function invalidDecodingSequence(): iterable + { + yield 'characters outside of base32 extended hex alphabet' => [ + 'sequence' => 'MZXQ====', + 'message' => 'The encoded string contains characters outside of the base32 Extended Hex alphabet.', + 'encoding' => PHP_BASE32_HEX, + ]; + + yield 'characters outside of base32 us ascii alphabet' => [ + 'sequence' => '90890808', + 'message' => 'The encoded string contains characters outside of the base32 US-ASCII alphabet.', + 'encoding' => PHP_BASE32_ASCII, + ]; + + yield 'characters not upper-cased' => [ + 'sequence' => 'MzxQ====', + 'message' => 'The encoded string contains lower-cased characters which is forbidden on strict mode.', + 'encoding' => PHP_BASE32_ASCII, + ]; + + yield 'padding character in the middle of the sequence' => [ + 'sequence' => 'A=ACA===', + 'message' => 'A padding character is contained in the middle of the encoded string.', + 'encoding' => PHP_BASE32_ASCII, + ]; + + yield 'invalid padding length' => [ + 'sequence' => 'A=======', + 'message' => 'The encoded string contains an invalid padding length.', + 'encoding' => PHP_BASE32_ASCII, + ]; + + yield 'invalid encoded string length' => [ + 'sequence' => 'A', + 'message' => 'The encoded string length is not a multiple of 8.', + 'encoding' => PHP_BASE32_HEX, + ]; + } +} diff --git a/src/Base32/LICENSE b/src/Base32/LICENSE new file mode 100644 index 0000000..ea6b459 --- /dev/null +++ b/src/Base32/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 ignace nyamagana butera + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/Base32/README.md b/src/Base32/README.md new file mode 100644 index 0000000..16d46c1 --- /dev/null +++ b/src/Base32/README.md @@ -0,0 +1,49 @@ +# Aide for base32 encoding and decoding + +functions or class to allow encoding or decoding strings using [RFC4648](https://datatracker.ietf.org/doc/html/rfc4648) base32 algorithm. + +> [!CAUTION] +> Sub-split of Aide for Error. +> ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/bakame-php/aide + +## Installation + +### Composer + +~~~ +composer require bakame-php/aide-error +~~~ + +### System Requirements + +You need: + +- **PHP >= 8.1** but the latest stable version of PHP is recommended + +## Usage + +The package provides a userland base32 encoding and decoding mechanism. + +You can either use the `Base32` Enumeration as shown below: + +```php +use Bakame\Aide\Base32\Base32; + +Base32::Ascii->encode('Bangui'); // returns 'IJQW4Z3VNE======' +Base32::Ascii->decode('IJQW4Z3VNE======'); // returns 'Bangui' +Base32::Hex->encode('Bangui'); // returns '89GMSPRLD4======' +Base32::Hex->decodeOrFail('89GMSPRLD4======'); // returns 'Bangui' +``` + +or use the equivalent functions in the default scope + +```php + +base32_encode('Bangui'); // returns 'IJQW4Z3VNE======' +base32_decode('IJQW4Z3VNE======'); // returns 'Bangui' +base32_encode('Bangui', PHP_BASE32_HEX); // returns '89GMSPRLD4======' +base32_decode('89GMSPRLD4======', PHP_BASE32_HEX, true); // returns 'Bangui' +``` + +In case of an error during decoding the `Base32` enumeration will throw a `Base23Exception` while +the equivalent functions will simply return `false`; diff --git a/src/Base32/composer.json b/src/Base32/composer.json new file mode 100644 index 0000000..b77e4b6 --- /dev/null +++ b/src/Base32/composer.json @@ -0,0 +1,43 @@ +{ + "name": "bakame/aide-base32", + "description": "base32 encoding and decoding using functions or class in PHP", + "type": "library", + "keywords": ["base32", "encoding", "decoding", "RFC4648"], + "license": "MIT", + "authors": [ + { + "name" : "Ignace Nyamagana Butera", + "email" : "nyamsprod@gmail.com", + "homepage" : "https://github.com/nyamsprod/", + "role" : "Developer" + } + ], + "support": { + "docs": "https://github.com/bakame-php/aide", + "issues": "https://github.com/bakame-php/aide", + "source": "https://github.com/bakame-php/aide" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nyamsprod" + } + ], + "require": { + "php" : "^8.1" + }, + "autoload": { + "psr-4": { + "Bakame\\Aide\\Base32\\": "" + }, + "files": ["functions.php"] + }, + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/src/Base32/functions.php b/src/Base32/functions.php new file mode 100644 index 0000000..a6cb3ee --- /dev/null +++ b/src/Base32/functions.php @@ -0,0 +1,43 @@ + Base32::Hex->encode($decoded), + default => Base32::Ascii->encode($decoded), + }; + } +} + +if (!function_exists('base32_decode')) { + function base32_decode(string $encoded, int $encoding = PHP_BASE32_ASCII, bool $strict = false): string|false + { + $base32 = match ($encoding) { + PHP_BASE32_HEX => Base32::Hex, + default => Base32::Ascii, + }; + + if (!$strict) { + return $base32->decode($encoded); + } + + try { + return $base32->decodeOrFail($encoded); + } catch (Throwable) { + return false; + } + } +}