public async Task <LoginAttempt> DetermineLoginAttemptOutcomeAsync( LoginAttempt loginAttempt, string passwordProvidedByClient, byte[] phase1HashOfProvidedPassword = null, TimeSpan?timeout = null, CancellationToken cancellationToken = default(CancellationToken)) { Task <IpHistory> ipHistoryGetTask = _ipHistoryCache.GetAsync(loginAttempt.AddressOfClientInitiatingRequest, cancellationToken); IRepository <string, TUserAccount> userAccountRepository = _userAccountRepositoryFactory.Create(); Task <TUserAccount> userAccountRequestTask = userAccountRepository.LoadAsync(loginAttempt.UsernameOrAccountId, cancellationToken); Task <int> passwordsHeightOnBinomialLadderTask = _binomialLadderFilter.GetHeightAsync(passwordProvidedByClient, cancellationToken: cancellationToken); IpHistory ip = await ipHistoryGetTask; TUserAccount account = await userAccountRequestTask; if (account != null) { try { IUserAccountController <TUserAccount> userAccountController = _userAccountControllerFactory.Create(); loginAttempt.DeviceCookieHadPriorSuccessfulLoginForThisAccount = await userAccountController.HasClientWithThisHashedCookieSuccessfullyLoggedInBeforeAsync( account, loginAttempt.HashOfCookieProvidedByBrowser, cancellationToken); if (phase1HashOfProvidedPassword == null) { phase1HashOfProvidedPassword = userAccountController.ComputePhase1Hash(account, passwordProvidedByClient); } string phase2HashOfProvidedPassword = userAccountController.ComputePhase2HashFromPhase1Hash(account, phase1HashOfProvidedPassword); bool isSubmittedPasswordCorrect = phase2HashOfProvidedPassword == account.PasswordHashPhase2; loginAttempt.PasswordsHeightOnBinomialLadder = await passwordsHeightOnBinomialLadderTask; if (isSubmittedPasswordCorrect) { AdjustBlockingScoreForPastTyposTreatedAsFullFailures( ip, userAccountController, account, loginAttempt.TimeOfAttemptUtc, passwordProvidedByClient, phase1HashOfProvidedPassword); double blockingThreshold = _options.BlockThresholdPopularPassword_T_base * _options.PopularityBasedThresholdMultiplier_T_multiplier( loginAttempt); double blockScore = ip.CurrentBlockScore.GetValue(_options.BlockScoreHalfLife, loginAttempt.TimeOfAttemptUtc); if (loginAttempt.DeviceCookieHadPriorSuccessfulLoginForThisAccount) { blockScore *= _options.MultiplierIfClientCookieIndicatesPriorSuccessfulLogin_Kappa; } if (blockScore > blockingThreshold) { loginAttempt.Outcome = AuthenticationOutcome.CredentialsValidButBlocked; } else { loginAttempt.Outcome = AuthenticationOutcome.CredentialsValid; userAccountController.RecordHashOfDeviceCookieUsedDuringSuccessfulLoginBackground( account, loginAttempt.HashOfCookieProvidedByBrowser); if ( ip.CurrentBlockScore.GetValue(_options.AccountCreditLimitHalfLife, loginAttempt.TimeOfAttemptUtc) > 0) { TaskHelper.RunInBackground(Task.Run(async() => { double credit = await userAccountController.TryGetCreditAsync( account, _options.RewardForCorrectPasswordPerAccount_Sigma, loginAttempt.TimeOfAttemptUtc, cancellationToken); ip.CurrentBlockScore.SubtractInPlace(_options.AccountCreditLimitHalfLife, credit, loginAttempt.TimeOfAttemptUtc); }, cancellationToken)); } } } else { loginAttempt.Phase2HashOfIncorrectPassword = phase2HashOfProvidedPassword; if (account.GetType().Name == "SimulatedUserAccount") { loginAttempt.EncryptedIncorrectPassword.Ciphertext = passwordProvidedByClient; } else { loginAttempt.EncryptedIncorrectPassword.Write(passwordProvidedByClient, account.EcPublicAccountLogKey); } if (await userAccountController.AddIncorrectPhaseTwoHashAsync(account, phase2HashOfProvidedPassword, cancellationToken: cancellationToken)) { loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidRepeatedIncorrectPassword; } else { loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidIncorrectPassword; double invalidPasswordPenalty = _options.PenaltyForInvalidPassword_Beta * _options.PopularityBasedPenaltyMultiplier_phi( loginAttempt); ip.CurrentBlockScore.AddInPlace(_options.AccountCreditLimitHalfLife, invalidPasswordPenalty, loginAttempt.TimeOfAttemptUtc); ip.RecentPotentialTypos.Add(new LoginAttemptSummaryForTypoAnalysis() { EncryptedIncorrectPassword = loginAttempt.EncryptedIncorrectPassword, Penalty = new DecayingDouble(invalidPasswordPenalty, loginAttempt.TimeOfAttemptUtc), UsernameOrAccountId = loginAttempt.UsernameOrAccountId }); } } } finally { TaskHelper.RunInBackground(userAccountRepository.SaveChangesAsync(cancellationToken)); } } else { if (phase1HashOfProvidedPassword == null) { phase1HashOfProvidedPassword = ExpensiveHashFunctionFactory.Get(_options.DefaultExpensiveHashingFunction)( passwordProvidedByClient, ManagedSHA256.Hash(Encoding.UTF8.GetBytes(loginAttempt.UsernameOrAccountId)), _options.ExpensiveHashingFunctionIterations); } loginAttempt.PasswordsHeightOnBinomialLadder = await passwordsHeightOnBinomialLadderTask; if (_recentIncorrectPasswords.AddMember(Convert.ToBase64String(phase1HashOfProvidedPassword))) { loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidRepeatedNoSuchAccount; } else { loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidNoSuchAccount; double invalidAccontPenalty = _options.PenaltyForInvalidAccount_Alpha * _options.PopularityBasedPenaltyMultiplier_phi(loginAttempt); ip.CurrentBlockScore.AddInPlace(_options.BlockScoreHalfLife, invalidAccontPenalty, loginAttempt.TimeOfAttemptUtc); } } if (loginAttempt.Outcome == AuthenticationOutcome.CredentialsInvalidNoSuchAccount || loginAttempt.Outcome == AuthenticationOutcome.CredentialsInvalidIncorrectPassword) { TaskHelper.RunInBackground(_binomialLadderFilter.StepAsync(passwordProvidedByClient, cancellationToken: cancellationToken)); } return(loginAttempt); }
/// <returns></returns> /// <summary> /// Add a LoginAttempt, along the way determining whether that loginAttempt should be allowed /// (the user authenticated) or denied. /// </summary> /// <param name="loginAttempt">The login loginAttempt record to be stored.</param> /// <param name="passwordProvidedByClient">The plaintext password provided by the client.</param> /// <param name="phase1HashOfProvidedPassword">If the caller has already computed the phase 1 (expensive) hash of the submitted password, /// it can supply it via this optional parameter to avoid incurring the cost of having incurring the expense of this calculationg a second time.</param> /// <param name="timeout"></param> /// <param name="cancellationToken">To allow this async method to be cancelled.</param> /// <returns>If the password is correct and the IP not blocked, returns AuthenticationOutcome.CredentialsValid. /// Otherwise, it returns a different AuthenticationOutcome. /// The client should not be made aware of any information beyond whether the login was allowed or not.</returns> public async Task <LoginAttempt> DetermineLoginAttemptOutcomeAsync( LoginAttempt loginAttempt, string passwordProvidedByClient, byte[] phase1HashOfProvidedPassword = null, TimeSpan?timeout = null, CancellationToken cancellationToken = default(CancellationToken)) { // // In parallel, fetch information we'll need to determine the outcome // // Get information about the client's IP Task <IpHistory> ipHistoryGetTask = _ipHistoryCache.GetAsync(loginAttempt.AddressOfClientInitiatingRequest, cancellationToken); // Get information about the account the client is trying to login to //IStableStoreContext<string, UserAccount> userAccountContext = _userAccountContextFactory.Get(); IRepository <string, TUserAccount> userAccountRepository = _userAccountRepositoryFactory.Create(); Task <TUserAccount> userAccountRequestTask = userAccountRepository.LoadAsync(loginAttempt.UsernameOrAccountId, cancellationToken); // Get a binomial ladder to estimate if the password is common Task <int> passwordsHeightOnBinomialLadderTask = _binomialLadderFilter.GetHeightAsync(passwordProvidedByClient, cancellationToken: cancellationToken); // // Start processing information as it comes in // // Preform an analysis of the IPs past beavhior to determine if the IP has been performing so many failed guesses // that we disallow logins even if it got the right password. We call this even when the submitted password is // correct lest we create a timing indicator (slower responses for correct passwords) that attackers could use // to guess passwords even if we'd blocked their IPs. IpHistory ip = await ipHistoryGetTask; // We'll need the salt from the account record before we can calculate the expensive hash, // so await that task first TUserAccount account = await userAccountRequestTask; if (account != null) { try { IUserAccountController <TUserAccount> userAccountController = _userAccountControllerFactory.Create(); // // This is an login attempt for a valid (existent) account. // // Determine whether the client provided a cookie to indicate that it has previously logged // into this account successfully---a very strong indicator that it is a client used by the // legitimate user and not an unknown client performing a guessing attack. loginAttempt.DeviceCookieHadPriorSuccessfulLoginForThisAccount = await userAccountController.HasClientWithThisHashedCookieSuccessfullyLoggedInBeforeAsync( account, loginAttempt.HashOfCookieProvidedByBrowser, cancellationToken); // Test to see if the password is correct by calculating the Phase2Hash and comparing it with the Phase2 hash // in this record. The expensive (phase1) hash which is used to encrypt the EC public key for this account // (which we use to store the encryptions of incorrect passwords) if (phase1HashOfProvidedPassword == null) { phase1HashOfProvidedPassword = userAccountController.ComputePhase1Hash(account, passwordProvidedByClient); } // Since we can't store the phase1 hash (it can decrypt that EC key) we instead store a simple (SHA256) // hash of the phase1 hash, which we call the phase 2 hash, and use that to compare the provided password // with the correct password. string phase2HashOfProvidedPassword = userAccountController.ComputePhase2HashFromPhase1Hash(account, phase1HashOfProvidedPassword); // To determine if the password is correct, compare the phase2 has we just generated (phase2HashOfProvidedPassword) // with the one generated from the correct password when the user chose their password (account.PasswordHashPhase2). bool isSubmittedPasswordCorrect = phase2HashOfProvidedPassword == account.PasswordHashPhase2; // Get the popularity of the password provided by the client among incorrect passwords submitted in the past, // as we are most concerned about frequently-guessed passwords. loginAttempt.PasswordsHeightOnBinomialLadder = await passwordsHeightOnBinomialLadderTask; if (isSubmittedPasswordCorrect) { // The password is corerct. // Determine if any of the outcomes for login attempts from the client IP for this request were the result of typos, // as this might impact our decision about whether or not to block this client IP in response to its past behaviors. AdjustBlockingScoreForPastTyposTreatedAsFullFailures( ip, userAccountController, account, loginAttempt.TimeOfAttemptUtc, passwordProvidedByClient, phase1HashOfProvidedPassword); // We'll get the blocking threshold, blocking condition, and block if the condition exceeds the threshold. double blockingThreshold = _options.BlockThresholdPopularPassword_T_base * _options.PopularityBasedThresholdMultiplier_T_multiplier( loginAttempt); double blockScore = ip.CurrentBlockScore.GetValue(_options.BlockScoreHalfLife, loginAttempt.TimeOfAttemptUtc); // If the client provided a cookie proving a past successful login, we'll ignore the block condition if (loginAttempt.DeviceCookieHadPriorSuccessfulLoginForThisAccount) { blockScore *= _options.MultiplierIfClientCookieIndicatesPriorSuccessfulLogin_Kappa; } if (blockScore > blockingThreshold) { // While this login attempt had valid credentials, the circumstances // are so suspicious that we should block the login and pretend the // credentials were invalid loginAttempt.Outcome = AuthenticationOutcome.CredentialsValidButBlocked; } else { // This login attempt has valid credentials and no reason to block, so the // client will be authenticated. loginAttempt.Outcome = AuthenticationOutcome.CredentialsValid; userAccountController.RecordHashOfDeviceCookieUsedDuringSuccessfulLoginBackground( account, loginAttempt.HashOfCookieProvidedByBrowser); // Use this login attempt to offset harm caused by prior login failures if ( ip.CurrentBlockScore.GetValue(_options.AccountCreditLimitHalfLife, loginAttempt.TimeOfAttemptUtc) > 0) { // There is a non-zero blocking score that might be counteracted by a credit TaskHelper.RunInBackground(Task.Run(async() => { double credit = await userAccountController.TryGetCreditAsync( account, _options.RewardForCorrectPasswordPerAccount_Sigma, loginAttempt.TimeOfAttemptUtc, cancellationToken); ip.CurrentBlockScore.SubtractInPlace(_options.AccountCreditLimitHalfLife, credit, loginAttempt.TimeOfAttemptUtc); }, cancellationToken)); } } } else { // // The password was invalid. Do bookkeeping of information about this failure so that we can // block the origin IP if it appears to be engaged in guessing and so that we can track // frequently guessed passwords. // We'll not only store the (phase 2) hash of the incorrect password, but we'll also store // the incorrect passwords itself, encrypted with the EcPublicAccountLogKey. // (The decryption key to get the incorrect password plaintext back is encrypted with the // correct password, so you can't get to the plaintext of the incorrect password if you // don't already know the correct password.) loginAttempt.Phase2HashOfIncorrectPassword = phase2HashOfProvidedPassword; if (account.GetType().Name == "SimulatedUserAccount") { loginAttempt.EncryptedIncorrectPassword.Ciphertext = passwordProvidedByClient; } else { loginAttempt.EncryptedIncorrectPassword.Write(passwordProvidedByClient, account.EcPublicAccountLogKey); } // Next, if it's possible to declare more about this outcome than simply that the // user provided the incorrect password, let's do so. // Since users who are unsure of their passwords may enter the same username/password twice, but attackers // don't learn anything from doing so, we'll want to account for these repeats differently (and penalize them less). // We actually have two data structures for catching this: A large sketch of clientsIpHistory/account/password triples and a // tiny LRU cache of recent failed passwords for this account. We'll check both. if (await userAccountController.AddIncorrectPhaseTwoHashAsync(account, phase2HashOfProvidedPassword, cancellationToken: cancellationToken)) { // The same incorrect password was recently used for this account, indicate this so that we // do not penalize the IP further (as attackers don't gain anything from guessing the wrong password again). loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidRepeatedIncorrectPassword; } else { // This is the first time we've (at least recently) seen this incorrect password attempted for the account, loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidIncorrectPassword; // Penalize the IP for the invalid password double invalidPasswordPenalty = _options.PenaltyForInvalidPassword_Beta * _options.PopularityBasedPenaltyMultiplier_phi( loginAttempt); ip.CurrentBlockScore.AddInPlace(_options.AccountCreditLimitHalfLife, invalidPasswordPenalty, loginAttempt.TimeOfAttemptUtc); // Record the penalty so that it can be reduced if this incorrect password is later discovered to be a typo. ip.RecentPotentialTypos.Add(new LoginAttemptSummaryForTypoAnalysis() { EncryptedIncorrectPassword = loginAttempt.EncryptedIncorrectPassword, Penalty = new DecayingDouble(invalidPasswordPenalty, loginAttempt.TimeOfAttemptUtc), UsernameOrAccountId = loginAttempt.UsernameOrAccountId }); } } } finally { // Save changes to the user account record in the background (so that we don't hold up returning the result) TaskHelper.RunInBackground(userAccountRepository.SaveChangesAsync(cancellationToken)); } } else { // account == null // This is an login attempt for an INvalid (NONexistent) account. // if (phase1HashOfProvidedPassword == null) { phase1HashOfProvidedPassword = ExpensiveHashFunctionFactory.Get(_options.DefaultExpensiveHashingFunction)( passwordProvidedByClient, ManagedSHA256.Hash(Encoding.UTF8.GetBytes(loginAttempt.UsernameOrAccountId)), _options.ExpensiveHashingFunctionIterations); } // Get the popularity of the password provided by the client among incorrect passwords submitted in the past, // as we are most concerned about frequently-guessed passwords. loginAttempt.PasswordsHeightOnBinomialLadder = await passwordsHeightOnBinomialLadderTask; // This appears to be an loginAttempt to login to a non-existent account, and so all we need to do is // mark it as such. However, since it's possible that users will forget their account names and // repeatedly loginAttempt to login to a nonexistent account, we'll want to track whether we've seen // this account/password double before and note in the outcome if it's a repeat so that. // the IP need not be penalized for issuign a query that isn't getting it any information it // didn't already have. if (_recentIncorrectPasswords.AddMember(Convert.ToBase64String(phase1HashOfProvidedPassword))) { // Don't penalize the incorrect <invalid account/password> pair if we've seen the same // pair recently loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidRepeatedNoSuchAccount; } else { // Penalize the IP for a login attempt with an invalid account loginAttempt.Outcome = AuthenticationOutcome.CredentialsInvalidNoSuchAccount; double invalidAccontPenalty = _options.PenaltyForInvalidAccount_Alpha * _options.PopularityBasedPenaltyMultiplier_phi(loginAttempt); ip.CurrentBlockScore.AddInPlace(_options.BlockScoreHalfLife, invalidAccontPenalty, loginAttempt.TimeOfAttemptUtc); } } if (loginAttempt.Outcome == AuthenticationOutcome.CredentialsInvalidNoSuchAccount || loginAttempt.Outcome == AuthenticationOutcome.CredentialsInvalidIncorrectPassword) { // Record the invalid password into the binomial ladder sketch that tracks freqeunt-incorrect passwords // Since we don't need to know the result, we'll run it in the background (so that we don't hold up returning the result) TaskHelper.RunInBackground(_binomialLadderFilter.StepAsync(passwordProvidedByClient, cancellationToken: cancellationToken)); } return(loginAttempt); }
protected void AdjustBlockingScoreForPastTyposTreatedAsFullFailures( IpHistory clientsIpHistory, IUserAccountController <TUserAccount> accountController, TUserAccount account, DateTime whenUtc, string correctPassword, byte[] phase1HashOfCorrectPassword) { double credit = 0d; if (clientsIpHistory == null) { return; } LoginAttemptSummaryForTypoAnalysis[] recentPotentialTypos = clientsIpHistory.RecentPotentialTypos.MostRecentFirst.ToArray(); Encryption.IPrivateKey privateAccountLogKey = null; try { foreach (LoginAttemptSummaryForTypoAnalysis potentialTypo in recentPotentialTypos) { bool usernameCorrect = potentialTypo.UsernameOrAccountId == account.UsernameOrAccountId; if (usernameCorrect) { string passwordSubmittedInFailedAttempt = null; if (account.GetType().Name == "SimulatedUserAccount") { passwordSubmittedInFailedAttempt = potentialTypo.EncryptedIncorrectPassword.Ciphertext; } else { if (privateAccountLogKey == null) { try { privateAccountLogKey = accountController.DecryptPrivateAccountLogKey(account, phase1HashOfCorrectPassword); } catch (Exception) { return; } } try { passwordSubmittedInFailedAttempt = potentialTypo.EncryptedIncorrectPassword.Read(privateAccountLogKey); } catch (Exception) { } } if (passwordSubmittedInFailedAttempt != null) { bool passwordHadTypo = EditDistance.Calculate(passwordSubmittedInFailedAttempt, correctPassword) <= _options.MaxEditDistanceConsideredATypo; if (passwordHadTypo) { credit += potentialTypo.Penalty.GetValue(_options.AccountCreditLimitHalfLife, whenUtc) * (1d - _options.PenaltyMulitiplierForTypo); } } } clientsIpHistory.RecentPotentialTypos.Remove(potentialTypo); } clientsIpHistory.CurrentBlockScore.SubtractInPlace(account.CreditHalfLife, credit, whenUtc); } finally { privateAccountLogKey?.Dispose(); } }
/// <summary> /// This analysis will examine the client IP's previous failed attempts to login to this account /// to determine if any failed attempts were due to typos. /// </summary> /// <param name="clientsIpHistory">Records of this client's previous attempts to examine.</param> /// <param name="accountController"></param> /// <param name="account">The account that the client is currently trying to login to.</param> /// <param name="whenUtc"></param> /// <param name="correctPassword">The correct password for this account. (We can only know it because /// the client must have provided the correct one this loginAttempt.)</param> /// <param name="phase1HashOfCorrectPassword">The phase1 hash of that correct password (which we could /// recalculate from the information in the previous parameters, but doing so would be expensive.)</param> /// <returns></returns> protected void AdjustBlockingScoreForPastTyposTreatedAsFullFailures( IpHistory clientsIpHistory, IUserAccountController <TUserAccount> accountController, TUserAccount account, DateTime whenUtc, string correctPassword, byte[] phase1HashOfCorrectPassword) { double credit = 0d; if (clientsIpHistory == null) { return; } LoginAttemptSummaryForTypoAnalysis[] recentPotentialTypos = clientsIpHistory.RecentPotentialTypos.MostRecentFirst.ToArray(); Encryption.IPrivateKey privateAccountLogKey = null; try { foreach (LoginAttemptSummaryForTypoAnalysis potentialTypo in recentPotentialTypos) { bool usernameCorrect = potentialTypo.UsernameOrAccountId == account.UsernameOrAccountId; //bool usernameHadTypo = !usernameCorrect && // EditDistance.Calculate(potentialTypo.UsernameOrAccountId, account.UsernameOrAccountId) <= //_options.MaxEditDistanceConsideredATypo; if (usernameCorrect) // || usernameHadTypo { // Get the plaintext password from the previous login attempt string passwordSubmittedInFailedAttempt = null; if (account.GetType().Name == "SimulatedUserAccount") { passwordSubmittedInFailedAttempt = potentialTypo.EncryptedIncorrectPassword.Ciphertext; } else { if (privateAccountLogKey == null) { // Get the EC decryption key, which is stored encrypted with the Phase1 password hash try { privateAccountLogKey = accountController.DecryptPrivateAccountLogKey(account, phase1HashOfCorrectPassword); } catch (Exception) { // There's a problem with the key that prevents us from decrypting it. We won't be able to do this analysis. return; } } // Now try to decrypt the incorrect password from the previous attempt and perform the typo analysis try { // Attempt to decrypt the password. passwordSubmittedInFailedAttempt = potentialTypo.EncryptedIncorrectPassword.Read(privateAccountLogKey); } catch (Exception) { // An exception is likely due to an incorrect key (perhaps outdated). // Since we simply can't do anything with a record we can't Decrypt, we carry on // as if nothing ever happened. No. Really. Nothing to see here. } } if (passwordSubmittedInFailedAttempt != null) { //bool passwordCorrect = passwordSubmittedInFailedAttempt == correctPassword; bool passwordHadTypo = // !passwordCorrect && EditDistance.Calculate(passwordSubmittedInFailedAttempt, correctPassword) <= _options.MaxEditDistanceConsideredATypo; // Get credit for this nearly-correct attempt to counter penalty if (passwordHadTypo) { credit += potentialTypo.Penalty.GetValue(_options.AccountCreditLimitHalfLife, whenUtc) * (1d - _options.PenaltyMulitiplierForTypo); } } } // Now that we know whether this past event was a typo or not, we no longer need to keep track // of it (and we should remove it so we don't double credit it). clientsIpHistory.RecentPotentialTypos.Remove(potentialTypo); } // Remove the amount to be credited from the block score due to the discovery of typos clientsIpHistory.CurrentBlockScore.SubtractInPlace(account.CreditHalfLife, credit, whenUtc); } finally { privateAccountLogKey?.Dispose(); } }