Files
zitadel/internal/domain/human_otp.go
T
6128aeaa89 fix(security): prevent TOTP reuse (#12616)
# 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]>
2026-08-27 07:17:39 +02:00

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
}