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.