package security import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base32" "encoding/binary" "fmt" "net/url" "time" ) // TOTP constants. The algorithm is pinned to HMAC SHA 256 end to end (URI generation and validation) // with a 30 second step and 6 digits. The secret is 256 bits to match SHA 256. Pinning the algorithm // and emitting algorithm=SHA256 in the provisioning URI prevents the common failure where an // authenticator defaults to SHA 1 and silently produces codes the server rejects. const ( totpDigits = 6 totpPeriod = 30 totpSecretSize = 32 totpModulo = 1000000 ) var totpBase32 = base32.StdEncoding.WithPadding(base32.NoPadding) // TOTP validates RFC 6238 time based one time codes. It carries no secret of its own; the caller // passes the per user secret on each call. Validation enforces a strictly monotonic replay guard so // a code, once accepted, cannot be replayed and no earlier still valid step can be reused either. type TOTP struct{} // GenerateSecret returns a fresh 32 byte TOTP secret from crypto/rand. func GenerateSecret() ([]byte, error) { secret := make([]byte, totpSecretSize) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("security: read totp secret: %w", err) } return secret, nil } // EncodeSecret renders a secret as unpadded base32, the form an authenticator app expects in a QR // code or manual entry. func EncodeSecret(secret []byte) string { return totpBase32.EncodeToString(secret) } // ProvisioningURI builds the otpauth URI an authenticator imports. issuer and account label the // entry; algorithm, digits, and period are pinned so the app and the server agree. func ProvisioningURI(secret []byte, account, issuer string) string { label := url.PathEscape(issuer + ":" + account) q := url.Values{} q.Set("secret", EncodeSecret(secret)) q.Set("issuer", issuer) q.Set("algorithm", "SHA256") q.Set("digits", fmt.Sprintf("%d", totpDigits)) q.Set("period", fmt.Sprintf("%d", totpPeriod)) return "otpauth://totp/" + label + "?" + q.Encode() } // code computes the 6 digit TOTP for a given counter using HMAC SHA 256 and RFC 4226 dynamic // truncation. func code(secret []byte, counter uint64) string { var buf [8]byte binary.BigEndian.PutUint64(buf[:], counter) mac := hmac.New(sha256.New, secret) mac.Write(buf[:]) sum := mac.Sum(nil) offset := sum[len(sum)-1] & 0x0f bin := (uint32(sum[offset])&0x7f)<<24 | uint32(sum[offset+1])<<16 | uint32(sum[offset+2])<<8 | uint32(sum[offset+3]) return fmt.Sprintf("%0*d", totpDigits, bin%totpModulo) } // Validate checks submitted against the secret across the steps now minus one, now, and now plus one // (a one step skew on each side). It accepts a step only if that step is strictly greater than // lastUsedStep, so once a code is consumed neither it nor any earlier in window step can be replayed. // On success it returns the matched step, which the caller MUST persist as the new lastUsedStep. func (TOTP) Validate(secret []byte, submitted string, now time.Time, lastUsedStep int64) (ok bool, usedStep int64) { if len(submitted) != totpDigits { return false, 0 } current := now.Unix() / totpPeriod for _, step := range []int64{current - 1, current, current + 1} { if step <= lastUsedStep { continue } want := code(secret, uint64(step)) if subtle.ConstantTimeCompare([]byte(want), []byte(submitted)) == 1 { return true, step } } return false, 0 } // CurrentStep is the timestep for now, useful for tests and for seeding lastUsedStep boundaries. func CurrentStep(now time.Time) int64 { return now.Unix() / totpPeriod }