diff --git a/.github/workflows/test-monorepo.yml b/.github/workflows/test-monorepo.yml index 67cf48634..0e64f13cd 100644 --- a/.github/workflows/test-monorepo.yml +++ b/.github/workflows/test-monorepo.yml @@ -123,6 +123,9 @@ jobs: - name: Test PHPStan run: vendor/bin/phpstan + - name: Create random file for encryption tests + run: (cd packages/DataProtection && tests/before-tests.sh) + - name: Test PHPUnit on Postgres run: vendor/bin/phpunit --no-coverage env: diff --git a/composer.json b/composer.json index 5ba197ea2..a621f72f6 100644 --- a/composer.json +++ b/composer.json @@ -117,6 +117,8 @@ }, "require": { "php": "^8.2", + "ext-amqp": "*", + "ext-openssl": "*", "doctrine/dbal": "^3.9|^4.0", "doctrine/persistence": "^2.5|^3.4", "defuse/php-encryption": "^2.4", @@ -124,10 +126,10 @@ "enqueue/redis": "^0.10.9", "enqueue/sqs": "^0.10.15", "enqueue/enqueue": "^0.10.0", - "ext-amqp": "*", "laminas/laminas-code": "^4", "jms/serializer": "^3.32", "laravel/framework": "^9.5.2|^10.0|^11.0|^12.0|^13.0", + "paragonie/random_compat": "^2.0", "prooph/pdo-event-store": "^1.16.3", "psr/log": "^2.0|^3.0", "queue-interop/queue-interop": "^0.8", @@ -172,7 +174,8 @@ "symfony/monolog-bundle": "^3.10", "kwn/php-rdkafka-stubs": "^2.2", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "enqueue/dsn": "^0.10.27" + "enqueue/dsn": "^0.10.27", + "yoast/phpunit-polyfills": "^4.0.0" }, "conflict": { "symfony/doctrine-messenger": ">7.0.5 < 7.1.0", @@ -212,7 +215,10 @@ }, "scripts": { "tests:phpstan": "vendor/bin/phpstan", - "tests:phpunit": "vendor/bin/phpunit --no-coverage", + "tests:phpunit": [ + "(cd packages/DataProtection && tests/before-tests.sh)", + "vendor/bin/phpunit --no-coverage" + ], "tests:behat": "vendor/bin/behat -vvv", "tests:ci": [ "@tests:phpstan", diff --git a/packages/DataProtection/.gitignore b/packages/DataProtection/.gitignore index 18c159d80..100dcb8ab 100644 --- a/packages/DataProtection/.gitignore +++ b/packages/DataProtection/.gitignore @@ -7,3 +7,5 @@ file .phpunit.result.cache composer.lock phpunit.xml + +tests/Fixture/files/big-generated-file \ No newline at end of file diff --git a/packages/DataProtection/composer.json b/packages/DataProtection/composer.json index 6724cdef0..2c72f68f7 100644 --- a/packages/DataProtection/composer.json +++ b/packages/DataProtection/composer.json @@ -19,10 +19,10 @@ "ecotone", "Encryption", "OpenSSL", - "Data Protection", - "Data Obfuscation" + "Sensitive Data Protection", + "Secure Messages" ], - "description": "Extends Ecotone with Data Protection features allowing to obfuscate messages with sensitive data.", + "description": "Extends Ecotone with Data Protection features allowing to secure sensitive data.", "autoload": { "psr-4": { "Ecotone\\DataProtection\\": "src" @@ -36,20 +36,23 @@ } }, "require": { + "php": "^8.2", "ext-openssl": "*", "ecotone/ecotone": "~1.299.2", "ecotone/jms-converter": "~1.299.2", - "defuse/php-encryption": "^2.4" + "paragonie/random_compat": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^9.5|^10.5|^11.0", - "phpstan/phpstan": "^1.8", - "psr/container": "^2.0", + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^2.1", "wikimedia/composer-merge-plugin": "^2.1" }, "scripts": { "tests:phpstan": "vendor/bin/phpstan", - "tests:phpunit": "vendor/bin/phpunit --no-coverage --testdox", + "tests:phpunit": [ + "tests/before-tests.sh", + "vendor/bin/phpunit --no-coverage" + ], "tests:ci": [ "@tests:phpstan", "@tests:phpunit" diff --git a/packages/DataProtection/phpunit.xml.dist b/packages/DataProtection/phpunit.xml.dist index fc3ebe4f7..36389ef69 100644 --- a/packages/DataProtection/phpunit.xml.dist +++ b/packages/DataProtection/phpunit.xml.dist @@ -1,13 +1,15 @@ - + - ./src + ./src + + diff --git a/packages/DataProtection/src/Configuration/ChannelProtectionConfiguration.php b/packages/DataProtection/src/Configuration/ChannelProtectionConfiguration.php index 58bb4f6e3..14a1ff1fb 100644 --- a/packages/DataProtection/src/Configuration/ChannelProtectionConfiguration.php +++ b/packages/DataProtection/src/Configuration/ChannelProtectionConfiguration.php @@ -25,9 +25,9 @@ public function channelName(): string return $this->channelName; } - public function obfuscatorConfig(): ObfuscatorConfig + public function messageEncryptionConfig(): MessageEncryptionConfig { - return new ObfuscatorConfig($this->encryptionKey, $this->isPayloadSensitive, $this->sensitiveHeaders); + return new MessageEncryptionConfig($this->encryptionKey, $this->isPayloadSensitive, $this->sensitiveHeaders); } public function withSensitivePayload(bool $isPayloadSensitive): self diff --git a/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php b/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php index 5b0caba33..687ed35e7 100644 --- a/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php +++ b/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php @@ -6,7 +6,7 @@ namespace Ecotone\DataProtection\Configuration; -use Defuse\Crypto\Key; +use Ecotone\DataProtection\Encryption\Key; use Ecotone\Messaging\Support\Assert; class DataProtectionConfiguration diff --git a/packages/DataProtection/src/Configuration/DataProtectionModule.php b/packages/DataProtection/src/Configuration/DataProtectionModule.php index 1dd8470eb..dfff37927 100644 --- a/packages/DataProtection/src/Configuration/DataProtectionModule.php +++ b/packages/DataProtection/src/Configuration/DataProtectionModule.php @@ -7,13 +7,13 @@ namespace Ecotone\DataProtection\Configuration; -use Defuse\Crypto\Key; use Ecotone\AnnotationFinder\AnnotatedMethod; use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\DataProtection\Attribute\Sensitive; use Ecotone\DataProtection\Attribute\WithEncryptionKey; use Ecotone\DataProtection\Attribute\WithSensitiveHeader; -use Ecotone\DataProtection\Obfuscator\Obfuscator; +use Ecotone\DataProtection\Encryption\Key; +use Ecotone\DataProtection\MessageEncryption\MessageEncryptor; use Ecotone\DataProtection\OutboundDecryptionChannelBuilder; use Ecotone\DataProtection\OutboundEncryptionChannelBuilder; use Ecotone\JMSConverter\JMSConverterConfiguration; @@ -39,20 +39,23 @@ #[ModuleAnnotation] final class DataProtectionModule extends NoExternalConfigurationModule { + final public const ENCRYPTOR_SERVICE_ID_FORMAT = 'ecotone.data-protection.encryptor.%s'; + final public const KEY_SERVICE_ID_FORMAT = 'ecotone.encryption.key.%s'; + /** - * @param array $obfuscatorConfigs + * @param array $encryptionConfigs */ - public function __construct(private array $obfuscatorConfigs) + public function __construct(private array $encryptionConfigs) { } public static function create(AnnotationFinder $annotationRegistrationService, InterfaceToCallRegistry $interfaceToCallRegistry): static { - $obfuscatorConfigs = self::resolveObfuscatorConfigsFromAnnotatedClasses($annotationRegistrationService->findAnnotatedClasses(Sensitive::class), [], $interfaceToCallRegistry); - $obfuscatorConfigs = self::resolveObfuscatorConfigsFromAnnotatedMethods($annotationRegistrationService->findAnnotatedMethods(CommandHandler::class), $obfuscatorConfigs, $interfaceToCallRegistry); - $obfuscatorConfigs = self::resolveObfuscatorConfigsFromAnnotatedMethods($annotationRegistrationService->findAnnotatedMethods(EventHandler::class), $obfuscatorConfigs, $interfaceToCallRegistry); + $encryptionConfigs = self::resolveEncryptionConfigsFromAnnotatedClasses($annotationRegistrationService->findAnnotatedClasses(Sensitive::class), $interfaceToCallRegistry); + $encryptionConfigs = self::resolveEncryptionConfigsFromAnnotatedMethods($annotationRegistrationService->findAnnotatedMethods(CommandHandler::class), $encryptionConfigs, $interfaceToCallRegistry); + $encryptionConfigs = self::resolveEncryptionConfigsFromAnnotatedMethods($annotationRegistrationService->findAnnotatedMethods(EventHandler::class), $encryptionConfigs, $interfaceToCallRegistry); - return new self($obfuscatorConfigs); + return new self($encryptionConfigs); } public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void @@ -70,7 +73,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO foreach ($dataProtectionConfiguration->keys() as $encryptionKeyName => $key) { $messagingConfiguration->registerServiceDefinition( - id: sprintf('ecotone.encryption.key.%s', $encryptionKeyName), + id: sprintf(self::KEY_SERVICE_ID_FORMAT, $encryptionKeyName), definition: new Definition( Key::class, [$key->saveToAsciiSafeString()], @@ -79,39 +82,39 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ); } - $channelObfuscatorReferences = $messageObfuscatorReferences = []; + $channelEncryptorReferences = $messageEncryptorReferences = []; foreach ($channelProtectionConfigurations as $channelProtectionConfiguration) { Assert::isTrue($messagingConfiguration->isPollableChannel($channelProtectionConfiguration->channelName()), sprintf('`%s` channel must be pollable channel to use Data Protection.', $channelProtectionConfiguration->channelName())); - $obfuscatorConfig = $channelProtectionConfiguration->obfuscatorConfig(); + $encryptionConfig = $channelProtectionConfiguration->messageEncryptionConfig(); $messagingConfiguration->registerServiceDefinition( - id: $id = sprintf('ecotone.encryption.obfuscator.%s', $channelProtectionConfiguration->channelName()), + id: $id = sprintf(self::ENCRYPTOR_SERVICE_ID_FORMAT, $channelProtectionConfiguration->channelName()), definition: new Definition( - Obfuscator::class, + MessageEncryptor::class, [ - Reference::to(sprintf('ecotone.encryption.key.%s', $obfuscatorConfig->encryptionKeyName($dataProtectionConfiguration))), - $obfuscatorConfig->isPayloadSensitive, - $obfuscatorConfig->sensitiveHeaders, + Reference::to(sprintf(self::KEY_SERVICE_ID_FORMAT, $encryptionConfig->encryptionKeyName($dataProtectionConfiguration))), + $encryptionConfig->isPayloadSensitive, + $encryptionConfig->sensitiveHeaders, ], ) ); - $channelObfuscatorReferences[$channelProtectionConfiguration->channelName()] = Reference::to($id); + $channelEncryptorReferences[$channelProtectionConfiguration->channelName()] = Reference::to($id); } - foreach ($this->obfuscatorConfigs as $messageClass => $obfuscatorConfig) { + foreach ($this->encryptionConfigs as $messageClass => $encryptionConfig) { $messagingConfiguration->registerServiceDefinition( - id: $id = sprintf('ecotone.encryption.obfuscator.%s', $messageClass), + id: $id = sprintf(self::ENCRYPTOR_SERVICE_ID_FORMAT, $messageClass), definition: new Definition( - Obfuscator::class, + MessageEncryptor::class, [ - Reference::to(sprintf('ecotone.encryption.key.%s', $obfuscatorConfig->encryptionKeyName($dataProtectionConfiguration))), - $obfuscatorConfig->isPayloadSensitive, - $obfuscatorConfig->sensitiveHeaders, + Reference::to(sprintf(self::KEY_SERVICE_ID_FORMAT, $encryptionConfig->encryptionKeyName($dataProtectionConfiguration))), + $encryptionConfig->isPayloadSensitive, + $encryptionConfig->sensitiveHeaders, ], ) ); - $messageObfuscatorReferences[$messageClass] = Reference::to($id); + $messageEncryptorReferences[$messageClass] = Reference::to($id); } foreach (ExtensionObjectResolver::resolve(MessageChannelWithSerializationBuilder::class, $extensionObjects) as $pollableMessageChannel) { @@ -122,15 +125,15 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $messagingConfiguration->registerChannelInterceptor( new OutboundEncryptionChannelBuilder( relatedChannel: $pollableMessageChannel->getMessageChannelName(), - channelObfuscatorReference: $channelObfuscatorReferences[$pollableMessageChannel->getMessageChannelName()] ?? null, - messageObfuscatorReferences: $messageObfuscatorReferences, + channelEncryptorReference: $channelEncryptorReferences[$pollableMessageChannel->getMessageChannelName()] ?? null, + messageEncryptorReferences: $messageEncryptorReferences, ) ); $messagingConfiguration->registerChannelInterceptor( new OutboundDecryptionChannelBuilder( relatedChannel: $pollableMessageChannel->getMessageChannelName(), - channelObfuscatorReference: $channelObfuscatorReferences[$pollableMessageChannel->getMessageChannelName()] ?? null, - messageObfuscatorReferences: $messageObfuscatorReferences, + channelEncryptionReference: $channelEncryptorReferences[$pollableMessageChannel->getMessageChannelName()] ?? null, + messageEncryptionReferences: $messageEncryptorReferences, ) ); } @@ -151,20 +154,21 @@ public function getModulePackageName(): string return ModulePackageList::DATA_PROTECTION_PACKAGE; } - private static function resolveObfuscatorConfigsFromAnnotatedClasses(array $sensitiveMessages, array $obfuscatorConfigs, InterfaceToCallRegistry $interfaceToCallRegistry): array + private static function resolveEncryptionConfigsFromAnnotatedClasses(array $sensitiveMessages, InterfaceToCallRegistry $interfaceToCallRegistry): array { + $encryptionConfigs = []; foreach ($sensitiveMessages as $message) { $classDefinition = $interfaceToCallRegistry->getClassDefinitionFor(Type::create($message)); $encryptionKey = $classDefinition->findSingleClassAnnotation(Type::create(WithEncryptionKey::class))?->encryptionKey(); $sensitiveHeaders = array_map(static fn (WithSensitiveHeader $annotation) => $annotation->header, $classDefinition->getClassAnnotations(Type::create(WithSensitiveHeader::class)) ?? []); - $obfuscatorConfigs[$message] = new ObfuscatorConfig(encryptionKey: $encryptionKey, isPayloadSensitive: true, sensitiveHeaders: $sensitiveHeaders); + $encryptionConfigs[$message] = new MessageEncryptionConfig(encryptionKey: $encryptionKey, isPayloadSensitive: true, sensitiveHeaders: $sensitiveHeaders); } - return $obfuscatorConfigs; + return $encryptionConfigs; } - private static function resolveObfuscatorConfigsFromAnnotatedMethods(array $annotatedMethods, array $obfuscatorConfigs, InterfaceToCallRegistry $interfaceToCallRegistry): array + private static function resolveEncryptionConfigsFromAnnotatedMethods(array $annotatedMethods, array $encryptionConfigs, InterfaceToCallRegistry $interfaceToCallRegistry): array { /** @var AnnotatedMethod $method */ foreach ($annotatedMethods as $method) { @@ -175,7 +179,7 @@ private static function resolveObfuscatorConfigsFromAnnotatedMethods(array $anno $payload->hasAnnotation(Header::class) || $payload->hasAnnotation(Headers::class) || $payload->hasAnnotation(Reference::class) - || array_key_exists($payload->getTypeHint(), $obfuscatorConfigs) + || array_key_exists($payload->getTypeHint(), $encryptionConfigs) ) { continue; } @@ -193,10 +197,10 @@ private static function resolveObfuscatorConfigsFromAnnotatedMethods(array $anno } } - $obfuscatorConfigs[$payload->getTypeHint()] = new ObfuscatorConfig(encryptionKey: $encryptionKey, isPayloadSensitive: true, sensitiveHeaders: $sensitiveHeaders); + $encryptionConfigs[$payload->getTypeHint()] = new MessageEncryptionConfig(encryptionKey: $encryptionKey, isPayloadSensitive: true, sensitiveHeaders: $sensitiveHeaders); } - return $obfuscatorConfigs; + return $encryptionConfigs; } private function verifyLicense(Configuration $messagingConfiguration): void diff --git a/packages/DataProtection/src/Configuration/ObfuscatorConfig.php b/packages/DataProtection/src/Configuration/MessageEncryptionConfig.php similarity index 93% rename from packages/DataProtection/src/Configuration/ObfuscatorConfig.php rename to packages/DataProtection/src/Configuration/MessageEncryptionConfig.php index 9429ce7c1..a768afe6c 100644 --- a/packages/DataProtection/src/Configuration/ObfuscatorConfig.php +++ b/packages/DataProtection/src/Configuration/MessageEncryptionConfig.php @@ -8,7 +8,7 @@ use Ecotone\Messaging\Support\Assert; -final readonly class ObfuscatorConfig +final readonly class MessageEncryptionConfig { /** * @param array $sensitiveHeaders diff --git a/packages/DataProtection/src/Encryption/Core.php b/packages/DataProtection/src/Encryption/Core.php new file mode 100644 index 000000000..1ac062157 --- /dev/null +++ b/packages/DataProtection/src/Encryption/Core.php @@ -0,0 +1,204 @@ + 0, 'Trying to increment a nonce by a nonpositive amount'); // The caller is probably re-using CTR-mode keystream if they increment by 0. + self::ensureTrue($inc <= PHP_INT_MAX - 255, 'Integer overflow may occur'); + + /* + * We start at the rightmost byte (big-endian) + * So, too, does OpenSSL: http://stackoverflow.com/a/3146214/2224584 + */ + for ($i = self::BLOCK_BYTE_SIZE - 1; $i >= 0; --$i) { + $sum = ord($ctr[$i]) + $inc; + + /* Detect integer overflow and fail. */ + self::ensureTrue(is_int($sum), 'Integer overflow in CTR mode nonce increment'); + + $ctr[$i] = pack('C', $sum & 0xFF); + $inc = $sum >> 8; + } + + return $ctr; + } + + /** + * Returns a random byte string of the specified length. + * + * @throws EnvironmentIsBrokenException|CryptoException + */ + public static function secureRandom(int $octets): string + { + if ($octets <= 0) { + throw new CryptoException('A zero or negative amount of random bytes was requested.'); + } + self::ensureFunctionExists('random_bytes'); + + try { + return random_bytes(max(1, $octets)); + } catch (Throwable $ex) { + throw new EnvironmentIsBrokenException(message: 'Your system does not have a secure random number generator.', previous: $ex); + } + } + + /** + * @throws EnvironmentIsBrokenException + */ + public static function ensureConstantExists(string $name): void + { + self::ensureTrue(defined($name), 'Constant ' . $name . ' does not exists'); + } + + /** + * @throws EnvironmentIsBrokenException + */ + public static function ensureFunctionExists(string $name): void + { + self::ensureTrue(function_exists($name), 'function ' . $name . ' does not exists'); + } + + /** + * @throws EnvironmentIsBrokenException + */ + public static function ensureTrue(bool $condition, string $message = ''): void + { + if (! $condition) { + throw new EnvironmentIsBrokenException($message); + } + } + + /** + * Computes the length of a string in bytes. + * + * @throws EnvironmentIsBrokenException + */ + public static function strlen(string $str): int + { + static $exists = null; + if ($exists === null) { + $exists = extension_loaded('mbstring') && function_exists('mb_strlen'); + } + if ($exists) { + $length = mb_strlen($str, '8bit'); + self::ensureTrue($length !== false); + + return $length; + } + + return strlen($str); + } + + /** + * Behaves roughly like the function substr() in PHP 7 does. + * + * @throws EnvironmentIsBrokenException + */ + public static function substr(string $str, int $start, ?int $length = null): bool|string + { + static $exists = null; + if ($exists === null) { + $exists = extension_loaded('mbstring') && function_exists('mb_substr'); + } + + // This is required to make mb_substr behavior identical to substr. + // Without this, mb_substr() would return false, contra to what the + // PHP documentation says (it doesn't say it can return false.) + $input_len = self::strlen($str); + if ($start === $input_len && ! $length) { + return ''; + } + + if ($start > $input_len) { + return false; + } + + // mb_substr($str, 0, NULL, '8bit') returns an empty string on PHP 5.3, + // so we have to find the length ourselves. Also, substr() doesn't + // accept null for the length. + if (! isset($length)) { + if ($start >= 0) { + $length = $input_len - $start; + } else { + $length = -$start; + } + } + + if ($length < 0) { + throw new InvalidArgumentException('Negative lengths are not supported with ourSubstr.'); + } + + if ($exists) { + $substr = mb_substr($str, $start, $length, '8bit'); + // At this point there are two cases where mb_substr can + // legitimately return an empty string. Either $length is 0, or + // $start is equal to the length of the string (both mb_substr and + // substr return an empty string when this happens). It should never + // ever return a string that's longer than $length. + if (self::strlen($substr) > $length || (self::strlen($substr) === 0 && $length !== 0 && $start !== $input_len)) { + throw new EnvironmentIsBrokenException('Your version of PHP has bug #66797. Its implementation of mb_substr() is incorrect. See the details here:https://bugs.php.net/bug.php?id=66797'); + } + return $substr; + } + + return substr($str, $start, $length); + } +} diff --git a/packages/DataProtection/src/Encryption/Crypto.php b/packages/DataProtection/src/Encryption/Crypto.php new file mode 100644 index 000000000..382367a0e --- /dev/null +++ b/packages/DataProtection/src/Encryption/Crypto.php @@ -0,0 +1,189 @@ +deriveKeys($salt); + $iv = Core::secureRandom(Core::BLOCK_BYTE_SIZE); + + $ciphertext = Core::CURRENT_VERSION . $salt . $iv . self::plainEncrypt($plaintext, $keys->encryptionKey, $iv); + $auth = hash_hmac(Core::HASH_FUNCTION_NAME, $ciphertext, $keys->authenticationKey, true); + $ciphertext .= $auth; + + if ($raw_binary) { + return $ciphertext; + } + + return Encoding::binToHex($ciphertext); + } + + /** + * Decrypts a ciphertext to a string with either a key or a password. + * + * @throws WrongKeyOrModifiedCiphertextException|EnvironmentIsBrokenException|CryptoException + */ + private static function decryptInternal(string $ciphertext, KeyOrPassword $secret, bool $raw_binary): string + { + RuntimeTests::runtimeTest(); + + if (! $raw_binary) { + try { + $ciphertext = Encoding::hexToBin($ciphertext); + } catch (Throwable $ex) { + throw new WrongKeyOrModifiedCiphertextException(message: 'Ciphertext has invalid hex encoding.', previous: $ex); + } + } + + if (Core::strlen($ciphertext) < Core::MINIMUM_CIPHERTEXT_SIZE) { + throw new WrongKeyOrModifiedCiphertextException('Ciphertext is too short.'); + } + + // Get and check the version header. + $header = Core::substr($ciphertext, 0, Core::HEADER_VERSION_SIZE); + if ($header !== Core::CURRENT_VERSION) { + throw new WrongKeyOrModifiedCiphertextException('Bad version header.'); + } + + // Get the salt. + $salt = Core::substr($ciphertext, Core::HEADER_VERSION_SIZE, Core::SALT_BYTE_SIZE); + Core::ensureTrue(is_string($salt)); + + // Get the IV. + $iv = Core::substr($ciphertext, Core::HEADER_VERSION_SIZE + Core::SALT_BYTE_SIZE, Core::BLOCK_BYTE_SIZE); + Core::ensureTrue(is_string($iv)); + + // Get the HMAC. + /** @var string $hmac */ + $hmac = Core::substr($ciphertext, Core::strlen($ciphertext) - Core::MAC_BYTE_SIZE, Core::MAC_BYTE_SIZE); + Core::ensureTrue(is_string($hmac)); + + // Get the actual encrypted ciphertext. + /** @var string $encrypted */ + $encrypted = Core::substr( + str: $ciphertext, + start: Core::HEADER_VERSION_SIZE + Core::SALT_BYTE_SIZE + Core::BLOCK_BYTE_SIZE, + length: Core::strlen($ciphertext) - Core::MAC_BYTE_SIZE - Core::SALT_BYTE_SIZE - Core::BLOCK_BYTE_SIZE - Core::HEADER_VERSION_SIZE, + ); + Core::ensureTrue(is_string($encrypted)); + + // Derive the separate encryption and authentication keys from the key + // or password, whichever it is. + $keys = $secret->deriveKeys($salt); + + if (self::verifyHMAC($hmac, $header . $salt . $iv . $encrypted, $keys->authenticationKey)) { + return self::plainDecrypt($encrypted, $keys->encryptionKey, $iv, Core::CIPHER_METHOD); + } + + throw new WrongKeyOrModifiedCiphertextException('Integrity check failed.'); + } + + /** + * Raw unauthenticated encryption (insecure on its own). + * + * @throws EnvironmentIsBrokenException + */ + protected static function plainEncrypt(string $plaintext, #[SensitiveParameter] string $key, #[SensitiveParameter] string $iv): string + { + Core::ensureConstantExists('OPENSSL_RAW_DATA'); + Core::ensureFunctionExists('openssl_encrypt'); + + $ciphertext = openssl_encrypt($plaintext, Core::CIPHER_METHOD, $key, OPENSSL_RAW_DATA, $iv); + Core::ensureTrue(is_string($ciphertext), 'openssl_encrypt() failed'); + + return $ciphertext; + } + + /** + * Raw unauthenticated decryption (insecure on its own). + * + * @throws EnvironmentIsBrokenException + */ + protected static function plainDecrypt(string $ciphertext, #[SensitiveParameter] string $key, #[SensitiveParameter] string $iv, string $cipherMethod): string + { + Core::ensureConstantExists('OPENSSL_RAW_DATA'); + Core::ensureFunctionExists('openssl_decrypt'); + + $plaintext = openssl_decrypt($ciphertext, $cipherMethod, $key, OPENSSL_RAW_DATA, $iv); + Core::ensureTrue(is_string($plaintext), 'openssl_decrypt() failed.'); + + return $plaintext; + } + + /** + * Verifies an HMAC without leaking information through side-channels. + * + * @throws EnvironmentIsBrokenException + */ + protected static function verifyHMAC(string $expected_hmac, string $message, #[SensitiveParameter] string $key): bool + { + $message_hmac = hash_hmac(Core::HASH_FUNCTION_NAME, $message, $key, true); + + return hash_equals($message_hmac, $expected_hmac); + } +} diff --git a/packages/DataProtection/src/Encryption/DerivedKeys.php b/packages/DataProtection/src/Encryption/DerivedKeys.php new file mode 100644 index 000000000..10e35c886 --- /dev/null +++ b/packages/DataProtection/src/Encryption/DerivedKeys.php @@ -0,0 +1,13 @@ +> 4; + $hex .= pack( + 'CC', + 87 + $b + ((($b - 10) >> 8) & ~38), + 87 + $c + ((($c - 10) >> 8) & ~38) + ); + } + + return $hex; + } + + /** + * Converts a hexadecimal string into a byte string without leaking information through side channels. + * + * @throws BadFormatException|EnvironmentIsBrokenException + * + * @psalm-suppress TypeDoesNotContainType + */ + public static function hexToBin(string $hex_string): string + { + $hex_pos = 0; + $bin = ''; + $hex_len = Core::strlen($hex_string); + $state = 0; + $c_acc = 0; + + while ($hex_pos < $hex_len) { + $c = ord($hex_string[$hex_pos]); + $c_num = $c ^ 48; + $c_num0 = ($c_num - 10) >> 8; + $c_alpha = ($c & ~32) - 55; + $c_alpha0 = (($c_alpha - 10) ^ ($c_alpha - 16)) >> 8; + if (($c_num0 | $c_alpha0) === 0) { + throw new BadFormatException('Encoding::hexToBin() input is not a hex string.'); + } + $c_val = ($c_num0 & $c_num) | ($c_alpha & $c_alpha0); + if ($state === 0) { + $c_acc = $c_val * 16; + } else { + $bin .= pack('C', $c_acc | $c_val); + } + $state ^= 1; + ++$hex_pos; + } + + return $bin; + } + + /** + * Remove trialing whitespace without table look-ups or branches. + * + * Calling this function may leak the length of the string as well as the number of trailing whitespace characters through side-channels. + * + * @throws EnvironmentIsBrokenException + */ + public static function trimTrailingWhitespace(string $string = ''): string + { + $length = Core::strlen($string); + if ($length < 1) { + return ''; + } + do { + $prevLength = $length; + $last = $length - 1; + $chr = ord($string[$last]); + + // Null Byte (0x00), a.k.a. \0 + // if ($chr === 0x00) $length -= 1; + $sub = (($chr - 1) >> 8) & 1; + $length -= $sub; + $last -= $sub; + + // Horizontal Tab (0x09) a.k.a. \t + $chr = ord($string[$last]); + // if ($chr === 0x09) $length -= 1; + $sub = (((0x08 - $chr) & ($chr - 0x0a)) >> 8) & 1; + $length -= $sub; + $last -= $sub; + + // New Line (0x0a), a.k.a. \n + $chr = ord($string[$last]); + // if ($chr === 0x0a) $length -= 1; + $sub = (((0x09 - $chr) & ($chr - 0x0b)) >> 8) & 1; + $length -= $sub; + $last -= $sub; + + // Carriage Return (0x0D), a.k.a. \r + $chr = ord($string[$last]); + // if ($chr === 0x0d) $length -= 1; + $sub = (((0x0c - $chr) & ($chr - 0x0e)) >> 8) & 1; + $length -= $sub; + $last -= $sub; + + // Space + $chr = ord($string[$last]); + // if ($chr === 0x20) $length -= 1; + $sub = (((0x1f - $chr) & ($chr - 0x21)) >> 8) & 1; + $length -= $sub; + } while ($prevLength !== $length && $length > 0); + + return (string) Core::substr($string, 0, $length); + } + + /* + * SECURITY NOTE ON APPLYING CHECKSUMS TO SECRETS: + * + * The checksum introduces a potential security weakness. For example, + * suppose we apply a checksum to a key, and that an adversary has an + * exploit against the process containing the key, such that they can + * overwrite an arbitrary byte of memory and then cause the checksum to + * be verified and learn the result. + * + * In this scenario, the adversary can extract the key one byte at + * a time by overwriting it with their guess of its value and then + * asking if the checksum matches. If it does, their guess was right. + * This kind of attack may be more easy to implement and more reliable + * than a remote code execution attack. + * + * This attack also applies to authenticated encryption as a whole, in + * the situation where the adversary can overwrite a byte of the key + * and then cause a valid ciphertext to be decrypted, and then + * determine whether the MAC check passed or failed. + * + * By using the full SHA256 hash instead of truncating it, I'm ensuring + * that both ways of going about the attack are equivalently difficult. + * A shorter checksum of say 32 bits might be more useful to the + * adversary as an oracle in case their writes are coarser grained. + * + * Because the scenario assumes a serious vulnerability, we don't try + * to prevent attacks of this style. + */ + + /** + * INTERNAL USE ONLY: Applies a version header, applies a checksum, and then encodes a byte string into a range of printable ASCII characters. + * + * @throws EnvironmentIsBrokenException + * + */ + public static function saveBytesToChecksummedAsciiSafeString(string $header, #[SensitiveParameter] string $bytes): string + { + // Headers must be a constant length to prevent one type's header from + // being a prefix of another type's header, leading to ambiguity. + Core::ensureTrue(Core::strlen($header) === self::SERIALIZE_HEADER_BYTES, 'Header must be ' . self::SERIALIZE_HEADER_BYTES . ' bytes.'); + + return self::binToHex($header . $bytes . hash(self::CHECKSUM_HASH_ALGO, $header . $bytes, true)); + } + + /** + * INTERNAL USE ONLY: Decodes, verifies the header and checksum, and returns the encoded byte string. + * + * @throws EnvironmentIsBrokenException|BadFormatException|CryptoException + */ + public static function loadBytesFromChecksummedAsciiSafeString(string $expected_header, #[SensitiveParameter] string $string): string + { + // Headers must be a constant length to prevent one type's header from + // being a prefix of another type's header, leading to ambiguity. + Core::ensureTrue(Core::strlen($expected_header) === self::SERIALIZE_HEADER_BYTES, 'Header must be 4 bytes.'); + + // If you get an exception here when attempting to load from a file, first pass your + // key to Encoding::trimTrailingWhitespace() to remove newline characters, etc. + $bytes = self::hexToBin($string); + + // Make sure we have enough bytes to get the version header and checksum. + if (Core::strlen($bytes) < self::SERIALIZE_HEADER_BYTES + self::CHECKSUM_BYTE_SIZE) { + throw new BadFormatException('Encoded data is shorter than expected.'); + } + + // Grab the version header. + $actual_header = (string) Core::substr($bytes, 0, self::SERIALIZE_HEADER_BYTES); + + if ($actual_header !== $expected_header) { + throw new BadFormatException('Invalid header.'); + } + + // Grab the bytes that are part of the checksum. + $checked_bytes = (string) Core::substr($bytes, 0, Core::strlen($bytes) - self::CHECKSUM_BYTE_SIZE); + + // Grab the included checksum. + $checksum_a = (string) Core::substr($bytes, Core::strlen($bytes) - self::CHECKSUM_BYTE_SIZE, self::CHECKSUM_BYTE_SIZE); + + // Re-compute the checksum. + $checksum_b = hash(self::CHECKSUM_HASH_ALGO, $checked_bytes, true); + + // Check if the checksum matches. + if (! hash_equals($checksum_a, $checksum_b)) { + throw new BadFormatException("Data is corrupted, the checksum doesn't match"); + } + + return (string) Core::substr($bytes, self::SERIALIZE_HEADER_BYTES, Core::strlen($bytes) - self::SERIALIZE_HEADER_BYTES - self::CHECKSUM_BYTE_SIZE); + } +} diff --git a/packages/DataProtection/src/Encryption/Exception/BadFormatException.php b/packages/DataProtection/src/Encryption/Exception/BadFormatException.php new file mode 100644 index 000000000..d6e51cd38 --- /dev/null +++ b/packages/DataProtection/src/Encryption/Exception/BadFormatException.php @@ -0,0 +1,10 @@ +deriveKeys($file_salt); + $ivsize = Core::BLOCK_BYTE_SIZE; + $iv = Core::secureRandom($ivsize); + + // Initialize a streaming HMAC state. + $hmac = hash_init(Core::HASH_FUNCTION_NAME, HASH_HMAC, $keys->authenticationKey); + Core::ensureTrue(is_resource($hmac) || is_object($hmac), 'Cannot initialize a hash context'); + + // Write the header, salt, and IV. + self::writeBytes( + $outputHandle, + Core::CURRENT_VERSION . $file_salt . $iv, + Core::HEADER_VERSION_SIZE + Core::SALT_BYTE_SIZE + $ivsize + ); + + // Add the header, salt, and IV to the HMAC. + hash_update($hmac, Core::CURRENT_VERSION); + hash_update($hmac, $file_salt); + hash_update($hmac, $iv); + + // $thisIv will be incremented after each call to the encryption. + $thisIv = $iv; + + // How many blocks do we encrypt at a time? We increment by this value. + $inc = (int) (Core::BUFFER_BYTE_SIZE / Core::BLOCK_BYTE_SIZE); + + // Loop until we reach the end of the input file. */ + $at_file_end = false; + while (! (feof($inputHandle) || $at_file_end)) { + // Find out if we can read a full buffer, or only a partial one. + $pos = ftell($inputHandle); + if (! is_int($pos)) { + throw new IOException('Could not get current position in input file during encryption'); + } + if ($pos + Core::BUFFER_BYTE_SIZE >= $inputSize) { + // We're at the end of the file, so we need to break out of the loop. + $at_file_end = true; + $read = self::readBytes($inputHandle, $inputSize - $pos); + } else { + $read = self::readBytes($inputHandle, Core::BUFFER_BYTE_SIZE); + } + + // Encrypt this buffer. + /** @var string */ + $encrypted = openssl_encrypt($read, Core::CIPHER_METHOD, $keys->encryptionKey, OPENSSL_RAW_DATA, $thisIv); + + Core::ensureTrue(is_string($encrypted), 'OpenSSL encryption error'); + + // Write this buffer's ciphertext. + self::writeBytes($outputHandle, $encrypted, Core::strlen($encrypted)); + // Add this buffer's ciphertext to the HMAC. + hash_update($hmac, $encrypted); + + // Increment the counter by the number of blocks in a buffer. + $thisIv = Core::incrementCounter($thisIv, $inc); + // WARNING: Usually, unless the file is a multiple of the buffer size, $thisIv will contain an incorrect value here on the last iteration of this loop. + } + + // Get the HMAC and append it to the ciphertext. + $final_mac = hash_final($hmac, true); + self::writeBytes($outputHandle, $final_mac, Core::MAC_BYTE_SIZE); + } + + /** + * Decrypts a file-backed resource with either a key or a password. + * + * Fixes erroneous errors caused by PHP 7.2 switching the return value of hash_init from a resource to a HashContext. + * + * @throws IOException|WrongKeyOrModifiedCiphertextException|EnvironmentIsBrokenException|CryptoException + * + * @psalm-suppress PossiblyInvalidArgument + */ + public static function decryptResourceInternal($inputHandle, $outputHandle, KeyOrPassword $secret): void + { + if (! is_resource($inputHandle)) { + throw new IOException('Input handle must be a resource!'); + } + if (! is_resource($outputHandle)) { + throw new IOException('Output handle must be a resource!'); + } + + // Make sure the file is big enough for all the reads we need to do. + $stat = fstat($inputHandle); + if ($stat['size'] < Core::MINIMUM_CIPHERTEXT_SIZE) { + throw new WrongKeyOrModifiedCiphertextException('Input file is too small to have been created by this library.'); + } + + // Check the version header. + $header = self::readBytes($inputHandle, Core::HEADER_VERSION_SIZE); + if ($header !== Core::CURRENT_VERSION) { + throw new WrongKeyOrModifiedCiphertextException('Bad version header.'); + } + + // Get the salt. + $file_salt = self::readBytes($inputHandle, Core::SALT_BYTE_SIZE); + + // Get the IV. + $ivsize = Core::BLOCK_BYTE_SIZE; + $iv = self::readBytes($inputHandle, $ivsize); + + // Derive the authentication and encryption keys. + $keys = $secret->deriveKeys($file_salt); + // We'll store the MAC of each buffer-sized chunk as we verify the actual MAC, so that we can check them again when decrypting. + $macs = []; + + // $thisIv will be incremented after each call to the decryption. + $thisIv = $iv; + + // How many blocks do we encrypt at a time? We increment by this value. + $inc = (int) (Core::BUFFER_BYTE_SIZE / Core::BLOCK_BYTE_SIZE); + + // Get the HMAC. + if (fseek($inputHandle, (-1 * Core::MAC_BYTE_SIZE), SEEK_END) === -1) { + throw new IOException('Cannot seek to beginning of MAC within input file'); + } + + // Get the position of the last byte in the actual ciphertext. + $cipher_end = ftell($inputHandle); + if (! is_int($cipher_end)) { + throw new IOException('Cannot read input file'); + } + // We have the position of the first byte of the HMAC. Go back by one. + --$cipher_end; + + // Read the HMAC. + $stored_mac = self::readBytes($inputHandle, Core::MAC_BYTE_SIZE); + + // Initialize a streaming HMAC state. + $hmac = hash_init(Core::HASH_FUNCTION_NAME, HASH_HMAC, $keys->authenticationKey); + Core::ensureTrue(is_resource($hmac) || is_object($hmac), 'Cannot initialize a hash context'); + + // Reset file pointer to the beginning of the file after the header + if (fseek($inputHandle, Core::HEADER_VERSION_SIZE, SEEK_SET) === -1) { + throw new IOException('Cannot read seek within input file'); + } + + // Seek to the start of the actual ciphertext. + if (fseek($inputHandle, Core::SALT_BYTE_SIZE + $ivsize, SEEK_CUR) === -1) { + throw new IOException('Cannot seek input file to beginning of ciphertext'); + } + + // PASS #1: Calculating the HMAC. + + hash_update($hmac, $header); + hash_update($hmac, $file_salt); + hash_update($hmac, $iv); + $hmac2 = hash_copy($hmac); + + $break = false; + while (! $break) { + $pos = ftell($inputHandle); + if (! is_int($pos)) { + throw new IOException('Could not get current position in input file during decryption'); + } + + // Read the next buffer-sized chunk (or less). + if ($pos + Core::BUFFER_BYTE_SIZE >= $cipher_end) { + $break = true; + $read = self::readBytes( + $inputHandle, + $cipher_end - $pos + 1 + ); + } else { + $read = self::readBytes( + $inputHandle, + Core::BUFFER_BYTE_SIZE + ); + } + + // Update the HMAC. + hash_update($hmac, $read); + + // Remember this buffer-sized chunk's HMAC. + $chunk_mac = hash_copy($hmac); + Core::ensureTrue(is_resource($chunk_mac) || is_object($chunk_mac), 'Cannot duplicate a hash context'); + $macs [] = hash_final($chunk_mac); + } + + // Get the final HMAC, which should match the stored one. + $final_mac = hash_final($hmac, true); + + // Verify the HMAC. + if (! hash_equals($final_mac, $stored_mac)) { + throw new WrongKeyOrModifiedCiphertextException('Integrity check failed.'); + } + + // PASS #2: Decrypt and write output. + + // Rewind to the start of the actual ciphertext. + if (fseek($inputHandle, Core::SALT_BYTE_SIZE + $ivsize + Core::HEADER_VERSION_SIZE, SEEK_SET) === -1) { + throw new IOException('Could not move the input file pointer during decryption'); + } + + $at_file_end = false; + while (! $at_file_end) { + $pos = ftell($inputHandle); + if (! is_int($pos)) { + throw new IOException('Could not get current position in input file during decryption'); + } + + /* Read the next buffer-sized chunk (or less). */ + if ($pos + Core::BUFFER_BYTE_SIZE >= $cipher_end) { + $at_file_end = true; + $read = self::readBytes( + $inputHandle, + $cipher_end - $pos + 1 + ); + } else { + $read = self::readBytes( + $inputHandle, + Core::BUFFER_BYTE_SIZE + ); + } + + /* Recalculate the MAC (so far) and compare it with the one we + * remembered from pass #1 to ensure attackers didn't change the + * ciphertext after MAC verification. */ + hash_update($hmac2, $read); + $calc_mac = hash_copy($hmac2); + Core::ensureTrue(is_resource($calc_mac) || is_object($calc_mac), 'Cannot duplicate a hash context'); + $calc = hash_final($calc_mac); + + if (empty($macs)) { + throw new WrongKeyOrModifiedCiphertextException('File was modified after MAC verification'); + } + + if (! hash_equals(array_shift($macs), $calc)) { + throw new WrongKeyOrModifiedCiphertextException('File was modified after MAC verification'); + } + + // Decrypt this buffer-sized chunk. + $decrypted = openssl_decrypt($read, Core::CIPHER_METHOD, $keys->encryptionKey, OPENSSL_RAW_DATA, $thisIv); + Core::ensureTrue(is_string($decrypted), 'OpenSSL decryption error'); + + // Write the plaintext to the output file. + self::writeBytes($outputHandle, $decrypted, Core::strlen($decrypted)); + + // Increment the IV by the amount of blocks in a buffer. + $thisIv = Core::incrementCounter($thisIv, $inc); + // WARNING: Usually, unless the file is a multiple of the buffer size, $thisIv will contain an incorrect value here on the last iteration of this loop. + } + } + + /** + * Read from a stream; prevent partial reads. + * + * @throws EnvironmentIsBrokenException|IOException + */ + public static function readBytes($stream, int $num_bytes): string + { + Core::ensureTrue($num_bytes >= 0, 'Tried to read less than 0 bytes'); + + if ($num_bytes === 0) { + return ''; + } + + $buf = ''; + $remaining = $num_bytes; + while ($remaining > 0 && ! feof($stream)) { + $read = fread($stream, $remaining); + if (! is_string($read)) { + throw new IOException('Could not read from the file'); + } + $buf .= $read; + $remaining -= Core::strlen($read); + } + if (Core::strlen($buf) !== $num_bytes) { + throw new IOException('Tried to read past the end of the file'); + } + + return $buf; + } + + /** + * Write to a stream; prevents partial writes. + * + * @throws EnvironmentIsBrokenException|IOException + */ + public static function writeBytes($stream, string $buf, ?int $num_bytes = null): ?int + { + $bufSize = Core::strlen($buf); + if ($num_bytes === null) { + $num_bytes = $bufSize; + } + if ($num_bytes > $bufSize) { + throw new IOException('Trying to write more bytes than the buffer contains.'); + } + if ($num_bytes < 0) { + throw new IOException('Tried to write less than 0 bytes'); + } + $remaining = $num_bytes; + while ($remaining > 0) { + $written = fwrite($stream, $buf, $remaining); + if (! is_int($written)) { + throw new IOException('Could not write to the file'); + } + $buf = (string) Core::substr($buf, $written, null); + $remaining -= $written; + } + + return $num_bytes; + } + + /** + * Returns the last PHP error's or warning's message string. + */ + private static function getLastErrorMessage(): string + { + $error = error_get_last(); + if ($error === null) { + return '[no PHP error, or you have a custom error handler set]'; + } + + return $error['message']; + } + + /** + * PHPUnit sets an error handler, which prevents getLastErrorMessage() from working, + * because error_get_last does not work when custom handlers are set. + * + * This is a workaround, which should be a no-op in production deployments, to make + * getLastErrorMessage() return the error messages that the PHPUnit tests expect. + * + * If, in a production deployment, a custom error handler is set, the exception + * handling will still work as usual, but the error messages will be confusing. + */ + private static function removePHPUnitErrorHandler(): void + { + if (defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__')) { + set_error_handler(null); + } + } + + /** + * Undoes what removePHPUnitErrorHandler did. + */ + private static function restorePHPUnitErrorHandler(): void + { + if (defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__')) { + restore_error_handler(); + } + } +} diff --git a/packages/DataProtection/src/Encryption/Key.php b/packages/DataProtection/src/Encryption/Key.php new file mode 100644 index 000000000..60145bbfa --- /dev/null +++ b/packages/DataProtection/src/Encryption/Key.php @@ -0,0 +1,76 @@ +key_bytes = $bytes; + } + + /** + * Creates a new random key. + * + * @throws CryptoException|EnvironmentIsBrokenException + */ + public static function createNewRandomKey(): self + { + return new Key(Core::secureRandom(self::KEY_BYTE_SIZE)); + } + + /** + * Loads a Key from its encoded form. + * + * By default, this function will call Encoding::trimTrailingWhitespace() to remove trailing CR, LF, NUL, TAB, and SPACE characters, which are commonly appended to files when working with text editors. + * + * @throws CryptoException|EnvironmentIsBrokenException|BadFormatException + */ + public static function loadFromAsciiSafeString(#[SensitiveParameter] string $saved_key_string, bool $do_not_trim = false): self + { + if (! $do_not_trim) { + $saved_key_string = Encoding::trimTrailingWhitespace($saved_key_string); + } + $key_bytes = Encoding::loadBytesFromChecksummedAsciiSafeString(self::KEY_CURRENT_VERSION, $saved_key_string); + + return new Key($key_bytes); + } + + /** + * Encodes the Key into a string of printable ASCII characters. + * + * @throws EnvironmentIsBrokenException + */ + public function saveToAsciiSafeString(): string + { + return Encoding::saveBytesToChecksummedAsciiSafeString(self::KEY_CURRENT_VERSION, $this->key_bytes); + } + + /** + * Gets the raw bytes of the key. + */ + public function getRawBytes(): string + { + return $this->key_bytes; + } +} diff --git a/packages/DataProtection/src/Encryption/KeyOrPassword.php b/packages/DataProtection/src/Encryption/KeyOrPassword.php new file mode 100644 index 000000000..b4d251ee5 --- /dev/null +++ b/packages/DataProtection/src/Encryption/KeyOrPassword.php @@ -0,0 +1,135 @@ +secret_type = $secret_type; + $this->secret = $secret; + } + + /** + * Initializes an instance of KeyOrPassword from a key. + * + * @throws EnvironmentIsBrokenException + */ + public static function createFromKey(Key $key): self + { + return new KeyOrPassword(self::SECRET_TYPE_KEY, $key); + } + + /** + * Initializes an instance of KeyOrPassword from a password. + * + * @throws EnvironmentIsBrokenException + */ + public static function createFromPassword(#[SensitiveParameter] string $password): self + { + return new KeyOrPassword(self::SECRET_TYPE_PASSWORD, $password); + } + + /** + * Derives authentication and encryption keys from the secret, using a slow key derivation function if the secret is a password. + * + * @throws EnvironmentIsBrokenException + */ + public function deriveKeys(string $salt): DerivedKeys + { + Core::ensureTrue(Core::strlen($salt) === Core::SALT_BYTE_SIZE, 'Bad salt.'); + + if ($this->secret_type === self::SECRET_TYPE_KEY) { + Core::ensureTrue($this->secret instanceof Key); + + $authenticationKey = hash_hkdf( + Core::HASH_FUNCTION_NAME, + $this->secret->getRawBytes(), + Core::KEY_BYTE_SIZE, + Core::AUTHENTICATION_INFO_STRING, + $salt + ); + + $encryptionKey = hash_hkdf( + Core::HASH_FUNCTION_NAME, + $this->secret->getRawBytes(), + Core::KEY_BYTE_SIZE, + Core::ENCRYPTION_INFO_STRING, + $salt + ); + + return new DerivedKeys($authenticationKey, $encryptionKey); + } + + if ($this->secret_type === self::SECRET_TYPE_PASSWORD) { + Core::ensureTrue(is_string($this->secret)); + /* Our PBKDF2 polyfill is vulnerable to a DoS attack documented in + * GitHub issue #230. The fix is to pre-hash the password to ensure + * it is short. We do the prehashing here instead of in pbkdf2() so + * that pbkdf2() still computes the function as defined by the + * standard. */ + + $prehash = hash(Core::HASH_FUNCTION_NAME, $this->secret, true); + + $prekey = hash_pbkdf2( + Core::HASH_FUNCTION_NAME, + $prehash, + $salt, + self::PBKDF2_ITERATIONS, + Core::KEY_BYTE_SIZE, + true + ); + $authenticationKey = hash_hkdf( + Core::HASH_FUNCTION_NAME, + $prekey, + Core::KEY_BYTE_SIZE, + Core::AUTHENTICATION_INFO_STRING, + $salt + ); + /* Note the cryptographic re-use of $salt here. */ + $encryptionKey = hash_hkdf( + Core::HASH_FUNCTION_NAME, + $prekey, + Core::KEY_BYTE_SIZE, + Core::ENCRYPTION_INFO_STRING, + $salt + ); + + return new DerivedKeys($authenticationKey, $encryptionKey); + } + + throw new EnvironmentIsBrokenException('Bad secret type.'); + } +} diff --git a/packages/DataProtection/src/Encryption/KeyProtectedByPassword.php b/packages/DataProtection/src/Encryption/KeyProtectedByPassword.php new file mode 100644 index 000000000..9cfb5bafb --- /dev/null +++ b/packages/DataProtection/src/Encryption/KeyProtectedByPassword.php @@ -0,0 +1,106 @@ +saveToAsciiSafeString(), hash(Core::HASH_FUNCTION_NAME, $password, true), true); + + return new KeyProtectedByPassword($encrypted_key); + } + + /** + * Loads a KeyProtectedByPassword from its encoded form. + * + * @throws BadFormatException|EnvironmentIsBrokenException|CryptoException + */ + public static function loadFromAsciiSafeString(#[SensitiveParameter]string $saved_key_string): self + { + $encrypted_key = Encoding::loadBytesFromChecksummedAsciiSafeString( + self::PASSWORD_KEY_CURRENT_VERSION, + $saved_key_string + ); + + return new KeyProtectedByPassword($encrypted_key); + } + + /** + * Encodes the KeyProtectedByPassword into a string of printable ASCII characters. + * + * @throws EnvironmentIsBrokenException|CryptoException + */ + public function saveToAsciiSafeString(): string + { + return Encoding::saveBytesToChecksummedAsciiSafeString(self::PASSWORD_KEY_CURRENT_VERSION, $this->encrypted_key); + } + + /** + * Decrypts the protected key, returning an unprotected Key object that can be used for encryption and decryption. + * + * @throws CryptoException|EnvironmentIsBrokenException|WrongKeyOrModifiedCiphertextException + */ + public function unlockKey(#[SensitiveParameter] string $password): Key + { + try { + $inner_key_encoded = Crypto::decryptWithPassword($this->encrypted_key, hash(Core::HASH_FUNCTION_NAME, $password, true), true); + + return Key::loadFromAsciiSafeString($inner_key_encoded); + } catch (BadFormatException $ex) { + /* This should never happen unless an attacker replaced the + * encrypted key ciphertext with some other ciphertext that was + * encrypted with the same password. We transform the exception type + * here in order to make the API simpler, avoiding the need to + * document that this method might throw an Ex\BadFormatException. */ + throw new WrongKeyOrModifiedCiphertextException('The decrypted key was found to be in an invalid format. This very likely indicates it was modified by an attacker.', previous: $ex); + } + } + + /** + * Changes the password. + * + * @throws CryptoException|EnvironmentIsBrokenException|WrongKeyOrModifiedCiphertextException + */ + public function changePassword(#[SensitiveParameter]string $current_password, #[SensitiveParameter] string $new_password): static + { + $inner_key = $this->unlockKey($current_password); + /* The password is hashed as a form of poor-man's domain separation + * between this use of encryptWithPassword() and other uses of + * encryptWithPassword() that the user may also be using as part of the + * same protocol. */ + $encrypted_key = Crypto::encryptWithPassword($inner_key->saveToAsciiSafeString(), hash(Core::HASH_FUNCTION_NAME, $new_password, true), true); + + $this->encrypted_key = $encrypted_key; + + return $this; + } +} diff --git a/packages/DataProtection/src/Encryption/RuntimeTests.php b/packages/DataProtection/src/Encryption/RuntimeTests.php new file mode 100644 index 000000000..6d7f01c05 --- /dev/null +++ b/packages/DataProtection/src/Encryption/RuntimeTests.php @@ -0,0 +1,241 @@ +getRawBytes()) === Core::KEY_BYTE_SIZE); + + Core::ensureTrue(Core::ENCRYPTION_INFO_STRING !== Core::AUTHENTICATION_INFO_STRING); + } catch (EnvironmentIsBrokenException $ex) { + // Do this, otherwise it will stay in the "tests are running" state. + $test_state = 3; + + throw $ex; + } + + // Change this to '0' make the tests always re-run (for benchmarking). + $test_state = 1; + } + + /** + * High-level tests of Crypto operations. + * + * @throws CryptoException|EnvironmentIsBrokenException|WrongKeyOrModifiedCiphertextException + */ + private static function testEncryptDecrypt(): void + { + $key = Key::createNewRandomKey(); + $data = "EnCrYpT EvErYThInG\x00\x00"; + + // Make sure encrypting then decrypting doesn't change the message. + $ciphertext = Crypto::encrypt($data, $key, true); + try { + $decrypted = Crypto::decrypt($ciphertext, $key, true); + } catch (WrongKeyOrModifiedCiphertextException $ex) { + // It's important to catch this and change it into a + // Ex\EnvironmentIsBrokenException, otherwise a test failure could trick + // the user into thinking it's just an invalid ciphertext! + throw new EnvironmentIsBrokenException(previous: $ex); + } + Core::ensureTrue($decrypted === $data); + + // Modifying the ciphertext: Appending a string. + try { + Crypto::decrypt($ciphertext . 'a', $key, true); + + throw new EnvironmentIsBrokenException(); + } catch (WrongKeyOrModifiedCiphertextException $e) { /* expected */ + } + + // Modifying the ciphertext: Changing an HMAC byte. + $indices_to_change = [ + 0, // The header. + Core::HEADER_VERSION_SIZE + 1, // the salt + Core::HEADER_VERSION_SIZE + Core::SALT_BYTE_SIZE + 1, // the IV + Core::HEADER_VERSION_SIZE + Core::SALT_BYTE_SIZE + Core::BLOCK_BYTE_SIZE + 1, // the ciphertext + ]; + + foreach ($indices_to_change as $index) { + try { + $ciphertext[$index] = chr((ord($ciphertext[$index]) + 1) % 256); + Crypto::decrypt($ciphertext, $key, true); + + throw new EnvironmentIsBrokenException(); + } catch (WrongKeyOrModifiedCiphertextException $e) { /* expected */ + } + } + + // Decrypting with the wrong key. + $key = Key::createNewRandomKey(); + $data = 'abcdef'; + $ciphertext = Crypto::encrypt($data, $key, true); + $wrong_key = Key::createNewRandomKey(); + try { + Crypto::decrypt($ciphertext, $wrong_key, true); + + throw new EnvironmentIsBrokenException(); + } catch (WrongKeyOrModifiedCiphertextException $e) { /* expected */ + } + + // Ciphertext too small. + $key = Key::createNewRandomKey(); + $ciphertext = str_repeat('A', Core::MINIMUM_CIPHERTEXT_SIZE - 1); + try { + Crypto::decrypt($ciphertext, $key, true); + + throw new EnvironmentIsBrokenException(); + } catch (WrongKeyOrModifiedCiphertextException $e) { /* expected */ + } + } + + /** + * Test HKDF against test vectors. + * + * @throws EnvironmentIsBrokenException|BadFormatException + */ + private static function HKDFTestVector(): void + { + // HKDF test vectors from RFC 5869 + + // Test Case 1 + $ikm = str_repeat("\x0b", 22); + $salt = Encoding::hexToBin('000102030405060708090a0b0c'); + $info = Encoding::hexToBin('f0f1f2f3f4f5f6f7f8f9'); + $length = 42; + $okm = Encoding::hexToBin( + '3cb25f25faacd57a90434f64d0362f2a' . + '2d2d0a90cf1a5a4c5db02d56ecc4c5bf' . + '34007208d5b887185865' + ); + $computed_okm = hash_hkdf('sha256', $ikm, $length, $info, $salt); + Core::ensureTrue($computed_okm === $okm); + + // Test Case 7 + $ikm = str_repeat("\x0c", 22); + $length = 42; + $okm = Encoding::hexToBin( + '2c91117204d745f3500d636a62f64f0a' . + 'b3bae548aa53d423b0d1f27ebba6f5e5' . + '673a081d70cce7acfc48' + ); + $computed_okm = hash_hkdf('sha1', $ikm, $length); + + Core::ensureTrue($computed_okm === $okm); + } + + /** + * Test HMAC against test vectors. + * + * @throws EnvironmentIsBrokenException + */ + private static function HMACTestVector(): void + { + // HMAC test vector From RFC 4231 (Test Case 1) + $key = str_repeat("\x0b", 20); + $data = 'Hi There'; + $correct = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'; + + Core::ensureTrue(hash_hmac(Core::HASH_FUNCTION_NAME, $data, $key) === $correct); + } + + /** + * Test AES against test vectors. + * + * @throws EnvironmentIsBrokenException|BadFormatException + */ + private static function AESTestVector(): void + { + // AES CTR mode test vector from NIST SP 800-38A + $key = Encoding::hexToBin( + '603deb1015ca71be2b73aef0857d7781' . + '1f352c073b6108d72d9810a30914dff4' + ); + $iv = Encoding::hexToBin('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'); + $plaintext = Encoding::hexToBin( + '6bc1bee22e409f96e93d7e117393172a' . + 'ae2d8a571e03ac9c9eb76fac45af8e51' . + '30c81c46a35ce411e5fbc1191a0a52ef' . + 'f69f2445df4f9b17ad2b417be66c3710' + ); + $ciphertext = Encoding::hexToBin( + '601ec313775789a5b7a7f504bbf3d228' . + 'f443e3ca4d62b59aca84e990cacaf5c5' . + '2b0930daa23de94ce87017ba2d84988d' . + 'dfc9c58db67aada613c2dd08457941a6' + ); + + $computed_ciphertext = Crypto::plainEncrypt($plaintext, $key, $iv); + Core::ensureTrue($computed_ciphertext === $ciphertext); + + $computed_plaintext = Crypto::plainDecrypt($ciphertext, $key, $iv, Core::CIPHER_METHOD); + Core::ensureTrue($computed_plaintext === $plaintext); + } +} diff --git a/packages/DataProtection/src/Encryption/docs/CryptoDetails.md b/packages/DataProtection/src/Encryption/docs/CryptoDetails.md new file mode 100644 index 000000000..526064dc0 --- /dev/null +++ b/packages/DataProtection/src/Encryption/docs/CryptoDetails.md @@ -0,0 +1,61 @@ +Cryptography Details +===================== + +Here is a high-level description of how this library works. Any discrepancy +between this documentation and the actual implementation will be considered +a security bug. + +Let's start with the following definitions: + +- HKDF-SHA256(*k*, *n*, *info*, *s*) is the key derivation function specified in + RFC 5869 (using the SHA256 hash function). The parameters are: + - *k*: The initial keying material. + - *n*: The number of output bytes. + - *info*: The info string. + - *s*: The salt. +- AES-256-CTR(*m*, *k*, *iv*) is AES-256 encryption in CTR mode. The parameters + are: + - *m*: An arbitrary-length (possibly zero-length) message. + - *k*: A 32-byte key. + - *iv*: A 16-byte initialization vector (nonce). +- PBKDF2-SHA256(*p*, *s*, *i*, *n*) is the password-based key derivation + function defined in RFC 2898 (using the SHA256 hash function). The parameters + are: + - *p*: The password string. + - *s*: The salt string. + - *i*: The iteration count. + - *n*: The output length in bytes. +- VERSION is the string `"\xDE\xF5\x02\x00"`. +- AUTHINFO is the string `"Ecotone|KeyForAuthentication"`. +- ENCRINFO is the string `"Ecotone|KeyForEncryption"`. + +To encrypt a message *m* using a 32-byte key *k*, the following steps are taken: + +1. Generate a random 32-byte string *salt*. +2. Derive the 32-byte authentication key *akey* = HKDF-SHA256(*k*, 32, AUTHINFO, *salt*). +3. Derive the 32-byte encryption key *ekey* = HKDF-SHA256(*k*, 32, ENCRINFO, *salt*). +4. Generate a random 16-byte initialization vector *iv*. +5. Compute *c* = AES-256-CTR(*m*, *ekey*, *iv*). +6. Combine *ctxt* = VERSION || *salt* || *iv* || *c*. +7. Compute *h* = HMAC-SHA256(*ctxt*, *akey*). +8. Output *ctxt* || *h*. + +Decryption is roughly the reverse process (see the code for details, since the +security of the decryption routine is highly implementation-dependent). + +For encryption using a password *p*, steps 1-3 above are replaced by: + +1. Generate a random 32-byte string *salt*. +2. Compute *k* = PBKDF2-SHA256(SHA256(*p*), *salt*, 100000, 32). +3. Derive the 32-byte authentication key *akey* = HKDF-SHA256(*k*, 32, AUTHINFO, *salt*) +4. Derive the 32-byte encryption key *ekey* = HKDF-SHA256(*k*, 32, ENCRINFO, *salt*) + +The remainder of the process is the same. Notice the reuse of the same *salt* +for PBKDF2-SHA256 and HKDF-SHA256. The prehashing of the password in step 2 is +done to prevent a DoS attack using long passwords. + +For `KeyProtectedByPassword`, the serialized key is encrypted according to the +password encryption defined above. However, the actual password used for +encryption is the SHA256 hash of the password the user provided. This is done in +order to provide domain separation between the message encryption in the user's +application and the internal key encryption done by this library. diff --git a/packages/DataProtection/src/Encryption/docs/FAQ.md b/packages/DataProtection/src/Encryption/docs/FAQ.md new file mode 100644 index 000000000..9f7739251 --- /dev/null +++ b/packages/DataProtection/src/Encryption/docs/FAQ.md @@ -0,0 +1,51 @@ +Frequently Asked Questions +=========================== + +How do I use this library to encrypt passwords? +------------------------------------------------ + +Passwords should not be encrypted, they should be hashed with a *slow* password +hashing function that's designed to slow down password guessing attacks. See +[How to Safely Store Your Users' Passwords in +2016](https://paragonie.com/blog/2016/02/how-safely-store-password-in-2016). + +How do I give it the same key every time instead of a new random key? +---------------------------------------------------------------------- + +A `Key` object can be saved to a string by calling its `saveToAsciiSafeString()` +method. You will have to save that string somewhere safe, and then load it back +into a `Key` object using `Key`'s `loadFromAsciiSafeString` static method. + +Where you store the string depends on your application. For example if you are +using `KeyProtectedByPassword` to encrypt files with a user's login password, +then you should not store the `Key` at all. If you are protecting sensitive data +on a server that may be compromised, then you should store it in a hardware +security module. When in doubt, consult a security expert. + +Why is an EnvironmentIsBrokenException getting thrown? +------------------------------------------------------- + +Either you've encountered a bug in this library, or your system doesn't support +the use of this library. For example, if your system does not have a secure +random number generator, this library will refuse to run, by throwing that +exception, instead of falling back to an insecure random number generator. + +Why am I getting a BadFormatException when loading a Key from a string? +------------------------------------------------------------------------ + +If you're getting this exception, then the string you're giving to +`loadFromAsciiSafeString()` is *not* the same as the string you got from +`saveToAsciiSafeString()`. Perhaps your database column isn't wide enough and +it's truncating the string as you insert it? + +Does encrypting hide the length of the plaintext? +-------------------------------------------------- + +Encryption does not, and is not intended to, hide the length of the data being +encrypted. For example, it is not safe to encrypt a field in which only a small +number of different-length values are possible (e.g. "male" or "female") since +it would be possible to tell what the plaintext is by looking at the length of +the ciphertext. In order to do this safely, it is your responsibility to, before +encrypting, pad the data out to the length of the longest string that will ever +be encrypted. This way, all plaintexts are the same length, and no information +about the plaintext can be gleaned from the length of the ciphertext. diff --git a/packages/DataProtection/src/Encryption/docs/InternalDeveloperDocs.md b/packages/DataProtection/src/Encryption/docs/InternalDeveloperDocs.md new file mode 100644 index 000000000..c8de7d626 --- /dev/null +++ b/packages/DataProtection/src/Encryption/docs/InternalDeveloperDocs.md @@ -0,0 +1,70 @@ +Information for the Developers of php-encryption +================================================= + +Status +------- + +This library is currently frozen under a long-term support release. We do not +plan to add any new features. We will maintain the library by fixing any bugs +that are reported, or security vulnerabilities that are found. + +Development Environment +------------------------ + +Development is done on Linux. To run the tests, you will need to have the +following tools installed: + +- `php` (with OpenSSL enabled, if you're compiling from source). +- `gpg` +- `composer` + +Running the Tests +------------------ + +First do `composer install` and then you can run the tests by running composer tests:ci`. This will run the tests in `test/unit`. + +Reporting Bugs +--------------- + +Please report bugs, even critical security vulnerabilities, by opening an issue +on GitHub. We recommend disclosing security vulnerabilities found in this +library *publicly* as soon as possible. + +Philosophy +----------- + +This library is developed around several core values: + +- Rule #1: Security is prioritized over everything else. + + > Whenever there is a conflict between security and some other property, + > security will be favored. For example, the library has runtime tests, + > which make it slower, but will hopefully stop it from encrypting stuff + > if the platform it's running on is broken. + +- Rule #2: It should be difficult to misuse the library. + + > We assume the developers using this library have no experience with + > cryptography. We only assume that they know that the "key" is something + > you need to encrypt and decrypt the messages, and that it must be kept + > secret. Whenever possible, the library should refuse to encrypt or decrypt + > messages when it is not being used correctly. + +- Rule #3: The library aims only to be compatible with itself. + + > Other PHP encryption libraries try to support every possible type of + > encryption, even the insecure ones (e.g. ECB mode). Because there are so + > many options, inexperienced developers must decide whether to use "CBC + > mode" or "ECB mode" when both are meaningless terms to them. This + > inevitably leads to vulnerabilities. + + > This library will only support one secure mode. A developer using this + > library will call "encrypt" and "decrypt" methods without worrying about + > how they are implemented. + +- Rule #4: The library should require no special installation. + + > Some PHP encryption libraries, like libsodium-php, are not straightforward + > to install and cannot packaged with "just download and extract" + > applications. This library will always be just a handful of PHP files that + > you can copy to your source tree and require(). diff --git a/packages/DataProtection/src/Encryption/docs/Tutorial.md b/packages/DataProtection/src/Encryption/docs/Tutorial.md new file mode 100644 index 000000000..f91a5c436 --- /dev/null +++ b/packages/DataProtection/src/Encryption/docs/Tutorial.md @@ -0,0 +1,314 @@ +Tutorial +========= + +Hello! If you're reading this file, it's because you want to add encryption to +one of your PHP projects. My job, as the person writing this documentation, is +to help you make sure you're doing the right thing and then show you how to use +this library to do it. To help me help you, please read the documentation +*carefully* and *deliberately*. + +A Word of Caution +------------------ + +Encryption is not magic dust you can sprinkle on a system to make it more +secure. The way encryption is integrated into a system's design needs to be +carefully thought out. Sometimes, encryption is the wrong thing to use. Other +times, encryption needs to be used in a very specific way in order for it to +work as intended. Even if you are sure of what you are doing, we strongly +recommend seeking advice from an expert. + +The first step is to think about your application's threat model. Ask yourself +the following questions. Who will want to attack my application, and what will +they get out of it? Are they trying to steal some information? Trying to alter +or destroy some information? Or just trying to make the system go down so people +can't access it? Then ask yourself how encryption can help combat those threats. +If you're going to add encryption to your application, you should have a very +clear idea of exactly which kinds of attacks it's helping to secure your +application against. Once you have your threat model, think about what kinds of +attacks it *does not* cover, and whether or not you should improve your threat +model to include those attacks. + +**This isn't for storing user login passwords:** The most common use of +cryptography in web applications is to protect the users' login passwords. If +you're trying to use this library to "encrypt" your users' passwords, you're in +the wrong place. Passwords shouldn't be *encrypted*, they should be *hashed* +with a slow computation-heavy function that makes password guessing attacks more +expensive. See [How to Safely Store Your Users' Passwords in +2016](https://paragonie.com/blog/2016/02/how-safely-store-password-in-2016). + +**This isn't for encrypting network communication:** Likewise, if you're trying +to encrypt messages sent between two parties over the Internet, you don't want +to be using this library. For that, set up a TLS connection between the two +points, or, if it's a chat app, use the [Signal +Protocol](https://whispersystems.org/blog/advanced-ratcheting/). + +What this library provides is symmetric encryption for "data at rest." This +means it is not suitable for use in building protocols where "data is in motion" +(i.e. moving over a network) except in limited set of cases. + +Please note that **encryption does not, and is not intended to, hide the +*length* of the data being encrypted.** For example, it is not safe to encrypt +a field in which only a small number of different-length values are possible +(e.g. "male" or "female") since it would be possible to tell what the plaintext +is by looking at the length of the ciphertext. In order to do this safely, it is +your responsibility to, before encrypting, pad the data out to the length of the +longest string that will ever be encrypted. This way, all plaintexts are the +same length, and no information about the plaintext can be gleaned from the +length of the ciphertext. + +Getting the Code +----------------- + +There are several different ways to obtain this library's code and to add it to +your project. Even if you've already cloned the code from GitHub, you should +take steps to verify the cryptographic signatures to make sure the code you got +was not intercepted and modified by an attacker. + +Please head over to the [**Installing and +Verifying**](InstallingAndVerifying.md) documentation to get the code, and then +come back here to continue the tutorial. + +Using the Library +------------------ + +I'm going to assume you know what symmetric encryption is, and the difference +between symmetric and asymmetric encryption. If you don't, I recommend taking +[Dan Boneh's Cryptography I course](https://www.coursera.org/learn/crypto/) on +Coursera. + +To give you a quick introduction to the library, I'm going to explain how it +would be used in two sterotypical scenarios. Hopefully, one of these sterotypes +is close enough to what you want to do that you'll be able to figure out what +needs to be different on your own. + +### Formal Documentation + +While this tutorial should get you up and running fast, it's important to +understand how this library behaves. Please make sure to read the formal +documentation of all of the functions you're using, since there are some +important security warnings there. + +The following classes are available for you to use: + +- [Crypto](classes/Crypto.md): Encrypting and decrypting strings. +- [File](classes/File.md): Encrypting and decrypting files. +- [Key](classes/Key.md): Represents a secret encryption key. +- [KeyProtectedByPassword](classes/KeyProtectedByPassword.md): Represents + a secret encryption key that needs to be "unlocked" by a password before it + can be used. + +### Scenario #1: Keep data secret from the database administrator + +In this scenario, our threat model is as follows. Alice is a server +administrator responsible for managing a trusted web server. Eve is a database +administrator responsible for managing a database server. Dave is a web +developer working on code that will eventually run on the trusted web server. + +Let's say Alice and Dave trust each other, and Alice is going to host Dave's +application on her server. But both Alice and Dave don't trust Eve. They know +Eve is a good database administrator, but she might have incentive to steal the +data from the database. They want to keep some of the web application's data +secret from Eve. + +In order to do that, Alice will use the included `generate-defuse-key` script +which generates a random encryption key and prints it to standard output: + +```sh +$ composer require defuse/php-encryption +$ vendor/bin/generate-defuse-key +``` + +Alice will run this script once and save the output to a configuration file, say +in `/etc/daveapp-secret-key.txt` and set the file permissions so that only the +user that the website PHP scripts run as can access it. + +Dave will write his code to load the key from the configuration file: + +```php +saveToAsciiSafeString(); + // ... save $protected_key_encoded into the user's account record +} +``` + +**WARNING:** Because of the way `KeyProtectedByPassword` is implemented, knowing +`SHA256($password)` is enough to decrypt a `KeyProtectedByPassword`. To be +secure, your application MUST NOT EVER compute `SHA256($password)` and use or +store it for any reason. You must also make sure that other libraries your +application is using don't compute it either. + +Then, when the user logs in, Dave's code will load the protected key from the +user's account record, unlock it to get a `Key` object, and save the `Key` +object somewhere safe (like temporary memory-backed session storage or +a cookie). Note that wherever Dave's code saves the key, it must be destroyed +once the user logs out, or else the attacker might be able to find users' keys +even if they were never logged in during the attack. + +```php +unlockKey($password); +$user_key_encoded = $user_key->saveToAsciiSafeString(); +// ... save $user_key_encoded in a cookie +``` + +```php +channelObfuscatorReference, - $this->messageObfuscatorReferences, + $this->channelEncryptionReference, + $this->messageEncryptionReferences, ] ); } diff --git a/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php b/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php index 9be4fe98c..5540564b9 100644 --- a/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php +++ b/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php @@ -6,7 +6,7 @@ namespace Ecotone\DataProtection; -use Ecotone\DataProtection\Obfuscator\Obfuscator; +use Ecotone\DataProtection\MessageEncryption\MessageEncryptor; use Ecotone\Messaging\Channel\AbstractChannelInterceptor; use Ecotone\Messaging\Conversion\MediaType; use Ecotone\Messaging\Message; @@ -17,13 +17,13 @@ class OutboundDecryptionChannelInterceptor extends AbstractChannelInterceptor { /** - * @param array $messageObfuscators + * @param array $messageEncryptors */ public function __construct( - private readonly ?Obfuscator $channelObfuscator, - private readonly array $messageObfuscators, + private readonly ?MessageEncryptor $channelEncryptor, + private readonly array $messageEncryptors, ) { - Assert::allInstanceOfType($this->messageObfuscators, Obfuscator::class); + Assert::allInstanceOfType($this->messageEncryptors, MessageEncryptor::class); } public function postReceive(Message $message, MessageChannel $messageChannel): ?Message @@ -32,18 +32,18 @@ public function postReceive(Message $message, MessageChannel $messageChannel): ? return $message; } - if ($messageObfuscator = $this->findMessageObfuscator($message)) { - return $messageObfuscator->decrypt($message); + if ($messageEncryptor = $this->findMessageEncryptor($message)) { + return $messageEncryptor->decrypt($message); } - if ($this->channelObfuscator) { - return $this->channelObfuscator->decrypt($message); + if ($this->channelEncryptor) { + return $this->channelEncryptor->decrypt($message); } return $message; } - private function findMessageObfuscator(Message $message): ?Obfuscator + private function findMessageEncryptor(Message $message): ?MessageEncryptor { if (! $message->getHeaders()->containsKey(MessageHeaders::TYPE_ID)) { return null; @@ -51,6 +51,6 @@ private function findMessageObfuscator(Message $message): ?Obfuscator $type = $message->getHeaders()->get(MessageHeaders::TYPE_ID); - return $this->messageObfuscators[$type] ?? null; + return $this->messageEncryptors[$type] ?? null; } } diff --git a/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php b/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php index 1cf9da4b0..d05db8157 100644 --- a/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php +++ b/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php @@ -15,9 +15,9 @@ readonly class OutboundEncryptionChannelBuilder implements ChannelInterceptorBuilder { public function __construct( - private string $relatedChannel, - private ?Reference $channelObfuscatorReference, - private array $messageObfuscatorReferences, + private string $relatedChannel, + private ?Reference $channelEncryptorReference, + private array $messageEncryptorReferences, ) { } @@ -36,8 +36,8 @@ public function compile(MessagingContainerBuilder $builder): Definition return new Definition( OutboundEncryptionChannelInterceptor::class, [ - $this->channelObfuscatorReference, - $this->messageObfuscatorReferences, + $this->channelEncryptorReference, + $this->messageEncryptorReferences, ] ); } diff --git a/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php b/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php index e559dab59..27da47cb2 100644 --- a/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php +++ b/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php @@ -6,7 +6,7 @@ namespace Ecotone\DataProtection; -use Ecotone\DataProtection\Obfuscator\Obfuscator; +use Ecotone\DataProtection\MessageEncryption\MessageEncryptor; use Ecotone\Messaging\Channel\AbstractChannelInterceptor; use Ecotone\Messaging\Conversion\MediaType; use Ecotone\Messaging\Message; @@ -17,13 +17,13 @@ class OutboundEncryptionChannelInterceptor extends AbstractChannelInterceptor { /** - * @param array $messageObfuscators + * @param array $messageEncryptors */ public function __construct( - private readonly ?Obfuscator $channelObfuscator, - private readonly array $messageObfuscators, + private readonly ?MessageEncryptor $channelEncryptor, + private readonly array $messageEncryptors, ) { - Assert::allInstanceOfType($this->messageObfuscators, Obfuscator::class); + Assert::allInstanceOfType($this->messageEncryptors, MessageEncryptor::class); } public function preSend(Message $message, MessageChannel $messageChannel): ?Message @@ -32,18 +32,18 @@ public function preSend(Message $message, MessageChannel $messageChannel): ?Mess return $message; } - if ($messageObfuscator = $this->findMessageObfuscator($message)) { - return $messageObfuscator->encrypt($message); + if ($messageEncryptor = $this->findMessageEncryptor($message)) { + return $messageEncryptor->encrypt($message); } - if ($this->channelObfuscator) { - return $this->channelObfuscator->encrypt($message); + if ($this->channelEncryptor) { + return $this->channelEncryptor->encrypt($message); } return $message; } - private function findMessageObfuscator(Message $message): ?Obfuscator + private function findMessageEncryptor(Message $message): ?MessageEncryptor { if (! $message->getHeaders()->containsKey(MessageHeaders::TYPE_ID)) { return null; @@ -51,6 +51,6 @@ private function findMessageObfuscator(Message $message): ?Obfuscator $type = $message->getHeaders()->get(MessageHeaders::TYPE_ID); - return $this->messageObfuscators[$type] ?? null; + return $this->messageEncryptors[$type] ?? null; } } diff --git a/packages/DataProtection/tests/Fixture/ObfuscateMessages/TestCommandHandler.php b/packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestCommandHandler.php similarity index 77% rename from packages/DataProtection/tests/Fixture/ObfuscateMessages/TestCommandHandler.php rename to packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestCommandHandler.php index 1f9f02e64..db55b57f7 100644 --- a/packages/DataProtection/tests/Fixture/ObfuscateMessages/TestCommandHandler.php +++ b/packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestCommandHandler.php @@ -1,6 +1,6 @@ withReceived($message, $headers); } - #[CommandHandler(endpointId: 'test.obfuscateAnnotatedMessages.commandHandler.AnnotatedMessageWithSecondaryEncryptionKey')] + #[CommandHandler(endpointId: 'test.EncryptAnnotatedMessages.commandHandler.AnnotatedMessageWithSecondaryEncryptionKey')] public function handleAnnotatedMessageWithSecondaryEncryptionKey( #[Payload] AnnotatedMessageWithSecondaryEncryptionKey $message, #[Headers] array $headers, @@ -33,7 +33,7 @@ public function handleAnnotatedMessageWithSecondaryEncryptionKey( $messageReceiver->withReceived($message, $headers); } - #[CommandHandler(endpointId: 'test.obfuscateAnnotatedMessages.commandHandler.AnnotatedMessageWithSensitiveHeaders')] + #[CommandHandler(endpointId: 'test.EncryptAnnotatedMessages.commandHandler.AnnotatedMessageWithSensitiveHeaders')] public function handleAnnotatedMessageWithSensitiveHeaders( #[Payload] AnnotatedMessageWithSensitiveHeaders $message, #[Headers] array $headers, diff --git a/packages/DataProtection/tests/Fixture/ObfuscateMessages/TestEventHandler.php b/packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestEventHandler.php similarity index 78% rename from packages/DataProtection/tests/Fixture/ObfuscateMessages/TestEventHandler.php rename to packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestEventHandler.php index 907108f6f..860aa02b4 100644 --- a/packages/DataProtection/tests/Fixture/ObfuscateMessages/TestEventHandler.php +++ b/packages/DataProtection/tests/Fixture/EncryptAnnotatedMessages/TestEventHandler.php @@ -1,6 +1,6 @@ withReceived($message, $headers); } - #[EventHandler(endpointId: 'test.obfuscateAnnotatedMessages.eventHandler.AnnotatedMessageWithSecondaryEncryptionKey')] + #[EventHandler(endpointId: 'test.EncryptAnnotatedMessages.eventHandler.AnnotatedMessageWithSecondaryEncryptionKey')] public function handleAnnotatedMessageWithSecondaryEncryptionKey( #[Payload] AnnotatedMessageWithSecondaryEncryptionKey $message, #[Headers] array $headers, @@ -33,7 +33,7 @@ public function handleAnnotatedMessageWithSecondaryEncryptionKey( $messageReceiver->withReceived($message, $headers); } - #[EventHandler(endpointId: 'test.obfuscateAnnotatedMessages.eventHandler.AnnotatedMessageWithSensitiveHeaders')] + #[EventHandler(endpointId: 'test.EncryptAnnotatedMessages.eventHandler.AnnotatedMessageWithSensitiveHeaders')] public function handleAnnotatedMessageWithSensitiveHeaders( #[Payload] AnnotatedMessageWithSensitiveHeaders $message, #[Headers] array $headers, diff --git a/packages/DataProtection/tests/Fixture/ObfuscateEndpoints/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php b/packages/DataProtection/tests/Fixture/EncryptMessagesWithAnnotatedEndpoint/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php similarity index 83% rename from packages/DataProtection/tests/Fixture/ObfuscateEndpoints/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php rename to packages/DataProtection/tests/Fixture/EncryptMessagesWithAnnotatedEndpoint/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php index c197692ce..5ffa4a97d 100644 --- a/packages/DataProtection/tests/Fixture/ObfuscateEndpoints/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php +++ b/packages/DataProtection/tests/Fixture/EncryptMessagesWithAnnotatedEndpoint/CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage.php @@ -1,6 +1,6 @@ withReceived($message, $headers); } - #[CommandHandler(routingKey: 'command', endpointId: 'test.ObfuscateChannel.commandHandler.withoutPayload')] + #[CommandHandler(routingKey: 'command', endpointId: 'test.EncryptMessagesWithChannelConfiguration.commandHandler.withoutPayload')] public function withoutPayload( #[Headers] array $headers, #[Reference] MessageReceiver $messageReceiver, diff --git a/packages/DataProtection/tests/Fixture/ObfuscateChannel/TestEventHandler.php b/packages/DataProtection/tests/Fixture/EncryptMessagesWithChannelConfiguration/TestEventHandler.php similarity index 72% rename from packages/DataProtection/tests/Fixture/ObfuscateChannel/TestEventHandler.php rename to packages/DataProtection/tests/Fixture/EncryptMessagesWithChannelConfiguration/TestEventHandler.php index 6c735c240..1a39a8d6a 100644 --- a/packages/DataProtection/tests/Fixture/ObfuscateChannel/TestEventHandler.php +++ b/packages/DataProtection/tests/Fixture/EncryptMessagesWithChannelConfiguration/TestEventHandler.php @@ -1,6 +1,6 @@ withReceived($message, $headers); } - #[EventHandler(listenTo: 'event', endpointId: 'test.ObfuscateChannel.eventHandler.withoutPayload')] + #[EventHandler(listenTo: 'event', endpointId: 'test.EncryptMessagesWithChannelConfiguration.eventHandler.withoutPayload')] public function handleRoutingKey( #[Headers] array $headers, #[Reference] MessageReceiver $messageReceiver, diff --git a/packages/DataProtection/tests/Fixture/files/empty-file.txt b/packages/DataProtection/tests/Fixture/files/empty-file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/packages/DataProtection/tests/Fixture/files/wat-gigantic-duck.jpg b/packages/DataProtection/tests/Fixture/files/wat-gigantic-duck.jpg new file mode 100644 index 000000000..ea1dc04a1 Binary files /dev/null and b/packages/DataProtection/tests/Fixture/files/wat-gigantic-duck.jpg differ diff --git a/packages/DataProtection/tests/Integration/ObfuscateMessagesTest.php b/packages/DataProtection/tests/Integration/EncryptAnnotatedMessagesTest.php similarity index 95% rename from packages/DataProtection/tests/Integration/ObfuscateMessagesTest.php rename to packages/DataProtection/tests/Integration/EncryptAnnotatedMessagesTest.php index 0c66c3ab1..f0a586031 100644 --- a/packages/DataProtection/tests/Integration/ObfuscateMessagesTest.php +++ b/packages/DataProtection/tests/Integration/EncryptAnnotatedMessagesTest.php @@ -2,10 +2,10 @@ namespace Test\Ecotone\DataProtection\Integration; -use Defuse\Crypto\Crypto; -use Defuse\Crypto\Key; use Ecotone\DataProtection\Configuration\ChannelProtectionConfiguration; use Ecotone\DataProtection\Configuration\DataProtectionConfiguration; +use Ecotone\DataProtection\Encryption\Crypto; +use Ecotone\DataProtection\Encryption\Key; use Ecotone\JMSConverter\JMSConverterConfiguration; use Ecotone\Lite\EcotoneLite; use Ecotone\Lite\Test\FlowTestSupport; @@ -19,9 +19,9 @@ use Test\Ecotone\DataProtection\Fixture\AnnotatedMessage; use Test\Ecotone\DataProtection\Fixture\AnnotatedMessageWithSecondaryEncryptionKey; use Test\Ecotone\DataProtection\Fixture\AnnotatedMessageWithSensitiveHeaders; +use Test\Ecotone\DataProtection\Fixture\EncryptAnnotatedMessages\TestCommandHandler; +use Test\Ecotone\DataProtection\Fixture\EncryptAnnotatedMessages\TestEventHandler; use Test\Ecotone\DataProtection\Fixture\MessageReceiver; -use Test\Ecotone\DataProtection\Fixture\ObfuscateMessages\TestCommandHandler; -use Test\Ecotone\DataProtection\Fixture\ObfuscateMessages\TestEventHandler; use Test\Ecotone\DataProtection\Fixture\TestClass; use Test\Ecotone\DataProtection\Fixture\TestEnum; use Test\Ecotone\DataProtection\TestQueueChannel; @@ -29,7 +29,7 @@ /** * @internal */ -class ObfuscateMessagesTest extends TestCase +class EncryptAnnotatedMessagesTest extends TestCase { private Key $primaryKey; private Key $secondaryKey; @@ -40,7 +40,7 @@ protected function setUp(): void $this->secondaryKey = Key::createNewRandomKey(); } - public function test_command_handler_with_obfuscate_annotated_message(): void + public function test_protect_commands_using_message_annotations(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -86,7 +86,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_message_with_non_default_key(): void + public function test_protect_commands_using_non_default_key(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -135,7 +135,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_message_with_sensitive_headers(): void + public function test_protect_commands_with_sensitive_headers(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -183,7 +183,7 @@ enum: TestEnum::FIRST, self::assertFalse($messageHeaders->containsKey('fos')); } - public function test_obfuscate_event_handler_with_annotated_message(): void + public function test_protect_events_using_message_annotations(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -229,7 +229,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_event_handler_message_with_non_default_key(): void + public function test_protect_events_using_non_default_key(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -278,7 +278,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_event_handler_message_with_sensitive_headers(): void + public function test_protect_events_with_sensitive_headers(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -334,7 +334,7 @@ classesToResolve: $classesToResolve, configuration: ServiceConfiguration::createWithDefaults() ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DATA_PROTECTION_PACKAGE, ModulePackageList::JMS_CONVERTER_PACKAGE])) - ->withNamespaces(['Test\Ecotone\DataProtection\Fixture\ObfuscateAnnotatedMessages']) + ->withNamespaces(['Test\Ecotone\DataProtection\Fixture\EncryptAnnotatedMessages']) ->withExtensionObjects( array_merge( [ diff --git a/packages/DataProtection/tests/Integration/ObfuscateEndpointsTest.php b/packages/DataProtection/tests/Integration/EncryptMessagesWithAnnotatedEndpointTest.php similarity index 90% rename from packages/DataProtection/tests/Integration/ObfuscateEndpointsTest.php rename to packages/DataProtection/tests/Integration/EncryptMessagesWithAnnotatedEndpointTest.php index 357fe3f7c..f145cfcbd 100644 --- a/packages/DataProtection/tests/Integration/ObfuscateEndpointsTest.php +++ b/packages/DataProtection/tests/Integration/EncryptMessagesWithAnnotatedEndpointTest.php @@ -2,10 +2,10 @@ namespace Test\Ecotone\DataProtection\Integration; -use Defuse\Crypto\Crypto; -use Defuse\Crypto\Key; use Ecotone\DataProtection\Configuration\ChannelProtectionConfiguration; use Ecotone\DataProtection\Configuration\DataProtectionConfiguration; +use Ecotone\DataProtection\Encryption\Crypto; +use Ecotone\DataProtection\Encryption\Key; use Ecotone\JMSConverter\JMSConverterConfiguration; use Ecotone\Lite\EcotoneLite; use Ecotone\Lite\Test\FlowTestSupport; @@ -17,15 +17,15 @@ use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\TestCase; use Test\Ecotone\DataProtection\Fixture\AnnotatedMessage; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\CommandHandlerWithAnnotatedEndpointWithSecondaryEncryptionKey; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\CommandHandlerWithAnnotatedMethodWithoutPayload; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\CommandHandlerWithAnnotatedPayloadAndHeader; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\EventHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\EventHandlerWithAnnotatedEndpointWithSecondaryEncryptionKey; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\EventHandlerWithAnnotatedMethodWithoutPayload; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithAnnotatedEndpoint\EventHandlerWithAnnotatedPayloadAndHeader; use Test\Ecotone\DataProtection\Fixture\MessageReceiver; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\CommandHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\CommandHandlerWithAnnotatedEndpointWithSecondaryEncryptionKey; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\CommandHandlerWithAnnotatedMethodWithoutPayload; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\CommandHandlerWithAnnotatedPayloadAndHeader; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\EventHandlerWithAnnotatedEndpointWithAlreadyAnnotatedMessage; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\EventHandlerWithAnnotatedEndpointWithSecondaryEncryptionKey; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\EventHandlerWithAnnotatedMethodWithoutPayload; -use Test\Ecotone\DataProtection\Fixture\ObfuscateEndpoints\EventHandlerWithAnnotatedPayloadAndHeader; use Test\Ecotone\DataProtection\Fixture\SomeMessage; use Test\Ecotone\DataProtection\Fixture\TestClass; use Test\Ecotone\DataProtection\Fixture\TestEnum; @@ -34,7 +34,7 @@ /** * @internal */ -class ObfuscateEndpointsTest extends TestCase +class EncryptMessagesWithAnnotatedEndpointTest extends TestCase { private Key $primaryKey; private Key $secondaryKey; @@ -45,7 +45,7 @@ protected function setUp(): void $this->secondaryKey = Key::createNewRandomKey(); } - public function test_command_handler_with_annotated_endpoint_with_already_annotated_message(): void + public function test_protect_commands_using_annotated_endpoint_with_already_annotated_message(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -91,7 +91,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_command_handler_with_annotated_endpoint_and_secondary_encryption_key(): void + public function test_protect_commands_using_annotated_endpoint_and_secondary_encryption_key(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -136,7 +136,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_command_handler_with_annotated_method_without_payload_will_use_channel_obfuscator(): void + public function test_protecting_commands_using_annotated_endpoint_without_payload_will_use_channel_obfuscator(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -179,7 +179,7 @@ classesToResolve: [ self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_command_handler_with_annotated_method_with_annotated_payload_and_header(): void + public function test_protect_commands_using_annotated_endpoint_with_annotated_payload_and_header(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -221,7 +221,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['bar'], Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->primaryKey)); } - public function test_command_handler_with_annotated_method_without_payload(): void + public function test_protect_commands_using_annotated_endpoint_without_payload(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -260,7 +260,7 @@ classesToResolve: [ self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_event_handler_with_annotated_endpoint_with_already_annotated_message(): void + public function test_protecting_events_using_annotated_endpoint_with_already_annotated_message(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -306,7 +306,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_event_handler_with_annotated_endpoint_and_secondary_encryption_key(): void + public function test_protect_events_using_annotated_endpoint_and_secondary_encryption_key(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -351,7 +351,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_event_handler_with_annotated_method_without_payload_will_use_channel_obfuscator(): void + public function test_protecting_events_using_annotated_endpoint_without_payload_will_use_channel_obfuscator(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -394,7 +394,7 @@ classesToResolve: [ self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_event_handler_with_annotated_method_with_annotated__payload_and_header(): void + public function test_protect_events_using_annotated_endpoint_with_annotated_payload_and_header(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ @@ -436,7 +436,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['bar'], Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->secondaryKey)); } - public function test_event_handler_with_annotated_method_without_payload(): void + public function test_protect_events_using_annotated_method_without_payload(): void { $ecotone = $this->bootstrapEcotone( classesToResolve: [ diff --git a/packages/DataProtection/tests/Integration/ObfuscateChannelTest.php b/packages/DataProtection/tests/Integration/EncryptMessagesWithChannelConfigurationTest.php similarity index 93% rename from packages/DataProtection/tests/Integration/ObfuscateChannelTest.php rename to packages/DataProtection/tests/Integration/EncryptMessagesWithChannelConfigurationTest.php index 1360e8f52..16ba385b9 100644 --- a/packages/DataProtection/tests/Integration/ObfuscateChannelTest.php +++ b/packages/DataProtection/tests/Integration/EncryptMessagesWithChannelConfigurationTest.php @@ -2,10 +2,10 @@ namespace Test\Ecotone\DataProtection\Integration; -use Defuse\Crypto\Crypto; -use Defuse\Crypto\Key; use Ecotone\DataProtection\Configuration\ChannelProtectionConfiguration; use Ecotone\DataProtection\Configuration\DataProtectionConfiguration; +use Ecotone\DataProtection\Encryption\Crypto; +use Ecotone\DataProtection\Encryption\Key; use Ecotone\JMSConverter\JMSConverterConfiguration; use Ecotone\Lite\EcotoneLite; use Ecotone\Lite\Test\FlowTestSupport; @@ -18,9 +18,9 @@ use Ecotone\Messaging\Support\InvalidArgumentException; use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\TestCase; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithChannelConfiguration\TestCommandHandler; +use Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithChannelConfiguration\TestEventHandler; use Test\Ecotone\DataProtection\Fixture\MessageReceiver; -use Test\Ecotone\DataProtection\Fixture\ObfuscateChannel\TestCommandHandler; -use Test\Ecotone\DataProtection\Fixture\ObfuscateChannel\TestEventHandler; use Test\Ecotone\DataProtection\Fixture\SomeMessage; use Test\Ecotone\DataProtection\Fixture\TestClass; use Test\Ecotone\DataProtection\Fixture\TestEnum; @@ -29,7 +29,7 @@ /** * @internal */ -class ObfuscateChannelTest extends TestCase +class EncryptMessagesWithChannelConfigurationTest extends TestCase { private Key $primaryKey; private Key $secondaryKey; @@ -40,7 +40,7 @@ protected function setUp(): void $this->secondaryKey = Key::createNewRandomKey(); } - public function test_obfuscate_command_handler_channel_with_default_encryption_key(): void + public function test_protect_commands_using_channel_configuration_with_default_encryption_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitiveHeader('foo') @@ -84,7 +84,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_channel_with_default_encryption_key_and_no_sensitive_payload(): void + public function test_protect_commands_using_channel_configuration_with_default_encryption_key_and_no_sensitive_payload(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitivePayload(false) @@ -128,7 +128,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_channel_with_non_default_key(): void + public function test_protect_commands_using_channel_configuration_with_non_default_encryption_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test', 'secondary') ->withSensitiveHeader('foo') @@ -172,7 +172,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_channel_called_with_routing_key(): void + public function test_protect_commands_using_channel_configuration_with_default_encryption_key_and_routing_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitiveHeader('foo') @@ -209,7 +209,7 @@ public function test_obfuscate_command_handler_channel_called_with_routing_key() self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_command_handler_channel_called_with_routing_key_and_no_sensitive_payload(): void + public function test_protect_commands_using_channel_configuration_with_default_encryption_key_and_routing_key_with_no_sensitive_payload(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitivePayload(false) @@ -246,7 +246,7 @@ public function test_obfuscate_command_handler_channel_called_with_routing_key_a self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_event_handler_channel_with_default_encryption_key(): void + public function test_protect_events_using_channel_configuration_with_default_encryption_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitiveHeader('foo') @@ -288,7 +288,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_event_handler_channel_with_non_default_key(): void + public function test_protect_events_using_channel_configuration_with_non_default_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test', 'secondary') ->withSensitiveHeader('foo') @@ -330,7 +330,7 @@ enum: TestEnum::FIRST, self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_event_handler_channel_called_with_routing_key(): void + public function test_protect_events_using_channel_configuration_and_routing_key(): void { $channelProtectionConfiguration = ChannelProtectionConfiguration::create('test') ->withSensitiveHeader('foo') @@ -367,7 +367,7 @@ public function test_obfuscate_event_handler_channel_called_with_routing_key(): self::assertEquals($metadataSent['baz'], $messageHeaders->get('baz')); } - public function test_obfuscate_non_pollable_channel(): void + public function test_protecting_messages_with_non_pollable_channel_is_not_possible(): void { $this->expectExceptionObject(InvalidArgumentException::create('`test` channel must be pollable channel to use Data Protection.')); @@ -402,7 +402,7 @@ private function bootstrapEcotone(ChannelProtectionConfiguration $channelProtect configuration: ServiceConfiguration::createWithDefaults() ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DATA_PROTECTION_PACKAGE, ModulePackageList::JMS_CONVERTER_PACKAGE])) - ->withNamespaces(['Test\Ecotone\DataProtection\Fixture\ObfuscateChannel']) + ->withNamespaces(['Test\Ecotone\DataProtection\Fixture\EncryptMessagesWithChannelConfiguration']) ->withExtensionObjects([ $channelProtectionConfiguration, DataProtectionConfiguration::create('primary', $this->primaryKey) diff --git a/packages/DataProtection/tests/Integration/RequirementsCheckTest.php b/packages/DataProtection/tests/Integration/RequirementsCheckTest.php index f6132c9e4..d9982d346 100644 --- a/packages/DataProtection/tests/Integration/RequirementsCheckTest.php +++ b/packages/DataProtection/tests/Integration/RequirementsCheckTest.php @@ -4,8 +4,8 @@ namespace Test\Ecotone\DataProtection\Integration; -use Defuse\Crypto\Key; use Ecotone\DataProtection\Configuration\DataProtectionConfiguration; +use Ecotone\DataProtection\Encryption\Key; use Ecotone\Lite\EcotoneLite; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; diff --git a/packages/DataProtection/tests/Unit/Encryption/CoreTest.php b/packages/DataProtection/tests/Unit/Encryption/CoreTest.php new file mode 100644 index 000000000..e0a849c45 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/CoreTest.php @@ -0,0 +1,130 @@ +=')) { + self::assertSame(12, strlen($str)); + } else { + self::assertSame(16, strlen($str)); + } + } else { + self::assertSame(16, strlen($str)); + + // We want ourSubstr to behave identically to substr() in PHP 7 in + // the non-mbstring case. This double checks what that behavior is. + if (version_compare(phpversion(), '7.0.0', '>=')) { + self::assertSame( + '', + substr('ABC', 3, 0) + ); + self::assertSame( + '', + substr('ABC', 3) + ); + } else { + // The behavior was changed for PHP 7. It used to be... + self::assertFalse(substr('ABC', 3, 0)); + self::assertFalse(substr('ABC', 3)); + } + // Seriously, fuck this shit. Don't use PHP. ╯‵Д′)╯彡┻━┻ + } + + // This checks that the behavior is indeed the same. + self::assertSame('', Core::substr($str, 16)); + } + + public function test_our_substr_trailing_empty_string_bug_normal(): void + { + // Same as above but with a non-weird string. + $str = 'AAAAAAAAAAAAAAAA'; + if (ini_get('mbstring.func_overload') == 7) { + self::assertSame(16, strlen($str)); + } else { + self::assertSame(16, strlen($str)); + } + self::assertSame(16, Core::strlen($str)); + self::assertSame('', Core::substr($str, 16)); + } + + public function test_our_substr_out_of_borders(): void + { + // See: https://secure.php.net/manual/en/function.mb-substr.php#50275 + + // We want to be like substr, so confirm that behavior. + if (PHP_VERSION_ID < 80000) { + // In PHP 8.0, substr starts returning '' instead of false. + // Core::ourSubstr should behave the OLD way. + self::assertFalse(substr('abc', 5, 2)); + } + + // Confirm that mb_substr does not have that behavior. + if (function_exists('mb_substr')) { + self::assertSame('', mb_substr('abc', 5, 2)); + } + + // Check if we actually have that behavior. + self::assertFalse(Core::substr('abc', 5, 2)); + } + + public function test_our_substr_negative_length(): void + { + $this->expectException(InvalidArgumentException::class); + Core::substr('abc', 0, -1); + } + + public function test_our_substr_negative_start(): void + { + self::assertSame('c', Core::substr('abc', -1, 1)); + } + + public function test_our_substr_length_is_max(): void + { + self::assertSame('bc', Core::substr('abc', 1, 500)); + } + + public function test_secure_random_zero_length(): void + { + $this->expectException(CryptoException::class); + $this->expectExceptionMessage('zero or negative'); + Core::secureRandom(0); + } + + public function test_secure_random_negative_length() + { + $this->expectException(CryptoException::class); + $this->expectExceptionMessage('zero or negative'); + Core::secureRandom(-1); + } + + public function test_secure_random_positive_length() + { + $x = Core::secureRandom(10); + self::assertSame(10, strlen($x)); + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/CryptoTest.php b/packages/DataProtection/tests/Unit/Encryption/CryptoTest.php new file mode 100644 index 000000000..36291f680 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/CryptoTest.php @@ -0,0 +1,126 @@ +expectException(WrongKeyOrModifiedCiphertextException::class); + + $ciphertext = Crypto::encryptWithPassword('testdata', 'password', true); + Crypto::decryptWithPassword($ciphertext, 'password'); + } + + public function test_decrypt_hex_as_raw(): void + { + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + + $ciphertext = Crypto::encryptWithPassword('testdata', 'password'); + Crypto::decryptWithPassword($ciphertext, 'password', true); + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/CtrModeTest.php b/packages/DataProtection/tests/Unit/Encryption/CtrModeTest.php new file mode 100644 index 000000000..cf50a0349 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/CtrModeTest.php @@ -0,0 +1,220 @@ +> 24) & 0xff) . + chr(($rand_a >> 16) & 0xff) . + chr(($rand_a >> 8) & 0xff) . + chr(($rand_a >> 0) & 0xff); + + $sum = $rand_a + $rand_b; + + $expected_end = $prefix . + chr(($sum >> 24) & 0xff) . + chr(($sum >> 16) & 0xff) . + chr(($sum >> 8) & 0xff) . + chr(($sum >> 0) & 0xff); + $actual_end = Core::incrementCounter($start, $rand_b); + + self::assertSame( + \bin2hex($expected_end), + \bin2hex($actual_end), + \bin2hex($start) . ' + ' . $rand_b + ); + } + } + + public function test_increment_by_negative_value(): void + { + $this->expectException(EnvironmentIsBrokenException::class); + + Core::incrementCounter(str_repeat("\x00", 16), -1); + } + + public function test_increment_by_zero(): void + { + $this->expectException(EnvironmentIsBrokenException::class); + + Core::incrementCounter(str_repeat("\x00", 16), 0); + } + + public static function allNonZeroByteValuesProvider(): array + { + $all_bytes = []; + for ($i = 1; $i <= 0xff; $i++) { + $all_bytes[] = [$i]; + } + return $all_bytes; + } + + #[DataProvider('allNonZeroByteValuesProvider')] + public function test_increment_causing_overflow_in_first_byte($lsb): void + { + $this->expectException(EnvironmentIsBrokenException::class); + + /* Smallest value that will overflow. */ + $increment = (PHP_INT_MAX - $lsb) + 1; + $start = str_repeat("\x00", 15) . chr($lsb); + + Core::incrementCounter($start, $increment); + } + + public function test_increment_with_short_iv_length(): void + { + $this->expectException(EnvironmentIsBrokenException::class); + + Core::incrementCounter(str_repeat("\x00", 15), 1); + } + + public function test_increment_with_long_iv_length(): void + { + $this->expectException(EnvironmentIsBrokenException::class); + + Core::incrementCounter(str_repeat("\x00", 17), 1); + } + + public function test_compatibility_with_open_ssl(): void + { + /* Plaintext is 0x300 blocks. */ + $plaintext = str_repeat('a', 0x300 * 16); + + /* Start at zero. */ + $starting_nonce = str_repeat("\x00", 16); + + $ciphertext = openssl_encrypt( + $plaintext, + Core::CIPHER_METHOD, + 'YELLOW SUBMARINE', + OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, + $starting_nonce + ); + + /* Take the second half, the last 0x150 blocks. */ + $cipher_lasthalf = mb_substr($ciphertext, 0x150 * 16, 0x150 * 16, '8bit'); + + /* Compute what the nonce should be at the start of the last half. */ + $computed_nonce = Core::incrementCounter( + $starting_nonce, + 0x150 + ); + + /* Try to decrypt it using that nonce. */ + $decrypt = openssl_decrypt( + $cipher_lasthalf, + Core::CIPHER_METHOD, + 'YELLOW SUBMARINE', + OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, + $computed_nonce + ); + + /* If it decrypts properly, we computed the nonce the same way. */ + self::assertSame( + str_repeat('a', 0x150 * 16), + $decrypt + ); + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/EncodingTest.php b/packages/DataProtection/tests/Unit/Encryption/EncodingTest.php new file mode 100644 index 000000000..a0805bab3 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/EncodingTest.php @@ -0,0 +1,117 @@ + 0 ? Core::secureRandom($length) : ''; + + $encode_a = Encoding::binToHex($random); + $encode_b = bin2hex($random); + + self::assertSame($encode_b, $encode_a); + + $decode_a = Encoding::hexToBin($encode_a); + $decode_b = hex2bin($encode_b); + + self::assertSame($decode_b, $decode_a); + // Just in case. + self::assertSame($random, $decode_b); + } + } + } + + public function test_encode_decode_equivalency_two_bytes(): void + { + for ($b1 = 0; $b1 < 256; $b1++) { + for ($b2 = 0; $b2 < 256; $b2++) { + $str = pack('C', $b1) . pack('C', $b2); + + $encode_a = Encoding::binToHex($str); + $encode_b = bin2hex($str); + + self::assertSame($encode_b, $encode_a); + + $decode_a = Encoding::hexToBin($encode_a); + $decode_b = hex2bin($encode_b); + + self::assertSame($decode_b, $decode_a); + self::assertSame($str, $decode_b); + } + } + } + + public function test_incorrect_checksum(): void + { + $this->expectExceptionObject(new BadFormatException("checksum doesn't match")); + + $header = Core::secureRandom(Core::HEADER_VERSION_SIZE); + $str = Encoding::saveBytesToChecksummedAsciiSafeString($header, Core::secureRandom(Core::KEY_BYTE_SIZE)); + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 0] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 1] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 3] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 4] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 5] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 6] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 7] = 'f'; + $str[2 * Encoding::SERIALIZE_HEADER_BYTES + 8] = 'f'; + + Encoding::loadBytesFromChecksummedAsciiSafeString($header, $str); + } + + public function test_bad_hex_encoding(): void + { + $this->expectExceptionObject(new BadFormatException('not a hex string')); + + $header = Core::secureRandom(Core::HEADER_VERSION_SIZE); + $str = Encoding::saveBytesToChecksummedAsciiSafeString($header, Core::secureRandom(Core::KEY_BYTE_SIZE)); + $str[0] = 'Z'; + + Encoding::loadBytesFromChecksummedAsciiSafeString($header, $str); + } + + /** + * This shouldn't throw an exception. + */ + public function test_padded_hex_encoding(): void + { + /* We're just ensuring that an empty string doesn't produce an error. */ + self::assertSame('', Encoding::trimTrailingWhitespace('')); + + $header = Core::secureRandom(Core::HEADER_VERSION_SIZE); + $str = Encoding::saveBytesToChecksummedAsciiSafeString($header, Core::secureRandom(Core::KEY_BYTE_SIZE)); + $orig = $str; + $noise = ["\r", "\n", "\t", "\0"]; + for ($i = 0; $i < 1000; ++$i) { + $c = $noise[random_int(0, 3)]; + $str .= $c; + self::assertSame( + Encoding::binToHex($orig), + Encoding::binToHex(Encoding::trimTrailingWhitespace($str)), + 'Pass #' . $i . ' (' . dechex(ord($c)) . ')' + ); + } + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/FileTest.php b/packages/DataProtection/tests/Unit/Encryption/FileTest.php new file mode 100644 index 000000000..88edd6c51 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/FileTest.php @@ -0,0 +1,400 @@ +key = Key::createNewRandomKey(); + } + + public function tearDown(): void + { + array_map('unlink', glob(self::$TEMP_DIR . '/*')); + rmdir(self::$TEMP_DIR); + } + + /** + * Test encryption from one file name to a destination file name + */ + #[DataProvider('fileToFileProvider')] + public function test_file_to_file(string $srcName): void + { + $src = self::$FILE_DIR . '/' . $srcName; + + $dest1 = self::$TEMP_DIR . '/ff1'; + File::encryptFile($src, $dest1, $this->key); + self::assertFileExists($dest1, 'destination file not created.'); + + $reverse1 = self::$TEMP_DIR . '/rv1'; + File::decryptFile($dest1, $reverse1, $this->key); + self::assertFileExists($reverse1); + self::assertSame( + md5_file($src), + md5_file($reverse1), + 'File and encrypted-decrypted file do not match.' + ); + + $dest2 = self::$TEMP_DIR . '/ff2'; + File::encryptFile($reverse1, $dest2, $this->key); + self::assertFileExists($dest2); + + self::assertNotEquals( + md5_file($dest1), + md5_file($dest2), + 'First and second encryption produced identical files.' + ); + + $reverse2 = self::$TEMP_DIR . '/rv2'; + File::decryptFile($dest2, $reverse2, $this->key); + self::assertSame( + md5_file($src), + md5_file($reverse2), + 'File and encrypted-decrypted file do not match.' + ); + } + + /** + * Test encryption from one file name to a destination file name (password). + */ + #[DataProvider('fileToFileProvider')] + public function test_file_to_file_with_password(string $srcName): void + { + $src = self::$FILE_DIR . '/' . $srcName; + + $dest1 = self::$TEMP_DIR . '/ff1'; + File::encryptFileWithPassword($src, $dest1, 'password'); + self::assertFileExists($dest1, 'destination file not created.'); + + $reverse1 = self::$TEMP_DIR . '/rv1'; + File::decryptFileWithPassword($dest1, $reverse1, 'password'); + self::assertFileExists($reverse1); + self::assertSame( + md5_file($src), + md5_file($reverse1), + 'File and encrypted-decrypted file do not match.' + ); + + $dest2 = self::$TEMP_DIR . '/ff2'; + File::encryptFileWithPassword($reverse1, $dest2, 'password'); + self::assertFileExists($dest2); + + self::assertNotEquals( + md5_file($dest1), + md5_file($dest2), + 'First and second encryption produced identical files.' + ); + + $reverse2 = self::$TEMP_DIR . '/rv2'; + File::decryptFileWithPassword($dest2, $reverse2, 'password'); + self::assertSame( + md5_file($src), + md5_file($reverse2), + 'File and encrypted-decrypted file do not match.' + ); + } + + #[DataProvider('fileToFileProvider')] + public function test_resource_to_resource(string $srcFile): void + { + $srcName = self::$FILE_DIR . '/' . $srcFile; + $destName = self::$TEMP_DIR . "/$srcFile.dest"; + $src = fopen($srcName, 'r'); + $dest = fopen($destName, 'w'); + + File::encryptResource($src, $dest, $this->key); + + fclose($src); + fclose($dest); + + $src2 = fopen($destName, 'r'); + $dest2 = fopen(self::$TEMP_DIR . '/dest2', 'w'); + + File::decryptResource($src2, $dest2, $this->key); + fclose($src2); + fclose($dest2); + + self::assertSame( + md5_file($srcName), + md5_file(self::$TEMP_DIR . '/dest2'), + 'Original file mismatches the result of encrypt and decrypt' + ); + } + + #[DataProvider('fileToFileProvider')] + public function test_resource_to_resource_with_password(string $srcFile): void + { + $srcName = self::$FILE_DIR . '/' . $srcFile; + $destName = self::$TEMP_DIR . "/$srcFile.dest"; + $src = fopen($srcName, 'r'); + $dest = fopen($destName, 'w'); + + File::encryptResourceWithPassword($src, $dest, 'password'); + + fclose($src); + fclose($dest); + + $src2 = fopen($destName, 'r'); + $dest2 = fopen(self::$TEMP_DIR . '/dest2', 'w'); + + File::decryptResourceWithPassword($src2, $dest2, 'password'); + fclose($src2); + fclose($dest2); + + self::assertSame( + md5_file($srcName), + md5_file(self::$TEMP_DIR . '/dest2'), + 'Original file mismatches the result of encrypt and decrypt' + ); + } + + public function test_decrypt_bad_magic_number(): void + { + $junk = self::$TEMP_DIR . '/junk'; + file_put_contents($junk, 'This file does not have the right magic number.'); + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + $this->expectExceptionMessage('Input file is too small to have been created by this library.'); + File::decryptFile($junk, self::$TEMP_DIR . '/unjunked', $this->key); + } + + #[DataProvider('garbageCiphertextProvider')] + public function test_decrypt_garbage(string $ciphertext): void + { + $junk = self::$TEMP_DIR . '/junk'; + file_put_contents($junk, $ciphertext); + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + File::decryptFile($junk, self::$TEMP_DIR . '/unjunked', $this->key); + } + + public static function garbageCiphertextProvider() + { + $ciphertexts = [ + [str_repeat('this is not anything that can be decrypted.', 100)], + ]; + for ($i = 0; $i < 1024; $i++) { + $ciphertexts[] = [Core::CURRENT_VERSION . str_repeat('A', $i)]; + } + return $ciphertexts; + } + + public function test_decrypt_empty_file(): void + { + $junk = self::$TEMP_DIR . '/junk'; + file_put_contents($junk, ''); + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + File::decryptFile($junk, self::$TEMP_DIR . '/unjunked', $this->key); + } + + public function test_decrypt_truncated_ciphertext(): void + { + // This tests for issue #115 on GitHub. + $plaintext_path = self::$TEMP_DIR . '/plaintext'; + $ciphertext_path = self::$TEMP_DIR . '/ciphertext'; + $truncated_path = self::$TEMP_DIR . '/truncated'; + + file_put_contents($plaintext_path, str_repeat('A', 1024)); + File::encryptFile($plaintext_path, $ciphertext_path, $this->key); + + $ciphertext = file_get_contents($ciphertext_path); + $truncated = substr($ciphertext, 0, 64); + file_put_contents($truncated_path, $truncated); + + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + File::decryptFile($truncated_path, $plaintext_path, $this->key); + } + + public function test_encrypt_with_crypto_decrypt_with_file(): void + { + $ciphertext_path = self::$TEMP_DIR . '/ciphertext'; + $plaintext_path = self::$TEMP_DIR . '/plaintext'; + + $key = Key::createNewRandomKey(); + $plaintext = 'Plaintext!'; + $ciphertext = Crypto::encrypt($plaintext, $key, true); + file_put_contents($ciphertext_path, $ciphertext); + + File::decryptFile($ciphertext_path, $plaintext_path, $key); + + $plaintext_decrypted = file_get_contents($plaintext_path); + self::assertSame($plaintext, $plaintext_decrypted); + } + + public function test_encrypt_with_file_decrypt_with_crypto(): void + { + $ciphertext_path = self::$TEMP_DIR . '/ciphertext'; + $plaintext_path = self::$TEMP_DIR . '/plaintext'; + + $key = Key::createNewRandomKey(); + $plaintext = 'Plaintext!'; + file_put_contents($plaintext_path, $plaintext); + File::encryptFile($plaintext_path, $ciphertext_path, $key); + + $ciphertext = file_get_contents($ciphertext_path); + $plaintext_decrypted = Crypto::decrypt($ciphertext, $key, true); + self::assertSame($plaintext, $plaintext_decrypted); + } + + public function test_extra_data(): void + { + $src = self::$FILE_DIR . '/wat-gigantic-duck.jpg'; + $dest = self::$TEMP_DIR . '/err'; + + File::encryptFile($src, $dest, $this->key); + + file_put_contents($dest, str_repeat('A', 2048), FILE_APPEND); + + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + $this->expectExceptionMessage('Integrity check failed.'); + File::decryptFile($dest, $dest . '.jpg', $this->key); + } + + public function test_file_create_random_key(): void + { + $result = Key::createNewRandomKey(); + self::assertInstanceOf('\Ecotone\DataProtection\Encryption\Key', $result); + } + + public function test_bad_source_path_encrypt(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('No such file or directory'); + File::encryptFile('./i-do-not-exist', 'output-file', $this->key); + } + + public function test_bad_source_path_decrypt(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('No such file or directory'); + File::decryptFile('./i-do-not-exist', 'output-file', $this->key); + } + + public function test_bad_source_path_encrypt_with_password(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('No such file or directory'); + File::encryptFileWithPassword('./i-do-not-exist', 'output-file', 'password'); + } + + public function test_bad_source_path_decrypt_with_password(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('No such file or directory'); + File::decryptFileWithPassword('./i-do-not-exist', 'output-file', 'password'); + } + + public function test_bad_destination_path_encrypt(): void + { + $src = self::$FILE_DIR . '/wat-gigantic-duck.jpg'; + $this->expectException(IOException::class); + $this->expectExceptionMessage('Is a directory'); + File::encryptFile($src, './', $this->key); + } + + public function test_bad_destination_path_decrypt(): void + { + $src = self::$FILE_DIR . '/wat-gigantic-duck.jpg'; + $this->expectException(IOException::class); + $this->expectExceptionMessage('Is a directory'); + File::decryptFile($src, './', $this->key); + } + + public function test_bad_destination_path_encrypt_with_password(): void + { + $src = self::$FILE_DIR . '/wat-gigantic-duck.jpg'; + $this->expectException(IOException::class); + $this->expectExceptionMessage('Is a directory'); + File::encryptFileWithPassword($src, './', 'password'); + } + + public function test_bad_destination_path_decrypt_with_password(): void + { + $src = self::$FILE_DIR . '/wat-gigantic-duck.jpg'; + $this->expectException(IOException::class); + $this->expectExceptionMessage('Is a directory'); + File::decryptFileWithPassword($src, './', 'password'); + } + + public function test_non_resource_input_encrypt(): void + { + $resource = fopen('php://memory', 'wb'); + $this->expectException(IOException::class); + $this->expectExceptionMessage('must be a resource'); + File::encryptResource('not a resource', $resource, $this->key); + fclose($resource); + } + + public function test_non_resource_output_encrypt(): void + { + $resource = fopen('php://memory', 'wb'); + $this->expectException(IOException::class); + $this->expectExceptionMessage('must be a resource'); + File::encryptResource($resource, 'not a resource', $this->key); + fclose($resource); + } + + public function test_non_resource_input_decrypt(): void + { + $resource = fopen('php://memory', 'wb'); + $this->expectException(IOException::class); + $this->expectExceptionMessage('must be a resource'); + File::decryptResource('not a resource', $resource, $this->key); + fclose($resource); + } + + public function test_non_resource_output_decrypt(): void + { + $resource = fopen('php://memory', 'wb'); + $this->expectException(IOException::class); + $this->expectExceptionMessage('must be a resource'); + File::decryptResource($resource, 'not a resource', $this->key); + fclose($resource); + } + + public function test_non_file_resource_decrypt(): void + { + /* This should behave equivalently to an empty file. Calling fstat() on + stdin returns a result saying it has zero size. */ + $stdin = fopen('php://stdin', 'r'); + $output = fopen('php://memory', 'wb'); + try { + File::decryptResource($stdin, $output, $this->key); + } catch (Exception $ex) { + fclose($output); + fclose($stdin); + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + throw $ex; + } + } + + public static function fileToFileProvider(): Generator + { + yield 'empty-file' => ['empty-file.txt']; + yield 'wat-gigantic-duck' => ['wat-gigantic-duck.jpg']; + yield 'extra-large' => ['big-generated-file']; + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/KeyTest.php b/packages/DataProtection/tests/Unit/Encryption/KeyTest.php new file mode 100644 index 000000000..f547192c5 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/KeyTest.php @@ -0,0 +1,37 @@ +getRawBytes())); + } + + public function test_save_and_load_key(): void + { + $key1 = Key::createNewRandomKey(); + $str = $key1->saveToAsciiSafeString(); + $key2 = Key::loadFromAsciiSafeString($str); + self::assertSame($key1->getRawBytes(), $key2->getRawBytes()); + } + + public function test_incorrect_header(): void + { + $key = Key::createNewRandomKey(); + $str = $key->saveToAsciiSafeString(); + $str[0] = 'f'; + $this->expectException(\Ecotone\DataProtection\Encryption\Exception\BadFormatException::class); + $this->expectExceptionMessage('Invalid header.'); + Key::loadFromAsciiSafeString($str); + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/PasswordTest.php b/packages/DataProtection/tests/Unit/Encryption/PasswordTest.php new file mode 100644 index 000000000..dc98b733c --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/PasswordTest.php @@ -0,0 +1,76 @@ +saveToAsciiSafeString()); + + $key1 = $pkey1->unlockKey('password'); + $key2 = $pkey2->unlockKey('password'); + + self::assertSame($key1->getRawBytes(), $key2->getRawBytes()); + } + + public function test_key_protected_by_password_wrong(): void + { + $pkey = KeyProtectedByPassword::createRandomPasswordProtectedKey('rightpassword'); + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + $pkey->unlockKey('wrongpassword'); + } + + /** + * Check that a new password was set. + */ + public function test_change_password(): void + { + $pkey1 = KeyProtectedByPassword::createRandomPasswordProtectedKey('password'); + $pkey1_enc_ascii = $pkey1->saveToAsciiSafeString(); + $key1 = $pkey1->unlockKey('password')->saveToAsciiSafeString(); + + $pkey1->changePassword('password', 'new password'); + + $pkey1_enc_ascii_new = $pkey1->saveToAsciiSafeString(); + $key1_new = $pkey1->unlockKey('new password')->saveToAsciiSafeString(); + + // The encrypted_key should not be the same. + self::assertNotSame($pkey1_enc_ascii, $pkey1_enc_ascii_new); + + // The actual key should be the same. + self::assertSame($key1, $key1_new); + } + + /** + * Check that changing the password actually changes the password. + */ + public function test_password_actually_changes(): void + { + $pkey1 = KeyProtectedByPassword::createRandomPasswordProtectedKey('password'); + $pkey1->changePassword('password', 'new password'); + + $this->expectException(WrongKeyOrModifiedCiphertextException::class); + $pkey1->unlockKey('password'); + } + + public function test_malformed_load(): void + { + $pkey1 = KeyProtectedByPassword::createRandomPasswordProtectedKey('password'); + $pkey1_enc_ascii = $pkey1->saveToAsciiSafeString(); + + $pkey1_enc_ascii[0] = "\xFF"; + + $this->expectException(BadFormatException::class); + KeyProtectedByPassword::loadFromAsciiSafeString($pkey1_enc_ascii); + } +} diff --git a/packages/DataProtection/tests/Unit/Encryption/RuntimeTestTest.php b/packages/DataProtection/tests/Unit/Encryption/RuntimeTestTest.php new file mode 100644 index 000000000..490885256 --- /dev/null +++ b/packages/DataProtection/tests/Unit/Encryption/RuntimeTestTest.php @@ -0,0 +1,21 @@ +