Files
zitadel/internal/crypto/hmac.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

50 lines
1.6 KiB
Go

package crypto
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
)
// HMACValue is a randomly salted HMAC-SHA256 of a payload.
// It allows checking whether a payload was seen before,
// without having to store the payload itself.
//
// It is intended for low entropy secrets, such as 6 digit TOTP codes:
// the random salt makes each value unique, even for an equal payload,
// which counters rainbow table usage and prevents that two stored values
// can be recognized as being derived from the same payload.
//
// As the salt is stored alongside the hash, an HMACValue is not suitable
// as a replacement for retrievable secrets. Use [CryptoValue] for that.
//
// Both fields are JSON serializable, so a value can be stored in an event payload.
type HMACValue struct {
Hash []byte `json:"hash"`
Salt []byte `json:"salt"`
}
// NewHMACValue returns the HMAC of payload, using a newly generated random salt.
// Two calls with an equal payload always return different values.
func NewHMACValue(payload string) *HMACValue {
salt := make([]byte, 16)
// [rand.Read] never returns an error, it panics if the system source fails.
rand.Read(salt)
hasher := hmac.New(sha256.New, salt)
hasher.Write([]byte(payload))
return &HMACValue{
Hash: hasher.Sum(nil),
Salt: salt,
}
}
// Equal reports whether payload is the one v was created from.
// The hashes are compared in constant time.
func (v *HMACValue) Equal(payload string) bool {
hasher := hmac.New(sha256.New, v.Salt)
hasher.Write([]byte(payload))
expectedHash := hasher.Sum(nil)
return hmac.Equal(v.Hash, expectedHash)
}