Files
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

239 lines
8.2 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password
import (
"bufio"
"context"
"crypto/sha1" //#nosec G505 -- sha1 is used for k-anonymity
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/ory/kratos/text"
"github.com/arbovm/levenshtein"
"github.com/dgraph-io/ristretto/v2"
"github.com/hashicorp/go-retryablehttp"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/httpx"
"github.com/ory/x/otelx"
)
const hashCacheItemTTL = time.Hour
// maxIdentifierSimilarityCheckLength bounds the operands of the O(n·m)
// identifier-similarity check to keep its worst-case cost constant. Real
// identifiers (emails, usernames) and passwords are far shorter than this, so
// legitimate flows are unaffected, while oversized operands are skipped to keep
// the check's cost bounded. The bound is checked on the raw byte length as a
// cheap pre-filter and again on the case-folded operands, since lowercasing can
// expand an operand (e.g. Turkish dotted I).
const maxIdentifierSimilarityCheckLength = 256
// Validator implements a validation strategy for passwords. One example is that the password
// has to have at least 6 characters and at least one lower and one uppercase password.
type Validator interface {
// Validate returns nil if the password is passing the validation strategy and an error otherwise. If a validation error
// occurs, a regular error will be returned. If some other type of error occurs (e.g. HTTP request failed), an error
// of type *herodot.DefaultError will be returned.
Validate(ctx context.Context, identifier, password string) error
}
type ValidationProvider interface {
PasswordValidator() Validator
}
var (
_ Validator = new(DefaultPasswordValidator)
ErrNetworkFailure = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unable to check if password has been leaked because an unexpected network error occurred")
ErrUnexpectedStatusCode = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unexpected status code from haveibeenpwned.com")
)
// DefaultPasswordValidator implements Validator. It is based on best
// practices as defined in the following blog posts:
//
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
//
// Additionally passwords are being checked against Troy Hunt's
// [haveibeenpwnd](https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange) service to check if the
// password has been breached in a previous data leak using k-anonymity.
type DefaultPasswordValidator struct {
reg validatorDependencies
hashes *ristretto.Cache[string, int64]
minIdentifierPasswordDist int
maxIdentifierPasswordSubstrThreshold float32
}
type validatorDependencies interface {
config.Provider
httpx.ClientProvider
}
func NewDefaultPasswordValidatorStrategy(reg validatorDependencies) (*DefaultPasswordValidator, error) {
cache, err := ristretto.NewCache(&ristretto.Config[string, int64]{
NumCounters: 10 * 10000,
MaxCost: 60 * 10000, // BCrypt hash size is 60 bytes
BufferItems: 64,
IgnoreInternalCost: true,
})
// sanity check - this should never happen unless above configuration variables are invalid
if err != nil {
return nil, errors.Wrap(err, "error while setting up validator cache")
}
return &DefaultPasswordValidator{
reg: reg,
hashes: cache,
minIdentifierPasswordDist: 5, maxIdentifierPasswordSubstrThreshold: 0.5,
}, nil
}
func b20(src []byte) string {
return fmt.Sprintf("%X", src)
}
// code inspired by https://rosettacode.org/wiki/Longest_Common_Substring#Go
func lcsLength(a, b string) int {
lengths := make([]int, len(a)*len(b))
greatestLength := 0
for i, x := range a {
for j, y := range b {
if x == y {
curr := 1
if i != 0 && j != 0 {
curr = lengths[(i-1)*len(b)+j-1] + 1
}
if curr > greatestLength {
greatestLength = curr
}
lengths[i*len(b)+j] = curr
}
}
}
return greatestLength
}
func (s *DefaultPasswordValidator) fetch(ctx context.Context, hpw []byte, apiDNSName string) (int64, error) {
prefix := fmt.Sprintf("%X", hpw)[0:5]
loc := fmt.Sprintf("https://%s/range/%s", apiDNSName, prefix)
req, err := retryablehttp.NewRequestWithContext(ctx, "GET", loc, nil)
if err != nil {
return 0, err
}
res, err := s.reg.HTTPClient(ctx, httpx.ResilientClientWithConnectionTimeout(time.Second)).Do(req)
if err != nil {
return 0, errors.Wrapf(ErrNetworkFailure, "%s", err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return 0, errors.Wrapf(ErrUnexpectedStatusCode, "%d", res.StatusCode)
}
var thisCount int64
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
row := sc.Text()
result := strings.Split(strings.TrimSpace(row), ":")
// We assume a count of 1. HIBP API sometimes responds without the
// colon, so we just assume that the leak count is one.
//
// See https://github.com/ory/kratos/issues/2145
count := int64(1)
if len(result) == 2 {
count, err = strconv.ParseInt(strings.ReplaceAll(result[1], ",", ""), 10, 64)
if err != nil {
return 0, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("Expected password hash to contain a count formatted as int but got: %s", result[1]))
}
}
s.hashes.SetWithTTL(prefix+result[0], count, 1, hashCacheItemTTL)
if prefix+result[0] == b20(hpw) {
thisCount = count
}
}
// A read that fails after the response headers arrived (connection reset,
// client timeout, truncated body) is as much a network failure as a failed
// request, so it has to be classified as one to stay subject to
// ignore_network_errors.
if err := sc.Err(); err != nil {
return 0, errors.Wrapf(ErrNetworkFailure, "unable to read the response body: %s", err)
}
s.hashes.SetWithTTL(b20(hpw), thisCount, 1, hashCacheItemTTL)
return thisCount, nil
}
func (s *DefaultPasswordValidator) Validate(ctx context.Context, identifier, password string) error {
return otelx.WithSpan(ctx, "password.DefaultPasswordValidator.Validate", func(ctx context.Context) error {
return s.validate(ctx, identifier, password)
})
}
func (s *DefaultPasswordValidator) validate(ctx context.Context, identifier, password string) error {
passwordPolicyConfig := s.reg.Config().PasswordPolicyConfig(ctx)
//nolint:gosec // disable G115
if len(password) < int(passwordPolicyConfig.MinPasswordLength) {
//nolint:gosec // disable G115
return text.NewErrorValidationPasswordMinLength(int(passwordPolicyConfig.MinPasswordLength), len(password))
}
if passwordPolicyConfig.IdentifierSimilarityCheckEnabled && len(identifier) > 0 &&
len(identifier) <= maxIdentifierSimilarityCheckLength &&
len(password) <= maxIdentifierSimilarityCheckLength {
compIdentifier, compPassword := strings.ToLower(identifier), strings.ToLower(password)
// Case-folding can expand an operand, so re-check the folded lengths
// before running the O(n·m) comparison.
if len(compIdentifier) <= maxIdentifierSimilarityCheckLength &&
len(compPassword) <= maxIdentifierSimilarityCheckLength {
dist := levenshtein.Distance(compIdentifier, compPassword)
lcs := float32(lcsLength(compIdentifier, compPassword)) / float32(len(compPassword))
if dist < s.minIdentifierPasswordDist || lcs > s.maxIdentifierPasswordSubstrThreshold {
return text.NewErrorValidationPasswordIdentifierTooSimilar()
}
}
}
if !passwordPolicyConfig.HaveIBeenPwnedEnabled {
return nil
}
//#nosec G401 -- sha1 is used for k-anonymity
h := sha1.New()
if _, err := h.Write([]byte(password)); err != nil {
return err
}
hpw := h.Sum(nil)
c, ok := s.hashes.Get(b20(hpw))
if !ok {
var err error
c, err = s.fetch(ctx, hpw, passwordPolicyConfig.HaveIBeenPwnedHost)
if (errors.Is(err, ErrNetworkFailure) || errors.Is(err, ErrUnexpectedStatusCode)) && passwordPolicyConfig.IgnoreNetworkErrors {
return nil
} else if err != nil {
return err
}
}
//nolint:gosec // disable G115
if c > int64(s.reg.Config().PasswordPolicyConfig(ctx).MaxBreaches) {
return text.NewErrorValidationPasswordTooManyBreaches(c)
}
return nil
}