Files
goext/totpext/totp_test.go
T
Mikescher 02d6894ec6
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m34s
[🤖] Add Unit-Tests
2026-04-27 16:31:29 +02:00

291 lines
8.3 KiB
Go

package totpext
import (
"crypto/sha1"
"encoding/base32"
"fmt"
"net/url"
"regexp"
"strings"
"testing"
"time"
)
// RFC 6238 reference seed (ASCII "12345678901234567890")
var rfcSeed = []byte("12345678901234567890")
// generateTOTP tests against RFC 6238 reference test vectors (Appendix B).
// The reference vectors yield 8-digit codes; the package uses 6 digits, so we
// take the last 6 digits of the published 8-digit value.
func TestGenerateTOTPRFCVectors(t *testing.T) {
cases := []struct {
unix int64
expected string // last 6 digits of the RFC 8-digit value
}{
{59, "287082"},
{1111111109, "081804"},
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
}
for _, c := range cases {
got := generateTOTP(sha1.New, rfcSeed, c.unix/30, 6)
if got != c.expected {
t.Errorf("generateTOTP(unix=%d) = %q, expected %q", c.unix, got, c.expected)
}
}
}
func TestGenerateTOTPLengthAndDigits(t *testing.T) {
for digits := 6; digits <= 8; digits++ {
got := generateTOTP(sha1.New, rfcSeed, 0, digits)
if len(got) != digits {
t.Errorf("expected length %d, got %d (%q)", digits, len(got), got)
}
matched, _ := regexp.MatchString("^[0-9]+$", got)
if !matched {
t.Errorf("expected all-digit string, got %q", got)
}
}
}
func TestGenerateTOTPLeftPadsWithZeros(t *testing.T) {
// Use a high digit count to make zero-padding likely for some t.
// Find a t that produces a value shorter than digits without padding.
digits := 8
for ts := range 5000 {
got := generateTOTP(sha1.New, rfcSeed, int64(ts), digits)
if len(got) != digits {
t.Fatalf("length mismatch at ts=%d: %q (len=%d)", ts, got, len(got))
}
}
}
func TestGenerateTOTPDeterministic(t *testing.T) {
a := generateTOTP(sha1.New, rfcSeed, 12345, 6)
b := generateTOTP(sha1.New, rfcSeed, 12345, 6)
if a != b {
t.Errorf("generateTOTP not deterministic: %q vs %q", a, b)
}
}
func TestGenerateTOTPDifferentSecrets(t *testing.T) {
a := generateTOTP(sha1.New, []byte("AAAAAAAAAAAAAAAAAAAA"), 100, 6)
b := generateTOTP(sha1.New, []byte("BBBBBBBBBBBBBBBBBBBB"), 100, 6)
if a == b {
t.Errorf("expected different TOTPs for different secrets, both = %q", a)
}
}
func TestGenerateTOTPDifferentTimes(t *testing.T) {
a := generateTOTP(sha1.New, rfcSeed, 100, 6)
b := generateTOTP(sha1.New, rfcSeed, 101, 6)
if a == b {
t.Errorf("expected different TOTPs for different times, both = %q", a)
}
}
func TestTOTPFormat(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
code := TOTP(secret)
if len(code) != 6 {
t.Errorf("expected 6 digit code, got %q (len=%d)", code, len(code))
}
matched, _ := regexp.MatchString("^[0-9]{6}$", code)
if !matched {
t.Errorf("expected 6 digit numeric code, got %q", code)
}
}
func TestTOTPMatchesGenerateTOTPForCurrentTime(t *testing.T) {
secret := rfcSeed
// Generate both as close together as possible to share the same 30s window.
for range 3 {
now := time.Now().Unix()
windowStart := now / 30
got := TOTP(secret)
expected := generateTOTP(sha1.New, secret, windowStart, 6)
// If we crossed a window boundary, retry.
if time.Now().Unix()/30 != windowStart {
continue
}
if got != expected {
t.Errorf("TOTP() = %q, expected %q (window=%d)", got, expected, windowStart)
}
return
}
t.Skip("could not capture stable 30s window for comparison")
}
func TestValidateCurrentWindow(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
code := TOTP(secret)
if !Validate(secret, code) {
t.Errorf("Validate rejected a freshly generated TOTP")
}
}
func TestValidatePreviousAndNextWindow(t *testing.T) {
secret := rfcSeed
t0 := time.Now().Unix() / 30
prev := generateTOTP(sha1.New, secret, t0-1, 6)
next := generateTOTP(sha1.New, secret, t0+1, 6)
if !Validate(secret, prev) {
t.Errorf("Validate rejected previous-window code %q", prev)
}
if !Validate(secret, next) {
t.Errorf("Validate rejected next-window code %q", next)
}
}
func TestValidateRejectsOutOfWindow(t *testing.T) {
secret := rfcSeed
t0 := time.Now().Unix() / 30
// Two windows away — must be rejected.
farFuture := generateTOTP(sha1.New, secret, t0+5, 6)
farPast := generateTOTP(sha1.New, secret, t0-5, 6)
// In the unlikely case of a hash collision with a valid window, skip.
current := generateTOTP(sha1.New, secret, t0, 6)
prev := generateTOTP(sha1.New, secret, t0-1, 6)
next := generateTOTP(sha1.New, secret, t0+1, 6)
validSet := map[string]bool{current: true, prev: true, next: true}
if !validSet[farFuture] && Validate(secret, farFuture) {
t.Errorf("Validate accepted out-of-window future code %q", farFuture)
}
if !validSet[farPast] && Validate(secret, farPast) {
t.Errorf("Validate accepted out-of-window past code %q", farPast)
}
}
func TestValidateRejectsGarbage(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
for _, bad := range []string{"", "000000", "abcdef", "12345", "1234567"} {
if Validate(secret, bad) {
// "000000" could theoretically be valid; only fail if it's not the actual code.
if bad == "000000" && TOTP(secret) == "000000" {
continue
}
t.Errorf("Validate accepted garbage input %q", bad)
}
}
}
func TestValidateRejectsWrongSecret(t *testing.T) {
a, _ := GenerateSecret()
b, _ := GenerateSecret()
code := TOTP(a)
// Extremely unlikely both 20-byte random secrets agree on any window.
if Validate(b, code) {
t.Errorf("Validate accepted code from a different secret")
}
}
func TestGenerateSecretLength(t *testing.T) {
s, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if len(s) != 20 {
t.Errorf("expected 20-byte secret, got %d", len(s))
}
}
func TestGenerateSecretRandomness(t *testing.T) {
a, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
b, err := GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
if string(a) == string(b) {
t.Errorf("two GenerateSecret calls returned identical output")
}
}
func TestGenerateOTPAuthFormat(t *testing.T) {
key := []byte("12345678901234567890")
got := GenerateOTPAuth("MyApp", key, "user@example.com", "MyIssuer")
if !strings.HasPrefix(got, "otpauth://totp/") {
t.Errorf("expected otpauth scheme prefix, got %q", got)
}
u, err := url.Parse(got)
if err != nil {
t.Fatalf("URL parse failed: %v", err)
}
if u.Scheme != "otpauth" {
t.Errorf("scheme = %q, expected %q", u.Scheme, "otpauth")
}
if u.Host != "totp" {
t.Errorf("host = %q, expected %q", u.Host, "totp")
}
// Path is "/MyApp:user@example.com" (account email is QueryEscaped before formatting).
expectedPath := fmt.Sprintf("/MyApp:%s", url.QueryEscape("user@example.com"))
if u.Path != expectedPath && u.EscapedPath() != expectedPath {
t.Errorf("path = %q (escaped %q), expected %q", u.Path, u.EscapedPath(), expectedPath)
}
q := u.Query()
expectedSecret := base32.StdEncoding.EncodeToString(key)
if q.Get("secret") != expectedSecret {
t.Errorf("secret = %q, expected %q", q.Get("secret"), expectedSecret)
}
if q.Get("issuer") != "MyIssuer" {
t.Errorf("issuer = %q, expected %q", q.Get("issuer"), "MyIssuer")
}
if q.Get("algorithm") != "SHA1" {
t.Errorf("algorithm = %q, expected %q", q.Get("algorithm"), "SHA1")
}
if q.Get("period") != "30" {
t.Errorf("period = %q, expected %q", q.Get("period"), "30")
}
if q.Get("digits") != "6" {
t.Errorf("digits = %q, expected %q", q.Get("digits"), "6")
}
}
func TestGenerateOTPAuthEscapesAccount(t *testing.T) {
key := []byte("12345678901234567890")
got := GenerateOTPAuth("App", key, "a b@c.com", "Iss")
// The account is run through url.QueryEscape, so the space becomes '+'.
if !strings.Contains(got, "App:a+b%40c.com") {
t.Errorf("expected escaped account in path, got %q", got)
}
}
func TestGenerateOTPAuthSecretIsBase32(t *testing.T) {
key := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09}
got := GenerateOTPAuth("App", key, "u@e.com", "Iss")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("URL parse failed: %v", err)
}
secret := u.Query().Get("secret")
decoded, err := base32.StdEncoding.DecodeString(secret)
if err != nil {
t.Fatalf("secret is not valid base32: %v (raw=%q)", err, secret)
}
if string(decoded) != string(key) {
t.Errorf("decoded secret = %v, expected %v", decoded, key)
}
}