diff --git a/inc/class-addon-repository.php b/inc/class-addon-repository.php index 07dd6ddc..91be7d6b 100644 --- a/inc/class-addon-repository.php +++ b/inc/class-addon-repository.php @@ -15,6 +15,14 @@ */ class Addon_Repository { + /** + * Legacy key used by source packages released before the OAuth secrets were + * regenerated on every release build. + * + * @var string + */ + private const LEGACY_CREDENTIAL_KEY = '4ffb3de0414a284b500b368caedf7c40f03cac215eb83b0164e91c491ced4bdf'; + private string $authorization_header = ''; private string $client_id; private string $client_secret; @@ -32,19 +40,52 @@ public function init() { /** * @param string $data base64 encoded string. * - * @return false|string + * @return string */ private function decrypt_value(string $data): string { // If the site doesn't have openssl, they just won't get auto updates. if ( ! function_exists('openssl_decrypt') || ! function_exists('openssl_cipher_iv_length')) { return ''; } - $key = hash_file('sha256', __FILE__); // Hash of this file - $data = base64_decode($data); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - $iv_length = openssl_cipher_iv_length('aes-256-cbc'); + + $data = base64_decode($data, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + + if (false === $data) { + return ''; + } + + $iv_length = openssl_cipher_iv_length('aes-256-cbc'); + + if (false === $iv_length || strlen($data) <= $iv_length) { + return ''; + } + $iv = substr($data, 0, $iv_length); $cipher_text = substr($data, $iv_length); - return openssl_decrypt($cipher_text, 'aes-256-cbc', $key, 0, $iv); + + foreach ($this->get_decryption_keys() as $key) { + $decrypted = openssl_decrypt($cipher_text, 'aes-256-cbc', $key, 0, $iv); + + if (false !== $decrypted) { + return $decrypted; + } + } + + return ''; + } + + /** + * Gets supported OAuth credential decryption keys. + * + * @return array + */ + private function get_decryption_keys(): array { + $keys = [ + hash_file('sha256', __FILE__), // Hash of this file for freshly built release archives. + self::LEGACY_CREDENTIAL_KEY, + ]; + + return array_values(array_unique(array_filter($keys))); } /** diff --git a/tests/WP_Ultimo/Addon_Repository_Test.php b/tests/WP_Ultimo/Addon_Repository_Test.php index 1a6f57d4..efbb6c5e 100644 --- a/tests/WP_Ultimo/Addon_Repository_Test.php +++ b/tests/WP_Ultimo/Addon_Repository_Test.php @@ -28,6 +28,28 @@ public function tear_down() { parent::tear_down(); } + private function get_oauth_query_args(): array { + $query = (string) wp_parse_url($this->repo->get_oauth_url(), PHP_URL_QUERY); + $args = []; + + parse_str($query, $args); + + return $args; + } + + private function encrypt_for_addon_repository(string $plain_text, string $key): string { + $iv_length = openssl_cipher_iv_length('aes-256-cbc'); + + $this->assertNotFalse($iv_length); + + $iv = str_repeat('1', $iv_length); + $encrypted = openssl_encrypt($plain_text, 'aes-256-cbc', $key, 0, $iv); + + $this->assertNotFalse($encrypted); + + return base64_encode($iv . $encrypted); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + // ------------------------------------------------------------------ // Constructor // ------------------------------------------------------------------ @@ -79,7 +101,7 @@ public function test_set_update_download_headers_adds_auth_for_matching_url() { $ref->setValue($this->repo, 'Bearer test_token_123'); $parsed_args = ['headers' => []]; - $url = MULTISITE_ULTIMATE_UPDATE_URL . 'some/path'; + $url = MULTISITE_ULTIMATE_UPDATE_URL . 'some/path'; $result = $this->repo->set_update_download_headers($parsed_args, $url); @@ -96,7 +118,7 @@ public function test_set_update_download_headers_ignores_non_matching_url() { $ref->setValue($this->repo, 'Bearer test_token_123'); $parsed_args = ['headers' => []]; - $url = 'https://example.com/other/path'; + $url = 'https://example.com/other/path'; $result = $this->repo->set_update_download_headers($parsed_args, $url); @@ -105,7 +127,7 @@ public function test_set_update_download_headers_ignores_non_matching_url() { public function test_set_update_download_headers_ignores_empty_auth_header() { $parsed_args = ['headers' => []]; - $url = MULTISITE_ULTIMATE_UPDATE_URL . 'some/path'; + $url = MULTISITE_ULTIMATE_UPDATE_URL . 'some/path'; $result = $this->repo->set_update_download_headers($parsed_args, $url); @@ -200,9 +222,10 @@ public function test_get_oauth_url_contains_response_type() { } public function test_get_oauth_url_contains_client_id() { - $url = $this->repo->get_oauth_url(); - // client_id parameter may have empty value (no constant defined in test env) - $this->assertStringContainsString('client_id', $url); + $args = $this->get_oauth_query_args(); + + $this->assertArrayHasKey('client_id', $args); + $this->assertNotSame('', $args['client_id'], 'Packaged addon-store OAuth credentials must decrypt to a non-empty client_id.'); } public function test_get_oauth_url_contains_redirect_uri() { @@ -236,8 +259,47 @@ public function test_decrypt_value_returns_string() { // Provide data with enough bytes for IV (16 bytes) + some cipher text $fake_data = str_repeat('A', 32); // 16 bytes IV + 16 bytes cipher text - $result = $method->invoke($this->repo, base64_encode($fake_data)); + $result = $method->invoke($this->repo, base64_encode($fake_data)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode // Result will be empty string or decrypted string (likely empty since data is invalid) $this->assertIsString($result); } + + public function test_decrypt_value_supports_current_release_archive_key() { + if ( ! function_exists('openssl_encrypt') || ! function_exists('openssl_cipher_iv_length')) { + $this->markTestSkipped('OpenSSL is required to test addon credential encryption.'); + } + + $method = new \ReflectionMethod(Addon_Repository::class, 'decrypt_value'); + + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $key = hash_file('sha256', dirname(__DIR__, 2) . '/inc/class-addon-repository.php'); + + $this->assertNotFalse($key); + + $payload = $this->encrypt_for_addon_repository('current-client-id', $key); + + $this->assertSame('current-client-id', $method->invoke($this->repo, $payload)); + } + + public function test_decrypt_value_supports_legacy_credential_key() { + if ( ! function_exists('openssl_encrypt') || ! function_exists('openssl_cipher_iv_length')) { + $this->markTestSkipped('OpenSSL is required to test addon credential encryption.'); + } + + $method = new \ReflectionMethod(Addon_Repository::class, 'decrypt_value'); + + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $payload = $this->encrypt_for_addon_repository( + 'legacy-client-id', + '4ffb3de0414a284b500b368caedf7c40f03cac215eb83b0164e91c491ced4bdf' + ); + + $this->assertSame('legacy-client-id', $method->invoke($this->repo, $payload)); + } }