Index: src/wp-includes/user.php
===================================================================
--- src/wp-includes/user.php	(revision 32998)
+++ src/wp-includes/user.php	(working copy)
@@ -2446,42 +2446,66 @@ function check_password_reset_key($key, 
 	if ( empty( $key ) || !is_string( $key ) )
 		return new WP_Error('invalid_key', __('Invalid key'));
 
 	if ( empty($login) || !is_string($login) )
 		return new WP_Error('invalid_key', __('Invalid key'));
 
 	$row = $wpdb->get_row( $wpdb->prepare( "SELECT ID, user_activation_key FROM $wpdb->users WHERE user_login = %s", $login ) );
 	if ( ! $row )
 		return new WP_Error('invalid_key', __('Invalid key'));
 
 	if ( empty( $wp_hasher ) ) {
 		require_once ABSPATH . WPINC . '/class-phpass.php';
 		$wp_hasher = new PasswordHash( 8, true );
 	}
 
-	if ( $wp_hasher->CheckPassword( $key, $row->user_activation_key ) )
+	/**
+	 * Filter the expiration time of password reset keys.
+	 *
+	 * @since 4.3.0
+	 *
+	 * @param int $expiration The expiration time in seconds.
+	 */
+	$expiration_duration = apply_filters( 'password_reset_expiration', DAY_IN_SECONDS );
+
+	if ( false !== strpos( $row->user_activation_key, ':' ) ) {
+		list( $pass_request_time, $pass_key ) = explode( ':', $row->user_activation_key, 2 );
+		$expiration_time = $pass_request_time + $expiration_duration;
+	} else {
+		$pass_key = $row->user_activation_key;
+		$expiration_time = false;
+	}
+
+	$hash_is_correct = $wp_hasher->CheckPassword( $key, $pass_key );
+
+	if ( $hash_is_correct && $expiration_time && time() < $expiration_time ) {
 		return get_userdata( $row->ID );
+	} elseif ( $hash_is_correct && $expiration_time ) {
+		// Key has an expiration time that's passed
+		return new WP_Error( 'expired_key', __( 'Your password reset token has expired.' ) );
+	}
 
-	if ( $key === $row->user_activation_key ) {
-		$return = new WP_Error( 'expired_key', __( 'Invalid key' ) );
+	if ( hash_equals( $row->user_activation_key, $key ) || ( $hash_is_correct && ! $expiration_time ) ) {
+		$return = new WP_Error( 'expired_key', __( 'Your password reset token has expired.' ) );
 		$user_id = $row->ID;
 
 		/**
 		 * Filter the return value of check_password_reset_key() when an
-		 * old-style key is used (plain-text key was stored in the database).
+		 * old-style key is used.
 		 *
-		 * @since 3.7.0
+		 * @since 3.7.0 Previously plain-text keys were stored in the database.
+		 * @since 4.3.0 Previously key hashes were stored without an expiration time.
 		 *
 		 * @param WP_Error $return  A WP_Error object denoting an expired key.
 		 *                          Return a WP_User object to validate the key.
 		 * @param int      $user_id The matched user ID.
 		 */
 		return apply_filters( 'password_reset_key_expired', $return, $user_id );
 	}
 
 	return new WP_Error( 'invalid_key', __( 'Invalid key' ) );
 }
 
 /**
  * Handles resetting the user's password.
  *
  * @since 2.5.0
Index: src/wp-login.php
===================================================================
--- src/wp-login.php	(revision 32998)
+++ src/wp-login.php	(working copy)
@@ -351,31 +351,31 @@ function retrieve_password() {
 	/**
 	 * Fires when a password reset key is generated.
 	 *
 	 * @since 2.5.0
 	 *
 	 * @param string $user_login The username for the user.
 	 * @param string $key        The generated password reset key.
 	 */
 	do_action( 'retrieve_password_key', $user_login, $key );
 
 	// Now insert the key, hashed, into the DB.
 	if ( empty( $wp_hasher ) ) {
 		require_once ABSPATH . WPINC . '/class-phpass.php';
 		$wp_hasher = new PasswordHash( 8, true );
 	}
-	$hashed = $wp_hasher->HashPassword( $key );
+	$hashed = time() . ':' . $wp_hasher->HashPassword( $key );
 	$wpdb->update( $wpdb->users, array( 'user_activation_key' => $hashed ), array( 'user_login' => $user_login ) );
 
 	$message = __('Someone requested that the password be reset for the following account:') . "\r\n\r\n";
 	$message .= network_home_url( '/' ) . "\r\n\r\n";
 	$message .= sprintf(__('Username: %s'), $user_login) . "\r\n\r\n";
 	$message .= __('If this was a mistake, just ignore this email and nothing will happen.') . "\r\n\r\n";
 	$message .= __('To reset your password, visit the following address:') . "\r\n\r\n";
 	$message .= '<' . network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($user_login), 'login') . ">\r\n";
 
 	if ( is_multisite() )
 		$blogname = $GLOBALS['current_site']->site_name;
 	else
 		/*
 		 * The blogname option is escaped with esc_html on the way into the database
 		 * in sanitize_option we want to reverse this for the plain text arena of emails.
@@ -519,31 +519,31 @@ case 'lostpassword' :
 case 'retrievepassword' :
 
 	if ( $http_post ) {
 		$errors = retrieve_password();
 		if ( !is_wp_error($errors) ) {
 			$redirect_to = !empty( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : 'wp-login.php?checkemail=confirm';
 			wp_safe_redirect( $redirect_to );
 			exit();
 		}
 	}
 
 	if ( isset( $_GET['error'] ) ) {
 		if ( 'invalidkey' == $_GET['error'] )
 			$errors->add( 'invalidkey', __( 'Sorry, that key does not appear to be valid.' ) );
 		elseif ( 'expiredkey' == $_GET['error'] )
-			$errors->add( 'expiredkey', __( 'Sorry, that key has expired. Please try again.' ) );
+			$errors->add( 'expiredkey', __( 'Your password reset link has expired. Please request a new link below.' ) );
 	}
 
 	$lostpassword_redirect = ! empty( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : '';
 	/**
 	 * Filter the URL redirected to after submitting the lostpassword/retrievepassword form.
 	 *
 	 * @since 3.0.0
 	 *
 	 * @param string $lostpassword_redirect The redirect destination URL.
 	 */
 	$redirect_to = apply_filters( 'lostpassword_redirect', $lostpassword_redirect );
 
 	/**
 	 * Fires before the lost password form.
 	 *
