mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-31 11:32:15 +00:00
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:
co-authored by
Livio Spring
Copilot Autofix powered by AI
copilot-swe-agent[bot] <198982749+[email protected]>
muhlemmer
parent
c9d747d407
commit
6128aeaa89
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user