This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user