// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package totp implements the time-based one-time password of RFC 6238 // with the HMAC-SHA-1 variant every authenticator application speaks. // The codes are six digits on a thirty-second step, the interoperable // default; anything rarer asks the user to configure their app instead // of the app just working. package totp import ( "crypto/hmac" "crypto/sha1" "encoding/binary" "strconv" "strings" "time" ) // Step is the time quantum a code lives on, per the RFC's reference. const Step = 30 * time.Second // Digits is the code length; six is what the URI format promises by // default and what applications show without asking. const Digits = 6 // counter derives the step counter of t. func counter(t time.Time) int64 { return t.Unix() / int64(Step/time.Second) } // codeAt computes the code of one counter. The comparison-ready HMAC // digest is returned as well: callers compare digests with hmac.Equal // instead of strings, so no timing leaks through early exits. func codeAt(secret []byte, counter int64) (string, []byte) { mac := hmac.New(sha1.New, secret) var msg [8]byte binary.BigEndian.PutUint64(msg[:], uint64(counter)) mac.Write(msg[:]) sum := mac.Sum(nil) // Dynamic truncation, RFC 4226 section 5.3: the last nibble picks // a four-byte window, whose top bit is dropped before the modulus. 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]) code := bin % 1_000_000 digits := strconv.Itoa(int(code)) return strings.Repeat("0", Digits-len(digits)) + digits, sum } // Code returns the code valid at t, for display and tests. A caller // that verifies must use Validate, which also guards replays. func Code(secret []byte, t time.Time) string { code, _ := codeAt(secret, counter(t)) return code } // Validate reports whether code is a current code for secret, accepting // one step of drift on either side the way every application does. // lastStep is the highest counter already accepted; counters at or below // it are refused, so a code observed once cannot be replayed while it is // still drifting. The accepted counter is returned for the caller to // store, and stays 0 when nothing matched. func Validate(secret []byte, code string, t time.Time, lastStep int64) (bool, int64) { code = normalise(code) if code == "" { return false, 0 } now := counter(t) // The newest candidate first: a drifting code from the previous // step must not advance the replay floor past a fresher one. for _, c := range []int64{now, now - 1, now + 1} { if c <= lastStep { continue } want, mac := codeAt(secret, c) var candidate [8]byte binary.BigEndian.PutUint64(candidate[:], uint64(c)) guess := hmac.New(sha1.New, secret) guess.Write(candidate[:]) if hmac.Equal(guess.Sum(nil), mac) && constantTimeEqual(code, want) { return true, c } } return false, 0 } // normalise strips the shapes humans type around a code: spaces from // the application's grouping and a dash a recovery habit might add. // Only six plain digits pass. func normalise(code string) string { code = strings.NewReplacer(" ", "", "-", "").Replace(code) if len(code) != Digits { return "" } for _, r := range code { if r < '0' || r > '9' { return "" } } return code } // constantTimeEqual compares two equal-length strings without an early // exit. Callers arrive here with both sides already six digits. func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := range a { v |= a[i] ^ b[i] } return v == 0 }