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]>
294 lines
7.3 KiB
Go
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"))
|
|
}
|