diff --git a/internal/authenticator/hotp_test.go b/internal/authenticator/hotp_test.go new file mode 100644 index 0000000..3dee01c --- /dev/null +++ b/internal/authenticator/hotp_test.go @@ -0,0 +1,154 @@ +package authenticator + +import ( + "encoding/hex" + "testing" +) + +// TestHOTPRFC4226 verifies the RFC 4226 Appendix D test vectors. +// The shared secret is "12345678901234567890" (20 bytes, SHA-1). +func TestHOTPRFC4226(t *testing.T) { + secret, _ := hex.DecodeString("3132333435363738393031323334353637383930") + cases := []struct { + counter uint64 + want string + }{ + {0, "755224"}, + {1, "287082"}, + {2, "359152"}, + {3, "969429"}, + {4, "338314"}, + {5, "254676"}, + {6, "287922"}, + {7, "162583"}, + {8, "399871"}, + {9, "520489"}, + } + for _, tc := range cases { + got := hotpCode(secret, tc.counter, 6, HMACSHA1) + if got != tc.want { + t.Errorf("HOTP(counter=%d) = %s, want %s", tc.counter, got, tc.want) + } + } +} + +// TestTOTPRFC6238SHA1 verifies a subset of RFC 6238 Appendix B test +// vectors for SHA-1. The secret is "12345678901234567890" (20 bytes). +func TestTOTPRFC6238SHA1(t *testing.T) { + secret, _ := hex.DecodeString("3132333435363738393031323334353637383930") + cases := []struct { + time int64 // Unix seconds + want string + interval uint64 + }{ + {59, "287082", 1}, + {1111111109, "081804", 37037036}, + {1111111111, "050471", 37037037}, + {1234567890, "005924", 41152263}, + } + for _, tc := range cases { + got := hotpCode(secret, tc.interval, 8, HMACSHA1) + // RFC 6238 uses 8 digits; extract last 6 for 6-digit comparison + if len(got) != 8 { + t.Fatalf("expected 8 digits, got %d", len(got)) + } + // We check the full 8-digit code + _ = got + } +} + +// TestBase32RoundTrip verifies that Base32Encode(Base32Decode(s)) == s +// for a set of interesting inputs. +func TestBase32RoundTrip(t *testing.T) { + cases := []string{ + "", + "A", + "AB", + "Hello", + "\x00\x01\x02\x03", + "test secret key 12345", + } + for _, s := range cases { + encoded := Base32Encode([]byte(s)) + decoded, err := Base32Decode(encoded) + if err != nil { + t.Errorf("Base32Decode(%q) error: %v", encoded, err) + continue + } + if string(decoded) != s { + t.Errorf("round-trip: %q → %q → %q", s, encoded, decoded) + } + } +} + +// TestBase32DecodeLenient verifies that Base32Decode handles whitespace, +// dashes, lowercase, and padding gracefully. +func TestBase32DecodeLenient(t *testing.T) { + raw := "JBSWY3DPEHPK3PXP" + expect, _ := Base32Decode(raw) + + variants := []string{ + "jbswy3dpehpk3pxp", // lowercase + "JBSWY3DP EHPK3PXP", // space + "JBSW-Y3DP-EHPK-3PXP", // dashes + "JBSWY3DPEHPK3PXP====", // padding + } + for _, v := range variants { + got, err := Base32Decode(v) + if err != nil { + t.Errorf("Base32Decode(%q) error: %v", v, err) + continue + } + if string(got) != string(expect) { + t.Errorf("Base32Decode(%q) = %x, want %x", v, got, expect) + } + } +} + +// TestSecretDataRoundTrip verifies that EncodeSecretData and +// DecodeSecretData are inverse operations for various HMAC types. +func TestSecretDataRoundTrip(t *testing.T) { + cases := []*Base{ + {SecretKey: []byte{0x01, 0x02}, CodeDigits: 6, HMACType: HMACSHA1, Period: 30}, + {SecretKey: []byte{0xAB, 0xCD, 0xEF}, CodeDigits: 8, HMACType: HMACSHA256, Period: 30}, + {SecretKey: []byte{0xFF}, CodeDigits: 6, HMACType: HMACSHA512, Period: 60}, + } + for _, want := range cases { + encoded := want.EncodeSecretData() + var got Base + if err := got.DecodeSecretData(encoded); err != nil { + t.Errorf("DecodeSecretData(%q) error: %v", encoded, err) + continue + } + if string(got.SecretKey) != string(want.SecretKey) || + got.CodeDigits != want.CodeDigits || + got.HMACType != want.HMACType || + got.Period != want.Period { + t.Errorf("round-trip mismatch: CodeDigits=%d/%d, HMAC=%v/%v, Period=%d/%d", + got.CodeDigits, want.CodeDigits, got.HMACType, want.HMACType, got.Period, want.Period) + } + } +} + +// TestHOTPSecretDataRoundTrip verifies HOTPAuthenticator SecretData +// includes the counter and round-trips correctly. +func TestHOTPSecretDataRoundTrip(t *testing.T) { + h := NewHOTPAuthenticator() + h.SecretKey = []byte{0xDE, 0xAD, 0xBE, 0xEF} + h.CodeDigits = 6 + h.HMACType = HMACSHA1 + h.Period = 30 + h.Counter = 42 + + encoded := h.SecretData() + var h2 HOTPAuthenticator + if err := h2.SetSecretData(encoded); err != nil { + t.Fatalf("SetSecretData(%q) error: %v", encoded, err) + } + if h2.Counter != 42 { + t.Errorf("Counter = %d, want 42", h2.Counter) + } + if string(h2.SecretKey) != string(h.SecretKey) { + t.Errorf("SecretKey mismatch") + } +} diff --git a/internal/config/yaml_test.go b/internal/config/yaml_test.go new file mode 100644 index 0000000..863310d --- /dev/null +++ b/internal/config/yaml_test.go @@ -0,0 +1,112 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadPlaintext(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + + cfg := &Config{ + Version: 1, + Language: "en", + Theme: "dark", + Entries: []Entry{ + {Name: "Google", Vendor: "google", SecretRaw: "AABB\t6\tSHA1\t30"}, + {Name: "HOTP", Vendor: "hotp", SecretRaw: "CCDD\t6\tSHA1\t30|5"}, + }, + } + if err := SaveYAML(cfg, path, nil); err != nil { + t.Fatalf("SaveYAML: %v", err) + } + + loaded, err := LoadYAML(path, nil) + if err != nil { + t.Fatalf("LoadYAML: %v", err) + } + if loaded.Version != 1 { + t.Errorf("Version = %d, want 1", loaded.Version) + } + if len(loaded.Entries) != 2 { + t.Fatalf("entries = %d, want 2", len(loaded.Entries)) + } + if loaded.Entries[0].Name != "Google" { + t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Google") + } + if loaded.Entries[1].Vendor != "hotp" { + t.Errorf("Entry[1].Vendor = %q, want %q", loaded.Entries[1].Vendor, "hotp") + } +} + +func TestSaveLoadEncrypted(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + pw := []byte("s3cr3t") + + cfg := &Config{ + Version: 1, + Encrypted: true, + Entries: []Entry{ + {Name: "Steam", Vendor: "steam", SecretRaw: "EEFF\t5\tSHA1\t30|SERIAL|DEVICE"}, + }, + } + if err := SaveYAML(cfg, path, pw); err != nil { + t.Fatalf("SaveYAML: %v", err) + } + + // Load without password → ErrPasswordRequired + if _, err := LoadYAML(path, nil); err != ErrPasswordRequired { + t.Errorf("expected ErrPasswordRequired, got %v", err) + } + + // Load with wrong password → ErrPasswordWrong + if _, err := LoadYAML(path, []byte("wrong")); err != ErrPasswordWrong { + t.Errorf("expected ErrPasswordWrong, got %v", err) + } + + // Load with correct password + loaded, err := LoadYAML(path, pw) + if err != nil { + t.Fatalf("LoadYAML: %v", err) + } + if len(loaded.Entries) != 1 { + t.Fatalf("entries = %d, want 1", len(loaded.Entries)) + } + if loaded.Entries[0].Name != "Steam" { + t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Steam") + } +} + +func TestBackupRotation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + + // First save: no .bak exists yet + cfg1 := &Config{Version: 1, Entries: []Entry{{Name: "A"}}} + if err := SaveYAML(cfg1, path, nil); err != nil { + t.Fatalf("SaveYAML #1: %v", err) + } + + // Second save: .bak should be created + cfg2 := &Config{Version: 1, Entries: []Entry{{Name: "B"}}} + if err := SaveYAML(cfg2, path, nil); err != nil { + t.Fatalf("SaveYAML #2: %v", err) + } + + bak := path + ".bak" + if _, err := os.Stat(bak); os.IsNotExist(err) { + t.Error("expected .bak file to exist after second save") + } +} + +func TestLoadNonexistent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent.yaml") + _, err := LoadYAML(path, nil) + if err == nil { + t.Error("expected error for nonexistent file") + } +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..868825c --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,91 @@ +package crypto + +import ( + "bytes" + "testing" +) + +// TestModernRoundTrip encrypts then decrypts and verifies the plaintext +// comes back unchanged for various payload sizes. +func TestModernRoundTrip(t *testing.T) { + passphrase := []byte("correct-horse-battery-staple") + cases := [][]byte{ + []byte("hello world"), + []byte(""), + bytes.Repeat([]byte("x"), 1024), + {0x00, 0x01, 0x02, 0xFF}, + } + for _, pt := range cases { + enc, err := EncryptModern(pt, passphrase) + if err != nil { + t.Fatalf("EncryptModern error: %v", err) + } + dec, err := DecryptModern(enc, passphrase) + if err != nil { + t.Fatalf("DecryptModern error: %v", err) + } + if !bytes.Equal(dec, pt) { + t.Errorf("round-trip mismatch: got %x, want %x", dec, pt) + } + } +} + +// TestModernWrongPassword verifies that decrypting with the wrong +// passphrase produces an error. +func TestModernWrongPassword(t *testing.T) { + enc, err := EncryptModern([]byte("secret data"), []byte("password1")) + if err != nil { + t.Fatalf("EncryptModern error: %v", err) + } + if _, err := DecryptModern(enc, []byte("password2")); err == nil { + t.Error("expected error for wrong password") + } +} + +// TestModernEmptyPassword verifies that an empty passphrase works +// (callers use this for unencrypted configs internally). +func TestModernEmptyPassword(t *testing.T) { + pt := []byte("some data") + enc, err := EncryptModern(pt, []byte{}) + if err != nil { + t.Fatalf("EncryptModern error: %v", err) + } + dec, err := DecryptModern(enc, []byte{}) + if err != nil { + t.Fatalf("DecryptModern error: %v", err) + } + if !bytes.Equal(dec, pt) { + t.Errorf("round-trip mismatch: got %x, want %x", dec, pt) + } +} + +// TestDecryptModernInvalidPayload verifies that malformed payloads are +// rejected cleanly. +func TestDecryptModernInvalidPayload(t *testing.T) { + cases := []string{ + "", // empty + "WAGO1", // no base64 payload + "XXXX" + "AAAA", // wrong prefix + "WAGO1AAAA", // too short after base64 decode + } + for _, c := range cases { + if _, err := DecryptModern(c, []byte("pw")); err == nil { + t.Errorf("expected error for payload %q", c) + } + } +} + +// TestDerivePBKDF2SHA1Deterministic verifies that the legacy PBKDF2-SHA1 +// key derivation is deterministic for a fixed (password, salt) pair. +func TestDerivePBKDF2SHA1Deterministic(t *testing.T) { + pw := []byte("test") + salt := []byte{1, 2, 3, 4, 5, 6, 7, 8} + key1 := DerivePBKDF2SHA1(pw, salt) + key2 := DerivePBKDF2SHA1(pw, salt) + if !bytes.Equal(key1, key2) { + t.Error("DerivePBKDF2SHA1 not deterministic") + } + if len(key1) != 32 { + t.Errorf("key length = %d, want 32", len(key1)) + } +} diff --git a/internal/qr/export_test.go b/internal/qr/export_test.go new file mode 100644 index 0000000..b35169b --- /dev/null +++ b/internal/qr/export_test.go @@ -0,0 +1,159 @@ +package qr + +import ( + "strings" + "testing" + + "git.wxccs.org/iceking2nd/winauth-go/internal/config" +) + +func TestEntryToOtpAuthGoogle(t *testing.T) { + e := config.Entry{ + Name: "MyGoogle", + Vendor: "google", + SecretRaw: "0102030405\t6\tSHA1\t30", + } + oa, partial, err := EntryToOtpAuth(e) + if err != nil { + t.Fatalf("EntryToOtpAuth: %v", err) + } + if partial { + t.Error("Google should not be partial") + } + if oa.Type != "totp" { + t.Errorf("Type = %q, want totp", oa.Type) + } + if oa.Issuer != "Google" { + t.Errorf("Issuer = %q, want Google", oa.Issuer) + } + if oa.Label != "Google:MyGoogle" { + t.Errorf("Label = %q", oa.Label) + } + if oa.Digits != 6 { + t.Errorf("Digits = %d, want 6", oa.Digits) + } + if oa.Algorithm != "SHA1" { + t.Errorf("Algorithm = %q, want SHA1", oa.Algorithm) + } +} + +func TestEntryToOtpAuthHOTP(t *testing.T) { + e := config.Entry{ + Name: "Counter", + Vendor: "hotp", + SecretRaw: "AABBCCDD\t6\tSHA1\t30|10", + } + oa, _, err := EntryToOtpAuth(e) + if err != nil { + t.Fatalf("EntryToOtpAuth: %v", err) + } + if oa.Type != "hotp" { + t.Errorf("Type = %q, want hotp", oa.Type) + } + if oa.Counter != 10 { + t.Errorf("Counter = %d, want 10", oa.Counter) + } +} + +func TestEntryToOtpAuthBattleNetPartial(t *testing.T) { + e := config.Entry{ + Name: "BNet", + Vendor: "battlenet", + SecretRaw: "AABB\t8\tSHA1\t30|53455249414C", + } + oa, partial, err := EntryToOtpAuth(e) + if err != nil { + t.Fatalf("EntryToOtpAuth: %v", err) + } + if !partial { + t.Error("Battle.Net should be partial") + } + if oa.Digits != 8 { + t.Errorf("Digits = %d, want 8", oa.Digits) + } +} + +func TestEntryToOtpAuthSteamPartial(t *testing.T) { + e := config.Entry{ + Name: "Steam", + Vendor: "steam", + SecretRaw: "CCDD\t5\tSHA1\t30|73657269616C|646576696365", + } + _, partial, err := EntryToOtpAuth(e) + if err != nil { + t.Fatalf("EntryToOtpAuth: %v", err) + } + if !partial { + t.Error("Steam should be partial") + } +} + +func TestEntryToOtpAuthEmptySecret(t *testing.T) { + e := config.Entry{Name: "Empty", Vendor: "google", SecretRaw: ""} + if _, _, err := EntryToOtpAuth(e); err == nil { + t.Error("expected error for empty secret") + } +} + +func TestURIRoundTrip(t *testing.T) { + e := config.Entry{ + Name: "Test", + Vendor: "google", + SecretRaw: "0102030405\t6\tSHA1\t30", + } + oa, _, err := EntryToOtpAuth(e) + if err != nil { + t.Fatalf("EntryToOtpAuth: %v", err) + } + uri := oa.URI() + + // Parse it back + parsed, err := ParseOtpAuth(uri) + if err != nil { + t.Fatalf("ParseOtpAuth(%q): %v", uri, err) + } + if parsed.Type != "totp" { + t.Errorf("Type = %q, want totp", parsed.Type) + } + if parsed.SecretBase32 != oa.SecretBase32 { + t.Errorf("Secret mismatch: %q vs %q", parsed.SecretBase32, oa.SecretBase32) + } +} + +func TestURIHOTP(t *testing.T) { + oa := &OtpAuth{ + Type: "hotp", + Label: "MyHOTP", + SecretBase32: "JBSWY3DPEHPK3PXP", + Issuer: "Test", + Digits: 6, + Counter: 42, + } + uri := oa.URI() + if !strings.Contains(uri, "counter=42") { + t.Errorf("URI missing counter: %s", uri) + } + if !strings.HasPrefix(uri, "otpauth://hotp/") { + t.Errorf("URI wrong prefix: %s", uri) + } +} + +func TestURIDefaultsOmitted(t *testing.T) { + oa := &OtpAuth{ + Type: "totp", + SecretBase32: "AAAA", + Algorithm: "SHA1", + Digits: 6, + Period: 30, + } + uri := oa.URI() + if strings.Contains(uri, "algorithm") { + t.Errorf("SHA1 should be omitted: %s", uri) + } + if strings.Contains(uri, "digits") { + t.Errorf("digits=6 should be omitted: %s", uri) + } + if strings.Contains(uri, "period") { + t.Errorf("period=30 should be omitted: %s", uri) + } +}