Index: src/wp-includes/user.php
===================================================================
--- src/wp-includes/user.php	(revision 25447)
+++ src/wp-includes/user.php	(working copy)
@@ -1568,29 +1568,58 @@
 /**
  * Retrieves a user row based on password reset key and login
  *
+ * A key is considered 'expired' if it exactly matches the value of the
+ * user_activation_key field, rather than being matched after going through the
+ * hashing process. This field is now hashed; old values are no longer accepted
+ * but have a different WP_Error code so good user feedback can be provided.
+ *
  * @uses $wpdb WordPress Database object
  *
- * @param string $key Hash to validate sending user's password
- * @param string $login The user login
- * @return object|WP_Error User's database row on success, error object for invalid keys
+ * @param string $key       Hash to validate sending user's password.
+ * @param string $login     The user login.
+ * @return WP_User|WP_Error WP_User object on success, WP_Error object for invalid or expired keys.
  */
-function check_password_reset_key( $key, $login ) {
-	global $wpdb;
+function check_password_reset_key($key, $login) {
+	global $wpdb, $wp_hasher;
 
-	$key = preg_replace( '/[^a-z0-9]/i', '', $key );
+	$key = preg_replace('/[^a-z0-9]/i', '', $key);
 
-	if ( empty( $key ) || ! is_string( $key ) )
-		return new WP_Error( 'invalid_key', __( 'Invalid 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' ) );
+	if ( empty($login) || !is_string($login) )
+		return new WP_Error('invalid_key', __('Invalid key'));
 
-	$user = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE user_activation_key = %s AND user_login = %s", $key, $login ) );
+	$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( $user ) )
-		return new WP_Error( 'invalid_key', __( 'Invalid key' ) );
+	if ( empty( $wp_hasher ) ) {
+		require_once ABSPATH . 'wp-includes/class-phpass.php';
+		$wp_hasher = new PasswordHash( 8, true );
+	}
 
-	return $user;
+	if ( $wp_hasher->CheckPassword( $key, $row->user_activation_key ) )
+		return get_userdata( $row->ID );
+
+	if ( $key === $row->user_activation_key ) {
+		$return = new WP_Error( 'expired_key', __( 'Invalid key' ) );
+		$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).
+		 *
+		 * @since 3.7.0
+		 *
+		 * @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'));
 }
 
 /**
Index: src/wp-login.php
===================================================================
--- src/wp-login.php	(revision 25450)
+++ src/wp-login.php	(working copy)
@@ -197,7 +197,7 @@
  * @return bool|WP_Error True: when finish. WP_Error on error
  */
 function retrieve_password() {
-	global $wpdb, $current_site;
+	global $wpdb, $current_site, $wp_hasher;
 
 	$errors = new WP_Error();
 
@@ -236,14 +236,17 @@
 	else if ( is_wp_error($allow) )
 		return $allow;
 
-	$key = $wpdb->get_var($wpdb->prepare("SELECT user_activation_key FROM $wpdb->users WHERE user_login = %s", $user_login));
-	if ( empty($key) ) {
-		// Generate something random for a key...
-		$key = wp_generate_password(20, false);
-		do_action('retrieve_password_key', $user_login, $key);
-		// Now insert the new md5 key into the db
-		$wpdb->update($wpdb->users, array('user_activation_key' => $key), array('user_login' => $user_login));
+	// Generate something random for a key...
+	$key = wp_generate_password(20, false);
+	do_action('retrieve_password_key', $user_login, $key);
+	// Now insert the key, hashed, into the db
+	if ( empty( $wp_hasher ) ) {
+		require_once ABSPATH . 'wp-includes/class-phpass.php';
+		$wp_hasher = new PasswordHash( 8, true );
 	}
+	$hashed = $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";
@@ -353,7 +356,13 @@
 		}
 	}
 
-	if ( isset($_GET['error']) && 'invalidkey' == $_GET['error'] ) $errors->add('invalidkey', __('Sorry, that key does not appear to be valid.'));
+	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.' ) );
+	}
+
 	$redirect_to = apply_filters( 'lostpassword_redirect', !empty( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : '' );
 
 	do_action('lost_password');
@@ -389,7 +398,10 @@
 	$user = check_password_reset_key($_GET['key'], $_GET['login']);
 
 	if ( is_wp_error($user) ) {
-		wp_redirect( site_url('wp-login.php?action=lostpassword&error=invalidkey') );
+		if ( $user->get_error_code() === 'expired_key' )
+			wp_redirect( site_url( 'wp-login.php?action=lostpassword&error=expiredkey' ) );
+		else
+			wp_redirect( site_url( 'wp-login.php?action=lostpassword&error=invalidkey' ) );
 		exit;
 	}
 
