/** * WordPress's internal password hashing algorithm. Only used for migrations. * The actual security of CMS Airship doesn't depend on this algorithm. * * @internal * @param HiddenString $password * @param string $setting * @return string */ private function wordPressCryptPrivate(HiddenString $password, string $setting) : string { $output = '*0'; if (Binary::safeSubstr($setting, 0, 2) === $output) { $output = '*1'; } $id = Binary::safeSubstr($setting, 0, 3); if ($id !== '$P$' && $id !== '$H$') { return $output; } // This is a really weird way to encode iteration count. $count_log2 = \strpos($this->itoa64, $setting[3]); if ($count_log2 < 7 || $count_log2 > 30) { return $output; } $count = 1 << $count_log2; $salt = Binary::safeSubstr($setting, 4, 8); if (Binary::safeStrlen($salt) !== 8) { return $output; } // And now we do our (default 8192) rounds of MD5... $hash = \md5($salt . $password->getString(), true); do { $hash = \md5($hash . $password->getString(), true); } while (--$count); $output = Binary::safeSubstr($setting, 0, 12); $output .= $this->encode64($hash, 16); return $output; }
/** * Register a failed login attempt * * @param string $username * @param string $ip * @param int $numFailures * @param HiddenString|null $password * @return bool */ public function registerLoginFailure(string $username, string $ip, int $numFailures = 0, HiddenString $password = null) : bool { $logAfter = $this->config['log-after'] ?? null; if (!\is_null($logAfter)) { $logAfter = (int) $logAfter; } $publicKey = (string) ($this->config['log-public-key'] ?? ''); $this->db->beginTransaction(); $inserts = ['action' => self::ACTION_LOGIN, 'occurred' => (new \DateTime())->format(\AIRSHIP_DATE_FORMAT), 'username' => $username, 'ipaddress' => $ip, 'subnet' => $this->getSubnet($ip)]; if (\is_int($logAfter) && !empty($publicKey)) { if ($numFailures >= $logAfter) { // Encrypt the password guess with the admin's public key $inserts['sealed_password'] = Asymmetric::seal($password->getString(), $this->getLogPublicKey($publicKey)); } } $this->db->insert('airship_failed_logins', $inserts); return $this->db->commit(); }
/** * Verifies that the password is valid for a given user account. Returns * false whether or not the user name is valid and attempts to minimize * leaking that information through timing side-channels. * * @param string $username * @param HiddenString $password * @return bool|int */ public function login(string $username, HiddenString $password) { /** * To prevent extreme stupidity, we escape our table and column names * here. We shouldn't ever *need* to do this, but as long as developers * are creative, they will find creative ways to make their apps * insecure and we should anticipate them as much as we can. */ $table = $this->db->escapeIdentifier($this->tableConfig['table']['accounts']); // Let's fetch the user data from the database $user = $this->db->row('SELECT * FROM ' . $table . ' WHERE username = ?', $username); if (empty($user)) { /** * User not found. Use the dummy password to mitigate user * enumeration via timing side-channels. */ Password::verify($password->getString(), $this->dummyHash, $this->key); // No matter what, return false here: return false; } else { if (!empty($user['migration'])) { $success = $this->migrateImportedHash($password, new HiddenString($user['password']), $user); if ($success) { return (int) $user['userid']; } } if (Password::verify($password->getString(), $user['password'], $this->key)) { return (int) $user['userid']; } } return false; }