diff --git a/composer.json b/composer.json index 14add8d..99ef082 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "replace": { "nexusphp/clock": "self.version", "nexusphp/collection": "self.version", + "nexusphp/encryption": "self.version", "nexusphp/option": "self.version", "nexusphp/password": "self.version", "nexusphp/phpstan-nexus": "self.version", diff --git a/infection.json5 b/infection.json5 index af668f9..193f6fe 100644 --- a/infection.json5 +++ b/infection.json5 @@ -109,7 +109,6 @@ "LogicalOrAllSubExprNegation": true, "LogicalOrNegation": true, "LogicalOrSingleSubExprNegation": true, - "MBString": true, "MatchArmRemoval": true, "MethodCallRemoval": true, "MinusEqual": true, @@ -145,7 +144,11 @@ "SpreadRemoval": true, "Ternary": true, "This": true, - "Throw_": true, + "Throw_": { + "ignore": [ + "Nexus\\Encryption\\Key::__unserialize" + ] + }, "TrueValue": { "ignore": [ "Nexus\\Collection\\Collection::generateDiffHashTable" diff --git a/src/Nexus/Encryption/BinaryStr.php b/src/Nexus/Encryption/BinaryStr.php new file mode 100644 index 0000000..f2276bc --- /dev/null +++ b/src/Nexus/Encryption/BinaryStr.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption; + +/** + * Binary string operations wrapping on related `mb_*` functions. + */ +final class BinaryStr +{ + /** + * Get the length of a binary string. + */ + public static function strlen(#[\SensitiveParameter] string $string): int + { + return mb_strlen($string, '8bit'); + } + + /** + * Get a substring of a binary string. + */ + public static function substr(#[\SensitiveParameter] string $string, int $start, ?int $length = null): string + { + return mb_substr($string, $start, $length, '8bit'); + } +} diff --git a/src/Nexus/Encryption/Cryptex.php b/src/Nexus/Encryption/Cryptex.php new file mode 100644 index 0000000..c9c3ed7 --- /dev/null +++ b/src/Nexus/Encryption/Cryptex.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption; + +use Nexus\Encryption\Exception\InvalidEncodingVariantException; + +final readonly class Cryptex +{ + /** + * Version tag in the form 'v' + major + minor + patch. + * + * Note: Increment only the major and minor versions when making updates. + */ + public const string HEADER_VERSION = "\x76\x01\x00\x00"; + + /** + * Length of the version header. + */ + public const int HEADER_VERSION_SIZE = 4; + + public const int ENCODE_NONE = 0; + public const int ENCODE_BASE64_ORIGINAL = 1; + public const int ENCODE_BASE64_ORIGINAL_NO_PADDING = 2; + public const int ENCODE_BASE64_URL_SAFE = 3; + public const int ENCODE_BASE64_URL_SAFE_NO_PADDING = 4; + public const int ENCODE_HEX = 5; + + /** + * Gets the encoder. If `self::ENCODE_NONE` is chosen, any subsequent encode + * and decode operations will just return the strings as-is. + * + * @throws InvalidEncodingVariantException + */ + public static function encoder(int $variant = self::ENCODE_HEX): Encoding\EncoderInterface + { + return match ($variant) { + self::ENCODE_NONE => new Encoding\NullEncoder(), + self::ENCODE_BASE64_ORIGINAL => new Encoding\Base64OriginalEncoder(), + self::ENCODE_BASE64_ORIGINAL_NO_PADDING => new Encoding\Base64OriginalNoPaddingEncoder(), + self::ENCODE_BASE64_URL_SAFE => new Encoding\Base64UrlSafeEncoder(), + self::ENCODE_BASE64_URL_SAFE_NO_PADDING => new Encoding\Base64UrlSafeNoPaddingEncoder(), + self::ENCODE_HEX => new Encoding\HexEncoder(), + default => throw new InvalidEncodingVariantException(), + }; + } +} diff --git a/src/Nexus/Encryption/Encoding/AbstractBase64Encoder.php b/src/Nexus/Encryption/Encoding/AbstractBase64Encoder.php new file mode 100644 index 0000000..63c0504 --- /dev/null +++ b/src/Nexus/Encryption/Encoding/AbstractBase64Encoder.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +abstract class AbstractBase64Encoder implements EncoderInterface +{ + public function encode(#[\SensitiveParameter] string $binaryString): string + { + return sodium_bin2base64($binaryString, $this->getBase64Variant()); + } + + public function decode(#[\SensitiveParameter] string $encodedString, string $ignore = ''): string + { + return sodium_base642bin($encodedString, $this->getBase64Variant(), $ignore); + } + + /** + * Get the variant of Base64 encoding to use. + */ + abstract protected function getBase64Variant(): int; +} diff --git a/src/Nexus/Encryption/Encoding/Base64OriginalEncoder.php b/src/Nexus/Encryption/Encoding/Base64OriginalEncoder.php new file mode 100644 index 0000000..0f71187 --- /dev/null +++ b/src/Nexus/Encryption/Encoding/Base64OriginalEncoder.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +/** + * Standard (A-Za-z0-9/\+) Base64 encoder. + */ +final class Base64OriginalEncoder extends AbstractBase64Encoder +{ + protected function getBase64Variant(): int + { + return SODIUM_BASE64_VARIANT_ORIGINAL; + } +} diff --git a/src/Nexus/Encryption/Encoding/Base64OriginalNoPaddingEncoder.php b/src/Nexus/Encryption/Encoding/Base64OriginalNoPaddingEncoder.php new file mode 100644 index 0000000..d064bc3 --- /dev/null +++ b/src/Nexus/Encryption/Encoding/Base64OriginalNoPaddingEncoder.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +/** + * Standard (A-Za-z0-9/\+) Base64 encoder without `=` padding characters. + */ +final class Base64OriginalNoPaddingEncoder extends AbstractBase64Encoder +{ + protected function getBase64Variant(): int + { + return SODIUM_BASE64_VARIANT_ORIGINAL_NO_PADDING; + } +} diff --git a/src/Nexus/Encryption/Encoding/Base64UrlSafeEncoder.php b/src/Nexus/Encryption/Encoding/Base64UrlSafeEncoder.php new file mode 100644 index 0000000..945a8ec --- /dev/null +++ b/src/Nexus/Encryption/Encoding/Base64UrlSafeEncoder.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +/** + * URL-safe (A-Za-z0-9\-_) Base64 encoder. + */ +final class Base64UrlSafeEncoder extends AbstractBase64Encoder +{ + protected function getBase64Variant(): int + { + return SODIUM_BASE64_VARIANT_URLSAFE; + } +} diff --git a/src/Nexus/Encryption/Encoding/Base64UrlSafeNoPaddingEncoder.php b/src/Nexus/Encryption/Encoding/Base64UrlSafeNoPaddingEncoder.php new file mode 100644 index 0000000..512356d --- /dev/null +++ b/src/Nexus/Encryption/Encoding/Base64UrlSafeNoPaddingEncoder.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +/** + * URL-safe (A-Za-z0-9\-_) Base64 encoder, without `=` padding characters. + */ +final class Base64UrlSafeNoPaddingEncoder extends AbstractBase64Encoder +{ + protected function getBase64Variant(): int + { + return SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING; + } +} diff --git a/src/Nexus/Encryption/Encoding/EncoderInterface.php b/src/Nexus/Encryption/Encoding/EncoderInterface.php new file mode 100644 index 0000000..c1c5e1f --- /dev/null +++ b/src/Nexus/Encryption/Encoding/EncoderInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +interface EncoderInterface +{ + /** + * Encodes a binary string into a readable format without cache-timing attacks. + */ + public function encode(#[\SensitiveParameter] string $binaryString): string; + + /** + * Converts an encoded string back to its original binary format without cache-timing attacks. + * + * @param string $ignore Characters to ignore when decoding (e.g. whitespace characters) + */ + public function decode(#[\SensitiveParameter] string $encodedString, string $ignore = ''): string; +} diff --git a/src/Nexus/Encryption/Encoding/HexEncoder.php b/src/Nexus/Encryption/Encoding/HexEncoder.php new file mode 100644 index 0000000..65b7868 --- /dev/null +++ b/src/Nexus/Encryption/Encoding/HexEncoder.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +/** + * Encode/decode binary data to/from hexadecimal strings without any side-channel attacks. + */ +final class HexEncoder implements EncoderInterface +{ + public function encode(#[\SensitiveParameter] string $binaryString): string + { + return sodium_bin2hex($binaryString); + } + + public function decode(#[\SensitiveParameter] string $encodedString, string $ignore = ''): string + { + return sodium_hex2bin($encodedString, $ignore); + } +} diff --git a/src/Nexus/Encryption/Encoding/NullEncoder.php b/src/Nexus/Encryption/Encoding/NullEncoder.php new file mode 100644 index 0000000..3a53bea --- /dev/null +++ b/src/Nexus/Encryption/Encoding/NullEncoder.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Encoding; + +final class NullEncoder implements EncoderInterface +{ + public function encode(#[\SensitiveParameter] string $binaryString): string + { + return $binaryString; + } + + public function decode(#[\SensitiveParameter] string $encodedString, string $ignore = ''): string + { + return $encodedString; + } +} diff --git a/src/Nexus/Encryption/Exception/InvalidEncodingVariantException.php b/src/Nexus/Encryption/Exception/InvalidEncodingVariantException.php new file mode 100644 index 0000000..d79ec84 --- /dev/null +++ b/src/Nexus/Encryption/Exception/InvalidEncodingVariantException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption\Exception; + +final class InvalidEncodingVariantException extends \RuntimeException +{ + public function __construct() + { + parent::__construct('Unknown variant for encoder.'); + } +} diff --git a/src/Nexus/Encryption/Key.php b/src/Nexus/Encryption/Key.php new file mode 100644 index 0000000..093dcfa --- /dev/null +++ b/src/Nexus/Encryption/Key.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption; + +abstract class Key +{ + private ?string $keyString; + + public function __construct(Secret $key) + { + $this->keyString = $key->reveal(); + } + + public function __destruct() + { + sodium_memzero($this->keyString); + } + + public function __clone(): void + { + throw new \BadMethodCallException(\sprintf('Cannot clone a %s object.', basename(static::class))); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return ['keyString' => '[redacted]']; + } + + public function __serialize(): never + { + throw new \BadMethodCallException(\sprintf('Cannot serialise a %s object.', static::class)); + } + + /** + * @param array $data + */ + public function __unserialize(array $data): never + { + throw new \BadMethodCallException(\sprintf('Cannot unserialise a %s object.', static::class)); // @codeCoverageIgnore + } + + public function getKeyString(): string + { + return $this->keyString ?? ''; + } +} diff --git a/src/Nexus/Encryption/LICENSE b/src/Nexus/Encryption/LICENSE new file mode 100644 index 0000000..ce701d4 --- /dev/null +++ b/src/Nexus/Encryption/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 John Paul E. Balandan, CPA + +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/Nexus/Encryption/README.md b/src/Nexus/Encryption/README.md new file mode 100644 index 0000000..8a8bc26 --- /dev/null +++ b/src/Nexus/Encryption/README.md @@ -0,0 +1,20 @@ +# Nexus Encryption + +## Installation + + composer require nexusphp/encryption + +## Getting Started + +## License + +Nexus Encryption is licensed under the [MIT License][1]. + +## Resources + +* [Report issues][2] and [send pull requests][3] in the [main Nexus repository][4] + +[1]: LICENSE +[2]: https://github.com/NexusPHP/framework/issues +[3]: https://github.com/NexusPHP/framework/pulls +[4]: https://github.com/NexusPHP/framework diff --git a/src/Nexus/Encryption/Secret.php b/src/Nexus/Encryption/Secret.php new file mode 100644 index 0000000..f500f49 --- /dev/null +++ b/src/Nexus/Encryption/Secret.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Encryption; + +/** + * An object representation for a secret. This encapsulates sensitive strings + * in a read-only object that forbids serialisation and dumping. + * + * This should be used for: + * - secret keys + * - plaintext (before encryption) + * - plaintext (after decryption) + */ +final class Secret +{ + /** + * @throws \RuntimeException + */ + public function __construct( + #[\SensitiveParameter] + private ?string $value, + ) { + if (null === $this->value) { + throw new \RuntimeException('Secret cannot accept null.'); + } + } + + public function __destruct() + { + sodium_memzero($this->value); + } + + /** + * @throws \BadMethodCallException + */ + public function __serialize(): never + { + throw new \BadMethodCallException('Cannot serialise a Secret object.'); + } + + /** + * @return array{value: string} + */ + public function __debugInfo(): array + { + return ['value' => '[redacted]']; + } + + public function equals(self $other): bool + { + return hash_equals($this->reveal(), $other->reveal()); + } + + public function reveal(): string + { + return $this->value ?? ''; + } +} diff --git a/src/Nexus/Encryption/composer.json b/src/Nexus/Encryption/composer.json new file mode 100644 index 0000000..40a5aa6 --- /dev/null +++ b/src/Nexus/Encryption/composer.json @@ -0,0 +1,38 @@ +{ + "name": "nexusphp/encryption", + "description": "The Nexus Encryption library.", + "license": "MIT", + "type": "library", + "keywords": [ + "nexus", + "encryption" + ], + "authors": [ + { + "name": "John Paul E. Balandan, CPA", + "email": "paulbalandan@gmail.com" + } + ], + "support": { + "issues": "https://github.com/NexusPHP/framework/issues", + "source": "https://github.com/NexusPHP/framework" + }, + "require": { + "php": "^8.3", + "ext-mbstring": "*", + "ext-sodium": "*", + "nexusphp/suppression": "^1.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Nexus\\Encryption\\": "" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/tests/Encryption/BinaryStrTest.php b/tests/Encryption/BinaryStrTest.php new file mode 100644 index 0000000..ce8e431 --- /dev/null +++ b/tests/Encryption/BinaryStrTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption; + +use Nexus\Encryption\BinaryStr; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(BinaryStr::class)] +#[Group('unit-test')] +final class BinaryStrTest extends TestCase +{ + public function testStrlen(): void + { + $binaryStr = str_repeat("\x00", 10); + self::assertSame(10, BinaryStr::strlen($binaryStr)); + } + + public function testSubstr(): void + { + $binaryStr = str_repeat("\x00", 10); + self::assertSame("\x00\x00", BinaryStr::substr($binaryStr, 0, 2)); + } +} diff --git a/tests/Encryption/CryptexTest.php b/tests/Encryption/CryptexTest.php new file mode 100644 index 0000000..1ef9640 --- /dev/null +++ b/tests/Encryption/CryptexTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption; + +use Nexus\Encryption\Cryptex; +use Nexus\Encryption\Encoding\Base64OriginalEncoder; +use Nexus\Encryption\Encoding\Base64OriginalNoPaddingEncoder; +use Nexus\Encryption\Encoding\Base64UrlSafeEncoder; +use Nexus\Encryption\Encoding\Base64UrlSafeNoPaddingEncoder; +use Nexus\Encryption\Encoding\EncoderInterface; +use Nexus\Encryption\Encoding\HexEncoder; +use Nexus\Encryption\Encoding\NullEncoder; +use Nexus\Encryption\Exception\InvalidEncodingVariantException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(Cryptex::class)] +#[Group('unit-test')] +final class CryptexTest extends TestCase +{ + public function testInvalidVariantThrows(): void + { + $this->expectException(InvalidEncodingVariantException::class); + $this->expectExceptionMessage('Unknown variant for encoder.'); + + Cryptex::encoder(10); + } + + /** + * @param class-string $expectedEncoder + */ + #[DataProvider('provideEncoderCases')] + public function testEncoder(int $variant, string $expectedEncoder): void + { + self::assertInstanceOf($expectedEncoder, Cryptex::encoder($variant)); + } + + /** + * @return iterable}> + */ + public static function provideEncoderCases(): iterable + { + yield 'base64 original' => [Cryptex::ENCODE_BASE64_ORIGINAL, Base64OriginalEncoder::class]; + + yield 'base 64 original no padding' => [Cryptex::ENCODE_BASE64_ORIGINAL_NO_PADDING, Base64OriginalNoPaddingEncoder::class]; + + yield 'base 64 URL safe' => [Cryptex::ENCODE_BASE64_URL_SAFE, Base64UrlSafeEncoder::class]; + + yield 'base 64 URL safe no padding' => [Cryptex::ENCODE_BASE64_URL_SAFE_NO_PADDING, Base64UrlSafeNoPaddingEncoder::class]; + + yield 'hex' => [Cryptex::ENCODE_HEX, HexEncoder::class]; + + yield 'none' => [Cryptex::ENCODE_NONE, NullEncoder::class]; + } +} diff --git a/tests/Encryption/Encoding/AbstractEncoderTestCase.php b/tests/Encryption/Encoding/AbstractEncoderTestCase.php new file mode 100644 index 0000000..eed9406 --- /dev/null +++ b/tests/Encryption/Encoding/AbstractEncoderTestCase.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\EncoderInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +abstract class AbstractEncoderTestCase extends TestCase +{ + /** + * @param int<1, 32> $i + */ + #[DataProvider('provideEncodeDecodeCases')] + public function testEncodeDecode(int $i): void + { + $encoder = $this->createEncoder(); + $data = random_bytes($i); + + $encoded = $encoder->encode($data); + $decoded = $encoder->decode($encoded); + + self::assertSame($data, $decoded); + self::assertSame($this->nativeEncode($data), $encoded); + self::assertSame($this->nativeDecode($encoded), $decoded); + } + + /** + * @return iterable + */ + public static function provideEncodeDecodeCases(): iterable + { + for ($i = 1; $i <= 32; ++$i) { + yield \sprintf('%02d bytes', $i) => [$i]; + } + } + + abstract protected function createEncoder(): EncoderInterface; + + abstract protected function nativeEncode(string $data): string; + + abstract protected function nativeDecode(string $encoded): string; +} diff --git a/tests/Encryption/Encoding/Base64OriginalEncoderTest.php b/tests/Encryption/Encoding/Base64OriginalEncoderTest.php new file mode 100644 index 0000000..16ac324 --- /dev/null +++ b/tests/Encryption/Encoding/Base64OriginalEncoderTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\AbstractBase64Encoder; +use Nexus\Encryption\Encoding\Base64OriginalEncoder; +use Nexus\Encryption\Encoding\EncoderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(AbstractBase64Encoder::class)] +#[CoversClass(Base64OriginalEncoder::class)] +#[Group('unit-test')] +final class Base64OriginalEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new Base64OriginalEncoder(); + } + + protected function nativeEncode(string $data): string + { + return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_ORIGINAL); + } + + protected function nativeDecode(string $encoded): string + { + return sodium_base642bin($encoded, SODIUM_BASE64_VARIANT_ORIGINAL); + } +} diff --git a/tests/Encryption/Encoding/Base64OriginalNoPaddingEncoderTest.php b/tests/Encryption/Encoding/Base64OriginalNoPaddingEncoderTest.php new file mode 100644 index 0000000..6bfc517 --- /dev/null +++ b/tests/Encryption/Encoding/Base64OriginalNoPaddingEncoderTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\AbstractBase64Encoder; +use Nexus\Encryption\Encoding\Base64OriginalNoPaddingEncoder; +use Nexus\Encryption\Encoding\EncoderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(AbstractBase64Encoder::class)] +#[CoversClass(Base64OriginalNoPaddingEncoder::class)] +#[Group('unit-test')] +final class Base64OriginalNoPaddingEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new Base64OriginalNoPaddingEncoder(); + } + + protected function nativeEncode(string $data): string + { + return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_ORIGINAL_NO_PADDING); + } + + protected function nativeDecode(string $encoded): string + { + return sodium_base642bin($encoded, SODIUM_BASE64_VARIANT_ORIGINAL_NO_PADDING); + } +} diff --git a/tests/Encryption/Encoding/Base64UrlSafeEncoderTest.php b/tests/Encryption/Encoding/Base64UrlSafeEncoderTest.php new file mode 100644 index 0000000..144313b --- /dev/null +++ b/tests/Encryption/Encoding/Base64UrlSafeEncoderTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\AbstractBase64Encoder; +use Nexus\Encryption\Encoding\Base64UrlSafeEncoder; +use Nexus\Encryption\Encoding\EncoderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(AbstractBase64Encoder::class)] +#[CoversClass(Base64UrlSafeEncoder::class)] +#[Group('unit-test')] +final class Base64UrlSafeEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new Base64UrlSafeEncoder(); + } + + protected function nativeEncode(string $data): string + { + return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE); + } + + protected function nativeDecode(string $encoded): string + { + return sodium_base642bin($encoded, SODIUM_BASE64_VARIANT_URLSAFE); + } +} diff --git a/tests/Encryption/Encoding/Base64UrlSafeNoPaddingEncoderTest.php b/tests/Encryption/Encoding/Base64UrlSafeNoPaddingEncoderTest.php new file mode 100644 index 0000000..3de971e --- /dev/null +++ b/tests/Encryption/Encoding/Base64UrlSafeNoPaddingEncoderTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\AbstractBase64Encoder; +use Nexus\Encryption\Encoding\Base64UrlSafeNoPaddingEncoder; +use Nexus\Encryption\Encoding\EncoderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(AbstractBase64Encoder::class)] +#[CoversClass(Base64UrlSafeNoPaddingEncoder::class)] +#[Group('unit-test')] +final class Base64UrlSafeNoPaddingEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new Base64UrlSafeNoPaddingEncoder(); + } + + protected function nativeEncode(string $data): string + { + return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } + + protected function nativeDecode(string $encoded): string + { + return sodium_base642bin($encoded, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } +} diff --git a/tests/Encryption/Encoding/HexEncoderTest.php b/tests/Encryption/Encoding/HexEncoderTest.php new file mode 100644 index 0000000..76cf597 --- /dev/null +++ b/tests/Encryption/Encoding/HexEncoderTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\EncoderInterface; +use Nexus\Encryption\Encoding\HexEncoder; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(HexEncoder::class)] +#[Group('unit-test')] +final class HexEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new HexEncoder(); + } + + protected function nativeEncode(string $data): string + { + return sodium_bin2hex($data); + } + + protected function nativeDecode(string $data): string + { + return sodium_hex2bin($data); + } +} diff --git a/tests/Encryption/Encoding/NullEncoderTest.php b/tests/Encryption/Encoding/NullEncoderTest.php new file mode 100644 index 0000000..a0ccf99 --- /dev/null +++ b/tests/Encryption/Encoding/NullEncoderTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Encoding; + +use Nexus\Encryption\Encoding\EncoderInterface; +use Nexus\Encryption\Encoding\NullEncoder; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(NullEncoder::class)] +#[Group('unit-test')] +final class NullEncoderTest extends AbstractEncoderTestCase +{ + protected function createEncoder(): EncoderInterface + { + return new NullEncoder(); + } + + protected function nativeEncode(string $data): string + { + return $data; + } + + protected function nativeDecode(string $encoded): string + { + return $encoded; + } +} diff --git a/tests/Encryption/Exception/InvalidEncodingVariantExceptionTest.php b/tests/Encryption/Exception/InvalidEncodingVariantExceptionTest.php new file mode 100644 index 0000000..70b9bac --- /dev/null +++ b/tests/Encryption/Exception/InvalidEncodingVariantExceptionTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption\Exception; + +use Nexus\Encryption\Cryptex; +use Nexus\Encryption\Exception\InvalidEncodingVariantException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(InvalidEncodingVariantException::class)] +#[Group('unit-test')] +final class InvalidEncodingVariantExceptionTest extends TestCase +{ + public function testMessage(): void + { + try { + Cryptex::encoder(10); + } catch (InvalidEncodingVariantException $e) { + self::assertSame('Unknown variant for encoder.', $e->getMessage()); + } + } +} diff --git a/tests/Encryption/KeyTest.php b/tests/Encryption/KeyTest.php new file mode 100644 index 0000000..3f01abe --- /dev/null +++ b/tests/Encryption/KeyTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption; + +use Nexus\Encryption\Key; +use Nexus\Encryption\Secret; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(Key::class)] +#[Group('unit-test')] +final class KeyTest extends TestCase +{ + private Key&MockObject $key; + + protected function setUp(): void + { + parent::setUp(); + + $this->key = $this->getMockBuilder(Key::class) + ->setConstructorArgs([new Secret(random_bytes(32))]) + ->onlyMethods([]) + ->getMock() + ; + } + + public function testCannotCloneKey(): void + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage(\sprintf('Cannot clone a %s object.', \get_class($this->key))); + + clone $this->key; // @phpstan-ignore expr.resultUnused + } + + public function testCannotSerialiseKey(): void + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage(\sprintf('Cannot serialise a %s object.', \get_class($this->key))); + + serialize($this->key); + } + + public function testHidesKeyStringFromDump(): void + { + $secret = new Secret(random_bytes(32)); + $this->key = $this->getMockBuilder(Key::class) + ->setConstructorArgs([$secret]) + ->onlyMethods([]) + ->getMock() + ; + + ob_start(); + var_dump($this->key); + $dump = (string) ob_get_clean(); + $print = print_r($this->key, true); + + self::assertStringNotContainsString($secret->reveal(), $dump); + self::assertStringContainsString('[redacted]', $dump); + self::assertStringNotContainsString($secret->reveal(), $print); + self::assertStringContainsString('[redacted]', $print); + } + + public function testGetKeyString(): void + { + $secret = new Secret(random_bytes(32)); + $this->key = $this->getMockBuilder(Key::class) + ->setConstructorArgs([$secret]) + ->onlyMethods([]) + ->getMock() + ; + + self::assertSame($secret->reveal(), $this->key->getKeyString()); + } +} diff --git a/tests/Encryption/SecretTest.php b/tests/Encryption/SecretTest.php new file mode 100644 index 0000000..e0babf7 --- /dev/null +++ b/tests/Encryption/SecretTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Encryption; + +use Nexus\Encryption\Encoding\HexEncoder; +use Nexus\Encryption\Secret; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(Secret::class)] +#[Group('unit-test')] +final class SecretTest extends TestCase +{ + private string $data; + + protected function setUp(): void + { + $this->data = (new HexEncoder())->encode(random_bytes(32)); + } + + public function testCannotAcceptNullOnConstruct(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Secret cannot accept null.'); + + new Secret(null); + } + + public function testCannotSerialise(): void + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Cannot serialise a Secret object.'); + + serialize(new Secret($this->data)); + } + + public function testCannotDumpString(): void + { + $plaintext = new Secret($this->data); + + ob_start(); + var_dump($plaintext); + $dump = (string) ob_get_clean(); + $dump = preg_replace('/(\033\[[0-9;]+m)|(\035\[[0-9;]+m)/u', '', $dump) ?? $dump; + $print = print_r($plaintext, true); + + self::assertStringNotContainsString($this->data, $dump); + self::assertStringContainsString('[redacted]', $dump); + self::assertStringNotContainsString($this->data, $print); + self::assertStringContainsString('[redacted]', $print); + } + + public function testEquals(): void + { + $one = new Secret($this->data); + $two = new Secret($this->data); + $three = new Secret((new HexEncoder())->encode(random_bytes(32))); + + self::assertTrue($one->equals($two)); + self::assertFalse($one->equals($three)); + self::assertTrue($two->equals($one)); + self::assertFalse($two->equals($three)); + self::assertFalse($three->equals($one)); + self::assertFalse($three->equals($two)); + } + + public function testRevealNeverReturnsEmptyString(): void + { + $plaintext = new Secret($this->data); + + self::assertSame($this->data, $plaintext->reveal()); + } +} diff --git a/tools/src/InfectionConfigBuilder.php b/tools/src/InfectionConfigBuilder.php index a66b303..253622e 100644 --- a/tools/src/InfectionConfigBuilder.php +++ b/tools/src/InfectionConfigBuilder.php @@ -16,6 +16,7 @@ use Infection\Mutator\ProfileList; use Nexus\Clock\SystemClock; use Nexus\Collection\Collection; +use Nexus\Encryption\Key; use Nexus\Password\Hash\Pbkdf2Hash; use Nexus\Password\Hash\SodiumHash; @@ -42,6 +43,7 @@ final class InfectionConfigBuilder 'IntegerNegation', 'LessThan', 'LessThanOrEqualTo', + 'MBString', 'Minus', 'NotIdentical', 'NotIdenticalNotEqual', // @deprecated @@ -80,6 +82,9 @@ final class InfectionConfigBuilder 'ModEqual' => [ SystemClock::class, ], + 'Throw_' => [ + Key::class.'::__unserialize', + ], 'TrueValue' => [ Collection::class.'::generateDiffHashTable', ],