Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package totp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rfcSecret is the RFC 6238 appendix B seed for SHA-1.
|
||||
var rfcSecret = []byte("12345678901234567890")
|
||||
|
||||
func at(unix int64) time.Time { return time.Unix(unix, 0).UTC() }
|
||||
|
||||
// TestRFCVectors checks the truncation against the appendix B table,
|
||||
// reduced to the six digits the URI format promises.
|
||||
func TestRFCVectors(t *testing.T) {
|
||||
vectors := []struct {
|
||||
unix int64
|
||||
code string
|
||||
}{
|
||||
{59, "287082"},
|
||||
{1111111109, "081804"},
|
||||
{1111111111, "050471"},
|
||||
{1234567890, "005924"},
|
||||
{2000000000, "279037"},
|
||||
{20000000000, "353130"},
|
||||
}
|
||||
for _, v := range vectors {
|
||||
if got := Code(rfcSecret, at(v.unix)); got != v.code {
|
||||
t.Fatalf("Code(%d) = %q, want %q", v.unix, got, v.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsWindow(t *testing.T) {
|
||||
codeTime := at(1_000_000_000)
|
||||
code := Code(rfcSecret, codeTime)
|
||||
for _, drift := range []time.Duration{-Step, 0, Step} {
|
||||
ok, step := Validate(rfcSecret, code, codeTime.Add(drift), 0)
|
||||
if !ok {
|
||||
t.Fatalf("drift %v rejected", drift)
|
||||
}
|
||||
if step != counter(codeTime) {
|
||||
t.Fatalf("drift %v: step = %d, want the code's counter %d",
|
||||
drift, step, counter(codeTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefusesOutsideWindow(t *testing.T) {
|
||||
code := Code(rfcSecret, at(1_000_000_000))
|
||||
if ok, _ := Validate(rfcSecret, code, at(1_000_000_000).Add(2*Step), 0); ok {
|
||||
t.Fatal("two steps ahead accepted")
|
||||
}
|
||||
if ok, _ := Validate(rfcSecret, code, at(1_000_000_000).Add(-2*Step), 0); ok {
|
||||
t.Fatal("two steps behind accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGuardsReplay(t *testing.T) {
|
||||
now := at(1_000_000_000)
|
||||
code := Code(rfcSecret, now)
|
||||
ok, step := Validate(rfcSecret, code, now, 0)
|
||||
if !ok || step == 0 {
|
||||
t.Fatalf("first use failed: ok=%v step=%d", ok, step)
|
||||
}
|
||||
if ok, _ := Validate(rfcSecret, code, now, step); ok {
|
||||
t.Fatal("same counter accepted twice")
|
||||
}
|
||||
if ok, _ := Validate(rfcSecret, code, now.Add(Step), step); ok {
|
||||
t.Fatal("older drifting code accepted after a newer one")
|
||||
}
|
||||
// The next step's code stays valid: the floor is the counter, not
|
||||
// a blanket cooldown.
|
||||
next := Code(rfcSecret, now.Add(2*Step))
|
||||
if ok, newer := Validate(rfcSecret, next, now.Add(2*Step), step); !ok || newer <= step {
|
||||
t.Fatalf("fresh code refused: ok=%v step=%d", ok, newer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalise(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"123456": "123456",
|
||||
" 123 456 ": "123456",
|
||||
"123-456": "123456",
|
||||
"004203": "004203",
|
||||
} {
|
||||
if got := normalise(in); got != want {
|
||||
t.Fatalf("normalise(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "12345", "1234567", "12345a", "12 34 56 78", "12345\n"} {
|
||||
if got := normalise(in); got != "" {
|
||||
t.Fatalf("normalise(%q) = %q, want empty", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user