diff --git a/src/wp-admin/includes/class-wp-upgrader.php b/src/wp-admin/includes/class-wp-upgrader.php index d8433b4516..7fbecbae0d 100644 --- a/src/wp-admin/includes/class-wp-upgrader.php +++ b/src/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/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 03f6e3c7b6..0cb55806c3 100644 --- a/src/wp-admin/includes/file.php +++ b/src/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.