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]>
This commit is contained in:
Tim Möhlmann
2026-08-27 07:17:39 +02:00
committed by GitHub
co-authored by Livio Spring Copilot Autofix powered by AI copilot-swe-agent[bot] <198982749+[email protected]> muhlemmer
parent c9d747d407
commit 6128aeaa89
56 changed files with 850 additions and 8 deletions
+150 -1
View File
@@ -1074,6 +1074,23 @@ func TestCommands_updateSession(t *testing.T) {
}
}
// assertTOTPCodeHash asserts that every [user.HumanOTPCheckSucceededEvent] in
// cmds carries a hash of code and clears it afterwards. The hash is salted with
// a random value, so it cannot be compared against a precomputed expectation.
func assertTOTPCodeHash(t *testing.T, cmds []eventstore.Command, code string) {
t.Helper()
for _, cmd := range cmds {
event, ok := cmd.(*user.HumanOTPCheckSucceededEvent)
if !ok {
continue
}
if assert.NotNil(t, event.CodeHash, "the OTP check succeeded event must carry a code hash") {
assert.True(t, event.CodeHash.Equal(code), "the code hash must match the checked code")
}
event.CodeHash = nil
}
}
func TestCheckTOTP(t *testing.T) {
ctx := authz.NewMockContext("instance1", "org1", "user1")
@@ -1090,6 +1107,9 @@ func TestCheckTOTP(t *testing.T) {
code, err := totp.GenerateCode(key.Secret(), testNow)
require.NoError(t, err)
// hash of a code which is guaranteed to be different from the checked code.
otherCodeHash := crypto.NewHMACValue(code + "0")
type fields struct {
sessionWriteModel *SessionWriteModel
eventstore func(*testing.T) *eventstore.Eventstore
@@ -1236,10 +1256,138 @@ func TestCheckTOTP(t *testing.T) {
tarpit: expectTarpit(0),
},
wantEventCommands: []eventstore.Command{
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil),
// The code hash is salted randomly and therefore not predictable.
// It is asserted separately by assertTOTPCodeHash.
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
},
},
{
name: "ok, previous check with a different code",
code: code,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
UserCheckedAt: testNow,
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
),
eventFromEventPusher(
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, otherCodeHash),
),
),
expectFilter(), // recheck
),
tarpit: expectTarpit(0),
},
wantEventCommands: []eventstore.Command{
// The code hash is salted randomly and therefore not predictable.
// It is asserted separately by assertTOTPCodeHash.
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
},
},
{
name: "ok, previous check of the same code outside of the reuse window",
code: code,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
UserCheckedAt: testNow,
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
),
eventFromEventPusher(
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
),
eventFromEventPusherWithCreationDate(
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
time.Now().Add(-2*time.Minute),
),
),
expectFilter(), // recheck
),
tarpit: expectTarpit(0),
},
wantEventCommands: []eventstore.Command{
// The code hash is salted randomly and therefore not predictable.
// It is asserted separately by assertTOTPCodeHash.
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
},
},
{
name: "code reuse error",
code: code,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
UserCheckedAt: testNow,
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
),
eventFromEventPusher(
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
),
),
expectFilter(), // recheck
),
tarpit: expectTarpit(0),
},
wantErrorCommands: []eventstore.Command{
user.NewHumanOTPCheckReusedEvent(ctx, userAgg, nil),
},
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
},
{
name: "code reuse error, used in the meantime",
code: code,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
UserCheckedAt: testNow,
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
),
eventFromEventPusher(
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
),
),
expectFilter( // recheck
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
),
),
),
tarpit: expectTarpit(0),
},
wantErrorCommands: []eventstore.Command{
user.NewHumanOTPCheckReusedEvent(ctx, userAgg, nil),
},
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
},
{
name: "ok, but locked in the meantime",
code: code,
@@ -1279,6 +1427,7 @@ func TestCheckTOTP(t *testing.T) {
gotCmds, err := CheckTOTP(tt.code)(ctx, cmd)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantErrorCommands, gotCmds)
assertTOTPCodeHash(t, cmd.eventCommands, tt.code)
assert.Equal(t, tt.wantEventCommands, cmd.eventCommands)
tt.fields.tarpit.metExpectedCalls(t)
})
+13 -1
View File
@@ -211,7 +211,19 @@ func checkTOTP(
// the OTP check succeeded and the user was not locked in the meantime
if verifyErr == nil {
return []eventstore.Command{user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, optionalAuthRequestInfo)}, nil
// A replay is reported as a distinct event, so that it remains 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.
if reuseErr := existingOTP.History.CheckReuse(code); reuseErr != nil {
return []eventstore.Command{user.NewHumanOTPCheckReusedEvent(ctx, userAgg, optionalAuthRequestInfo)}, reuseErr
}
return []eventstore.Command{user.NewHumanOTPCheckSucceededEvent(
ctx,
userAgg,
optionalAuthRequestInfo,
crypto.NewHMACValue(code),
)}, nil
}
// the OTP check failed, therefore check if the limit was reached and the user must additionally be locked
+8
View File
@@ -16,6 +16,7 @@ type HumanTOTPWriteModel struct {
Secret *crypto.CryptoValue
CheckFailedCount uint64
UserLocked bool
History *domain.TOTPHistory
}
func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
@@ -24,6 +25,7 @@ func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
AggregateID: userID,
ResourceOwner: resourceOwner,
},
History: new(domain.TOTPHistory),
}
}
@@ -38,6 +40,7 @@ func (wm *HumanTOTPWriteModel) Reduce() error {
wm.CheckFailedCount = 0
case *user.HumanOTPCheckSucceededEvent:
wm.CheckFailedCount = 0
wm.History.AddRecent(e.CreatedAt(), e.CodeHash)
case *user.HumanOTPCheckFailedEvent:
wm.CheckFailedCount++
case *user.UserLockedEvent:
@@ -87,6 +90,11 @@ func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
// checkTOTP rechecks the same write model for events which arrived during the
// code verification. Bound the query so only those are reduced again.
if wm.ProcessedSequence != 0 {
query.SequenceGreater(wm.ProcessedSequence)
}
return query
}
@@ -0,0 +1,25 @@
package command
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHumanTOTPWriteModel_Query(t *testing.T) {
wm := NewHumanTOTPWriteModel("user1", "org1")
// A freshly created write model has nothing reduced yet
// and therefore must query the complete stream.
unbound := wm.Query()
assert.Zero(t, unbound.GetEventSequenceGreater())
// checkTOTP rechecks the same write model after the code verification.
// Only the events which arrived in the meantime may be reduced again.
wm.ProcessedSequence = 42
bound := wm.Query()
assert.Equal(t, uint64(42), bound.GetEventSequenceGreater())
// The sequence must be the only difference between both queries.
assert.Equal(t, unbound.SequenceGreater(42), bound)
}