Files
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

294 lines
7.3 KiB
Go

package domain
import (
"testing"
"time"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/zerrors"
)
// stableNow returns a timestamp which is not in the last second of a TOTP period.
// VerifyTOTP validates against the wall clock, so this guarantees the period
// does not tick over between generating a code and validating it,
// which would shift the expected validity window of the skew based test cases.
func stableNow(t *testing.T) time.Time {
t.Helper()
for {
now := time.Now()
if now.Unix()%TOTPPeriod != TOTPPeriod-1 {
return now
}
time.Sleep(200 * time.Millisecond)
}
}
func TestVerifyTOTP(t *testing.T) {
cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
key, err := NewTOTPKey("example.com", "user1")
require.NoError(t, err)
secret, err := crypto.Encrypt([]byte(key.Secret()), cryptoAlg)
require.NoError(t, err)
unknownKeySecret := &crypto.CryptoValue{
CryptoType: secret.CryptoType,
Algorithm: secret.Algorithm,
KeyID: "unknown",
Crypted: secret.Crypted,
}
base := stableNow(t)
codeAt := func(ts time.Time) string {
t.Helper()
code, err := totp.GenerateCode(key.Secret(), ts)
require.NoError(t, err)
return code
}
type args struct {
code string
secret *crypto.CryptoValue
}
tests := []struct {
name string
args args
wantErr error
}{
{
name: "current period",
args: args{
code: codeAt(base),
secret: secret,
},
},
{
name: "previous period, within skew",
args: args{
code: codeAt(base.Add(-TOTPPeriod * time.Second)),
secret: secret,
},
},
{
name: "next period, within skew",
args: args{
code: codeAt(base.Add(TOTPPeriod * time.Second)),
secret: secret,
},
},
{
name: "two periods in the past, outside skew",
args: args{
code: codeAt(base.Add(-2 * TOTPPeriod * time.Second)),
secret: secret,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
},
{
name: "two periods in the future, outside skew",
args: args{
code: codeAt(base.Add(2 * TOTPPeriod * time.Second)),
secret: secret,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
},
{
name: "malformed code",
args: args{
code: "foobar",
secret: secret,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
},
{
name: "empty code",
args: args{
code: "",
secret: secret,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
},
{
name: "missing secret",
args: args{
code: codeAt(base),
secret: nil,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPT-mNsQwe", "input value cannot be nil"),
},
{
name: "unknown encryption key",
args: args{
code: codeAt(base),
secret: unknownKeySecret,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPT-Kq12vn", "value was encrypted with a different key"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := VerifyTOTP(tt.args.code, tt.args.secret, cryptoAlg)
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestTOTPHistory_AddRecent(t *testing.T) {
// start of a window which is still open.
start := time.Now().Add(-checkDuration)
hash1 := crypto.NewHMACValue("123456")
hash2 := crypto.NewHMACValue("654321")
type args struct {
ts time.Time
value *crypto.HMACValue
}
tests := []struct {
name string
history *TOTPHistory
args args
wantValues []*crypto.HMACValue
}{
{
name: "zero start, timestamp inside the window",
history: new(TOTPHistory),
args: args{
ts: time.Now(),
value: hash1,
},
wantValues: []*crypto.HMACValue{hash1},
},
{
name: "zero start, timestamp outside the window",
history: new(TOTPHistory),
args: args{
ts: time.Now().Add(-checkDuration - time.Minute),
value: hash1,
},
wantValues: nil,
},
{
name: "timestamp exactly at the start of the window",
history: &TOTPHistory{start: start},
args: args{
ts: start,
value: hash1,
},
wantValues: nil,
},
{
name: "appended after existing codes",
history: &TOTPHistory{start: start, recentValues: []*crypto.HMACValue{hash1}},
args: args{
ts: time.Now(),
value: hash2,
},
wantValues: []*crypto.HMACValue{hash1, hash2},
},
{
name: "same value added twice",
history: &TOTPHistory{start: start, recentValues: []*crypto.HMACValue{hash1}},
args: args{
ts: time.Now(),
value: hash1,
},
wantValues: []*crypto.HMACValue{hash1, hash1},
},
{
name: "nil value of a legacy event",
history: &TOTPHistory{start: start},
args: args{
ts: time.Now(),
value: nil,
},
wantValues: nil,
},
{
name: "nil value of a legacy event, zero start",
history: new(TOTPHistory),
args: args{
ts: time.Now(),
value: nil,
},
wantValues: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
startWasZero := tt.history.start.IsZero()
startBefore := tt.history.start
tt.history.AddRecent(tt.args.ts, tt.args.value)
assert.Equal(t, tt.wantValues, tt.history.recentValues)
switch {
case tt.args.value == nil:
assert.Equal(t, startBefore, tt.history.start, "a nil value must not set the start")
case startWasZero:
assert.WithinDuration(t, time.Now().Add(-checkDuration), tt.history.start, time.Second)
default:
assert.Equal(t, startBefore, tt.history.start, "an already set start must not be recomputed")
}
})
}
}
func TestTOTPHistory_CheckReuse(t *testing.T) {
recentValues := []*crypto.HMACValue{
crypto.NewHMACValue("123456"),
crypto.NewHMACValue("654321"),
}
tests := []struct {
name string
history *TOTPHistory
code string
wantErr error
}{
{
name: "empty history",
history: new(TOTPHistory),
code: "123456",
},
{
name: "code not used recently",
history: &TOTPHistory{recentValues: recentValues},
code: "111111",
},
{
name: "first code reused",
history: &TOTPHistory{recentValues: recentValues},
code: "123456",
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
},
{
name: "last code reused",
history: &TOTPHistory{recentValues: recentValues},
code: "654321",
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.history.CheckReuse(tt.code)
require.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestTOTPHistory_addAndCheck(t *testing.T) {
history := new(TOTPHistory)
history.AddRecent(time.Now(), crypto.NewHMACValue("123456"))
history.AddRecent(time.Now().Add(-checkDuration-time.Minute), crypto.NewHMACValue("654321"))
history.AddRecent(time.Now(), nil)
require.Error(t, history.CheckReuse("123456"), "a code used inside the window must be reported as reused")
require.NoError(t, history.CheckReuse("654321"), "a code used before the window must not be reported as reused")
require.NoError(t, history.CheckReuse("111111"))
}