mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved TOTP codes could be reused, violating [RFC 6238, section 5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2). # How the Problems Are Solved Transmitted codes are now recorded in each success event. As the codes amount to secrets, they are not stored verbatim but as a randomly salted HMAC-SHA256 (`crypto.HMACValue`). Each value gets a fresh random salt, so an equal code always produces a different stored value and two entries can never be recognized as being derived from the same code. This is deliberately not a replacement for proper secret storage: with the salt stored alongside the hash and a 6 digit payload, anyone with event access can brute-force the code in milliseconds. That is acceptable here because a code is worthless once its window has passed. The `HMACValue` doc comment states the same. TOTP codes are low-entropy (6 digits), pseudo random codes. Therefore it is possible for the same code to be generated more than once over the lifetime of a user. In order to prevent the (unlikely) case where users login with large time intervalls and a generated code may be equal to the one last used a long time ago, we only check for codes used within the recent period window. A simpler implementation would be simply storing the latest consumed step, but the TOTP library does not return this value. Because of the allowed skew there are always 3 steps within the validation window, so we can't calculate the current step based on time alone. The retained history spans one period more than the validation window: validity is decided by the app server's clock, while the stored events carry the database clock, and the extra period absorbs the drift between them. **Note:** a reused code pushes a distinct `user.human.mfa.otp.check.reused` event, not a failed check event. A replay therefore stays observable through the Events API without counting towards the lockout policy. Otherwise anyone who captured a code could replay it to lock the user out. # Additional Changes - Read only the latest events in recheck query # Additional Context - Closes https://github.com/zitadel/zitadel/issues/6452 --------- Co-authored-by: Livio Spring <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: muhlemmer <[email protected]>
106 lines
2.8 KiB
Go
106 lines
2.8 KiB
Go
package domain
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/pquerna/otp"
|
|
"github.com/pquerna/otp/totp"
|
|
|
|
"github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
// These constants are not exported by the totp library,
|
|
// but are important for our reuse prevention logic.
|
|
// They are taken from the [totp.Validate] implementation,
|
|
// and are compatible with Google-Authenticator and most clients.
|
|
// See [totp.ValidateOpts] for the meaning of these values.
|
|
const (
|
|
TOTPPeriod = 30
|
|
TOTPSkew = 1
|
|
)
|
|
|
|
type TOTP struct {
|
|
*ObjectDetails
|
|
|
|
Secret string
|
|
URI string
|
|
}
|
|
|
|
func NewTOTPKey(issuer, accountName string) (*otp.Key, error) {
|
|
key, err := totp.Generate(totp.GenerateOpts{
|
|
Issuer: issuer,
|
|
AccountName: accountName,
|
|
Period: TOTPPeriod,
|
|
})
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func VerifyTOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error {
|
|
decrypt, err := crypto.DecryptString(secret, cryptoAlg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use ValidateCustom so we have compile-time alignment of constants,
|
|
// as [totp.Validate] does not export the default values.
|
|
valid, err := totp.ValidateCustom(
|
|
code,
|
|
decrypt,
|
|
time.Now().UTC(),
|
|
totp.ValidateOpts{
|
|
Period: TOTPPeriod,
|
|
Skew: TOTPSkew,
|
|
Digits: otp.DigitsSix,
|
|
Algorithm: otp.AlgorithmSHA1,
|
|
})
|
|
if err != nil || !valid {
|
|
return zerrors.ThrowInvalidArgument(err, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
checkPeriods = 2 + 2*TOTPSkew // 1 extra period to account for DB clock skew.
|
|
checkDuration = TOTPPeriod * time.Second * checkPeriods
|
|
)
|
|
|
|
type TOTPHistory struct {
|
|
start time.Time
|
|
recentValues []*crypto.HMACValue
|
|
}
|
|
|
|
// AddRecent appends the HMAC value of a used code if its timestamp is
|
|
// within the current period window and value is not nil.
|
|
// A nil value is ignored, as it originates from a legacy event
|
|
// which did not record the used code yet.
|
|
func (h *TOTPHistory) AddRecent(ts time.Time, value *crypto.HMACValue) {
|
|
if value == nil {
|
|
return
|
|
}
|
|
|
|
// If the start is zero, we assume this is the first code being added,
|
|
// and set the start to the beginning of the current window.
|
|
// This ensures that we only store codes that are within the valid window.
|
|
if h.start.IsZero() {
|
|
h.start = time.Now().Add(-checkDuration)
|
|
}
|
|
if ts.After(h.start) {
|
|
h.recentValues = append(h.recentValues, value)
|
|
}
|
|
}
|
|
|
|
// CheckReuse returns an error if the provided code was recently used.
|
|
func (h *TOTPHistory) CheckReuse(code string) error {
|
|
if slices.ContainsFunc(h.recentValues, func(v *crypto.HMACValue) bool {
|
|
return v.Equal(code)
|
|
}) {
|
|
return zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused")
|
|
}
|
|
return nil
|
|
}
|