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) } }