From e38814f3594ce859a3fb946de5ddc7bd29ce34e7 Mon Sep 17 00:00:00 2001 From: tellyworth Date: Thu, 21 Mar 2019 05:49:50 +0000 Subject: [PATCH] Upgrade/Install: Add experimental package signing to some updates. This adds code for soft verification of signatures for theme and plugin installs and updates, when provided by the update server. This experimental version does not reject unverified packages or failed signatures; it simply reports anonymous errors so we can evaluate its feasibility and detect incompatibilities. This code relies on the new sodium_compat library for PHP versions prior to 7.2. Props dd32, paragoninitiativeenterprises. See #39309, #45806. Built from https://develop.svn.wordpress.org/trunk@44954 git-svn-id: http://core.svn.wordpress.org/trunk@44785 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-admin/includes/class-wp-upgrader.php | 23 +++- wp-admin/includes/file.php | 171 +++++++++++++++++++++++- wp-includes/version.php | 2 +- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/wp-admin/includes/class-wp-upgrader.php b/wp-admin/includes/class-wp-upgrader.php index d8433b4516..7fbecbae0d 100644 --- a/wp-admin/includes/class-wp-upgrader.php +++ b/wp-admin/includes/class-wp-upgrader.php @@ -275,9 +275,9 @@ class WP_Upgrader { $this->skin->feedback( 'downloading_package', $package ); - $download_file = download_url( $package ); + $download_file = download_url( $package, 300, true ); - if ( is_wp_error( $download_file ) ) { + if ( is_wp_error( $download_file ) && ! $download_file->get_error_data( 'softfail-filename' ) ) { return new WP_Error( 'download_failed', $this->strings['download_failed'], $download_file->get_error_message() ); } @@ -731,6 +731,25 @@ class WP_Upgrader { * of the file if the package is a local file) */ $download = $this->download_package( $options['package'] ); + + // Allow for signature soft-fail. + // WARNING: This may be removed in the future. + if ( is_wp_error( $download ) && $download->get_error_data( 'softfail-filename' ) ) { + // Outout the failure error as a normal feedback, and not as an error: + $this->skin->feedback( $download->get_error_message() ); + + // Report this failure back to WordPress.org for debugging purposes. + wp_version_check( + array( + 'signature_failure_code' => $download->get_error_code(), + 'signature_failure_data' => $download->get_error_data(), + ) + ); + + // Pretend this error didn't happen. + $download = $download->get_error_data( 'softfail-filename' ); + } + if ( is_wp_error( $download ) ) { $this->skin->error( $download ); $this->skin->after(); diff --git a/wp-admin/includes/file.php b/wp-admin/includes/file.php index 03f6e3c7b6..0cb55806c3 100644 --- a/wp-admin/includes/file.php +++ b/wp-admin/includes/file.php @@ -965,12 +965,14 @@ function wp_handle_sideload( &$file, $overrides = false, $time = null ) { * Please note that the calling function must unlink() the file. * * @since 2.5.0 + * @since 5.2.0 Signature Verification with SoftFail was added. * - * @param string $url The URL of the file to download. - * @param int $timeout The timeout for the request to download the file. Default 300 seconds. + * @param string $url The URL of the file to download. + * @param int $timeout The timeout for the request to download the file. Default 300 seconds. + * @param bool $signature_softfail Whether to allow Signature Verification to softfail. Default true. * @return string|WP_Error Filename on success, WP_Error on failure. */ -function download_url( $url, $timeout = 300 ) { +function download_url( $url, $timeout = 300, $signature_softfail = true ) { //WARNING: The file is not automatically deleted, The script must unlink() the file. if ( ! $url ) { return new WP_Error( 'http_no_url', __( 'Invalid URL Provided.' ) ); @@ -1034,6 +1036,55 @@ function download_url( $url, $timeout = 300 ) { } } + /** + * Filters the list of hosts which should have Signature Verification attempted on. + * + * @since 5.2.0 + * + * @param array List of hostnames. + */ + $signed_hostnames = apply_filters( 'wp_signature_hosts', array( 'wordpress.org', 'downloads.wordpress.org', 's.w.org' ) ); + $signature_verification = in_array( parse_url( $url, PHP_URL_HOST ), $signed_hostnames, true ); + + // Perform the valiation + if ( $signature_verification ) { + $signature = wp_remote_retrieve_header( $response, 'x-content-signature' ); + if ( ! $signature ) { + // Retrieve signatures from a file if the header wasn't included. + // WordPress.org stores signatures at $package_url.sig + $signature_request = wp_safe_remote_get( $url . '.sig' ); + if ( ! is_wp_error( $signature_request ) && 200 === wp_remote_retrieve_response_code( $signature_request ) ) { + $signature = explode( "\n", wp_remote_retrieve_body( $signature_request ) ); + } + } + + // Perform the checks. + $signature_verification = verify_file_signature( $tmpfname, $signature, basename( parse_url( $url, PHP_URL_PATH ) ) ); + } + + if ( is_wp_error( $signature_verification ) ) { + if ( + /** + * Filters whether Signature Verification failures should be allowed to soft fail. + * + * WARNING: This may be removed from a future release. + * + * @since 5.2.0 + * + * @param bool $signature_softfail If a softfail is allowed. + * @param string $url The url being accessed. + */ + apply_filters( 'wp_signature_softfail', $signature_softfail, $url ) + ) { + $signature_verification->add_data( $tmpfname, 'softfail-filename' ); + } else { + // Hard-fail. + unlink( $tmpfname ); + } + + return $signature_verification; + } + return $tmpfname; } @@ -1066,6 +1117,120 @@ function verify_file_md5( $filename, $expected_md5 ) { return new WP_Error( 'md5_mismatch', sprintf( __( 'The checksum of the file (%1$s) does not match the expected checksum value (%2$s).' ), bin2hex( $file_md5 ), bin2hex( $expected_raw_md5 ) ) ); } +/** + * Verifies the contents of a file against its ED25519 signature. + * + * @since 5.2.0 + * + * @param string $filename The file to validate. + * @param string|array $signatures A Signature provided for the file. + * @param string $filename_for_errors A friendly filename for errors. Optional. + * + * @return bool|WP_Error true on success, false if verificaiton not attempted, or WP_Error describing an error condition. + */ +function verify_file_signature( $filename, $signatures, $filename_for_errors = false ) { + if ( ! $filename_for_errors ) { + $filename_for_errors = wp_basename( $filename ); + } + + // Check we can process signatures. + if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) || ! in_array( 'sha384', array_map( 'strtolower', hash_algos() ) ) ) { + return new WP_Error( + 'signature_verification_unsupported', + sprintf( + /* translators: 1: The filename of the package. */ + __( 'The authenticity of %1$s could not be verified as signature verification is unavailable on this system.' ), + '' . esc_html( $filename_for_errors ) . '' + ), + ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ? 'sodium_crypto_sign_verify_detached' : 'sha384' ) + ); + } + + if ( ! $signatures ) { + return new WP_Error( + 'signature_verification_no_signature', + sprintf( + /* translators: 1: The filename of the package. */ + __( 'The authenticity of %1$s could not be verified as no signature was found.' ), + '' . esc_html( $filename_for_errors ) . '' + ) + ); + } + + $trusted_keys = wp_trusted_keys(); + $file_hash = hash_file( 'sha384', $filename, true ); + + mbstring_binary_safe_encoding(); + + foreach ( (array) $signatures as $signature ) { + $signature_raw = base64_decode( $signature ); + + // Ensure only valid-length signatures are considered. + if ( SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature_raw ) ) { + continue; + } + + foreach ( (array) $trusted_keys as $key ) { + $key_raw = base64_decode( $key ); + + // Only pass valid public keys through. + if ( SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen( $key_raw ) ) { + continue; + } + + if ( sodium_crypto_sign_verify_detached( $signature_raw, $file_hash, $key_raw ) ) { + reset_mbstring_encoding(); + return true; + } + } + } + + reset_mbstring_encoding(); + + return new WP_Error( + 'signature_verification_failed', + sprintf( + /* translators: 1: The filename of the package. */ + __( 'The authenticity of %1$s could not be verified.' ), + '' . esc_html( $filename_for_errors ) . '' + ), + // Error data helpful for debugging: + array( + 'filename' => $filename_for_errors, + 'keys' => $trusted_keys, + 'signatures' => $signatures, + 'hash' => bin2hex( $file_hash ), + ) + ); +} + +/** + * Retrieve the list of signing keys trusted by WordPress. + * + * @since 5.2.0 + * + * @return array List of hex-encoded Signing keys. + */ +function wp_trusted_keys() { + $trusted_keys = array(); + + if ( time() < 1617235200 ) { + // WordPress.org Key #1 - This key is only valid before April 1st, 2021. + $trusted_keys[] = 'fRPyrxb/MvVLbdsYi+OOEv4xc+Eqpsj+kkAS6gNOkI0='; + } + + // TODO: Add key #2 with longer expiration. + + /** + * Filter the valid Signing keys used to verify the contents of files. + * + * @since 5.2.0 + * + * @param array $trusted_keys The trusted keys that may sign packages. + */ + return apply_filters( 'wp_trusted_keys', $trusted_keys ); +} + /** * Unzips a specified ZIP file to a location on the filesystem via the WordPress * Filesystem Abstraction. diff --git a/wp-includes/version.php b/wp-includes/version.php index f4a14b7147..b793c74d52 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -13,7 +13,7 @@ * * @global string $wp_version */ -$wp_version = '5.2-alpha-44953'; +$wp_version = '5.2-alpha-44954'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.