Files
volumen/internal/totp/totp.go
T

119 lines
3.7 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
}