Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
// Package otp реализует алгоритмы одноразовых паролей HOTP (RFC 4226)
// и TOTP (RFC 6238) в том виде, в каком их использует SafeNet eToken PASS.
package otp
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/binary"
"fmt"
"hash"
"strings"
"time"
)
// Algorithm — хеш, на котором строится HMAC. eToken PASS использует SHA1.
type Algorithm int
const (
SHA1 Algorithm = iota
SHA256
SHA512
)
func (a Algorithm) String() string {
switch a {
case SHA256:
return "SHA256"
case SHA512:
return "SHA512"
default:
return "SHA1"
}
}
func (a Algorithm) new() func() hash.Hash {
switch a {
case SHA256:
return sha256.New
case SHA512:
return sha512.New
default:
return sha1.New
}
}
// ParseAlgorithm разбирает название алгоритма без учёта регистра.
func ParseAlgorithm(s string) (Algorithm, error) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SHA1", "SHA-1", "":
return SHA1, nil
case "SHA256", "SHA-256":
return SHA256, nil
case "SHA512", "SHA-512":
return SHA512, nil
}
return SHA1, fmt.Errorf("неизвестный алгоритм %q (допустимо: sha1, sha256, sha512)", s)
}
// DefaultStep — шаг времени TOTP по умолчанию (RFC 6238).
const DefaultStep = 30 * time.Second
// Config описывает параметры конкретного токена.
type Config struct {
Secret []byte // вектор инициализации (seed)
Digits int // разрядность кода: 6 или 8
Alg Algorithm // хеш для HMAC
Step time.Duration // шаг времени, только для TOTP
}
// Validate проверяет параметры на осмысленность.
func (c Config) Validate() error {
if len(c.Secret) == 0 {
return fmt.Errorf("пустой seed")
}
if c.Digits < 6 || c.Digits > 10 {
return fmt.Errorf("разрядность %d вне допустимого диапазона 6..10", c.Digits)
}
if c.Step <= 0 {
return fmt.Errorf("шаг времени должен быть положительным")
}
return nil
}
var pow10 = [...]uint32{1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000}
// HOTP считает одноразовый пароль для указанного значения счётчика (RFC 4226).
func (c Config) HOTP(counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(c.Alg.new(), c.Secret)
mac.Write(buf[:])
sum := mac.Sum(nil)
// Динамическое усечение: младшие 4 бита последнего байта задают смещение.
offset := sum[len(sum)-1] & 0x0F
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
digits := c.Digits
if digits < 1 || digits >= len(pow10) {
digits = 6
}
return fmt.Sprintf("%0*d", digits, code%pow10[digits])
}
// TimeStep возвращает номер временного шага T для момента t (RFC 6238).
func (c Config) TimeStep(t time.Time) uint64 {
step := c.Step
if step <= 0 {
step = DefaultStep
}
return uint64(t.Unix() / int64(step/time.Second))
}
// StepStart возвращает момент начала временного шага с номером step.
func (c Config) StepStart(step uint64) time.Time {
d := c.Step
if d <= 0 {
d = DefaultStep
}
return time.Unix(int64(step)*int64(d/time.Second), 0)
}
// TOTP считает одноразовый пароль, действующий в момент t.
func (c Config) TOTP(t time.Time) string {
return c.HOTP(c.TimeStep(t))
}
// equalCode сравнивает коды за постоянное время.
func equalCode(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+108
View File
@@ -0,0 +1,108 @@
package otp
import (
"testing"
"time"
)
// RFC 4226, Appendix D: ключ ASCII "12345678901234567890".
var rfc4226Key = []byte("12345678901234567890")
func TestHOTP_RFC4226(t *testing.T) {
want := []string{
"755224", "287082", "359152", "969429", "338314",
"254676", "287922", "162583", "399871", "520489",
}
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
for i, w := range want {
if got := c.HOTP(uint64(i)); got != w {
t.Errorf("HOTP(%d) = %s, ожидалось %s", i, got, w)
}
}
}
// RFC 6238, Appendix B. Ключи для SHA256/SHA512 — продолжение той же
// последовательности "12345678901234567890..." до размера блока хеша.
func seedRepeat(n int) []byte {
base := "12345678901234567890"
out := make([]byte, 0, n)
for len(out) < n {
out = append(out, base...)
}
return out[:n]
}
func TestTOTP_RFC6238(t *testing.T) {
cases := []struct {
unix int64
alg Algorithm
key []byte
want string
}{
{59, SHA1, seedRepeat(20), "94287082"},
{59, SHA256, seedRepeat(32), "46119246"},
{59, SHA512, seedRepeat(64), "90693936"},
{1111111109, SHA1, seedRepeat(20), "07081804"},
{1111111111, SHA1, seedRepeat(20), "14050471"},
{1234567890, SHA1, seedRepeat(20), "89005924"},
{2000000000, SHA1, seedRepeat(20), "69279037"},
{20000000000, SHA1, seedRepeat(20), "65353130"},
{1111111109, SHA256, seedRepeat(32), "68084774"},
{20000000000, SHA256, seedRepeat(32), "77737706"},
{1111111109, SHA512, seedRepeat(64), "25091201"},
{20000000000, SHA512, seedRepeat(64), "47863826"},
}
for _, tc := range cases {
c := Config{Secret: tc.key, Digits: 8, Alg: tc.alg, Step: DefaultStep}
got := c.TOTP(time.Unix(tc.unix, 0).UTC())
if got != tc.want {
t.Errorf("TOTP(%d, %s) = %s, ожидалось %s", tc.unix, tc.alg, got, tc.want)
}
}
}
func TestHOTP_LeadingZeros(t *testing.T) {
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
for i := uint64(0); i < 5000; i++ {
if got := c.HOTP(i); len(got) != 6 {
t.Fatalf("HOTP(%d) = %q: длина %d вместо 6", i, got, len(got))
}
}
}
func TestTimeStepAndStepStart(t *testing.T) {
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
if got := c.TimeStep(time.Unix(59, 0)); got != 1 {
t.Errorf("TimeStep(59) = %d, ожидалось 1", got)
}
if got := c.StepStart(1).Unix(); got != 30 {
t.Errorf("StepStart(1) = %d, ожидалось 30", got)
}
c60 := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: 60 * time.Second}
if got := c60.TimeStep(time.Unix(119, 0)); got != 1 {
t.Errorf("TimeStep(119, шаг 60с) = %d, ожидалось 1", got)
}
}
func TestValidate(t *testing.T) {
if err := (Config{Digits: 6, Step: DefaultStep}).Validate(); err == nil {
t.Error("пустой seed должен отвергаться")
}
if err := (Config{Secret: rfc4226Key, Digits: 4, Step: DefaultStep}).Validate(); err == nil {
t.Error("разрядность 4 должна отвергаться")
}
if err := (Config{Secret: rfc4226Key, Digits: 6}).Validate(); err == nil {
t.Error("нулевой шаг должен отвергаться")
}
}
func TestParseAlgorithm(t *testing.T) {
for _, s := range []string{"sha1", "SHA-1", ""} {
if a, err := ParseAlgorithm(s); err != nil || a != SHA1 {
t.Errorf("ParseAlgorithm(%q) = %v, %v", s, a, err)
}
}
if _, err := ParseAlgorithm("md5"); err == nil {
t.Error("md5 должен отвергаться")
}
}
+87
View File
@@ -0,0 +1,87 @@
package otp
import (
"errors"
"fmt"
"math"
"time"
)
// Match — найденная позиция токена. Counter соответствует ПЕРВОМУ из двух
// введённых кодов; второй код приходится на Counter+1.
type Match struct {
Counter uint64
Time time.Time // начало окна первого кода; заполняется только для TOTP
}
// ErrNotFound возвращается, когда пара кодов не найдена в заданном диапазоне.
type ErrNotFound struct {
From, To uint64
Mode string
}
func (e *ErrNotFound) Error() string {
return fmt.Sprintf("пара кодов не найдена в диапазоне %s %d..%d", e.Mode, e.From, e.To)
}
// FindCounter ищет значение счётчика HOTP, при котором два введённых кода
// идут подряд. Перебираются счётчики [start, start+span).
func FindCounter(c Config, otp1, otp2 string, start, span uint64) ([]Match, error) {
if err := c.Validate(); err != nil {
return nil, err
}
if span < 2 {
return nil, fmt.Errorf("диапазон перебора должен быть не меньше 2")
}
end := start + span - 1 // включительно; защита от переполнения ниже
if end < start {
end = math.MaxUint64
}
var matches []Match
prev := c.HOTP(start)
for i := start; i < end; i++ {
cur := c.HOTP(i + 1)
if equalCode(prev, otp1) && equalCode(cur, otp2) {
matches = append(matches, Match{Counter: i})
}
prev = cur
}
if len(matches) == 0 {
return nil, &ErrNotFound{From: start, To: end, Mode: "счётчика"}
}
return matches, nil
}
// FindTimeStep ищет временной шаг TOTP, при котором два введённых кода идут
// подряд. Перебор идёт вокруг момента now в пределах ±window.
func FindTimeStep(c Config, otp1, otp2 string, now time.Time, window time.Duration) ([]Match, error) {
if err := c.Validate(); err != nil {
return nil, err
}
if window < 0 {
return nil, fmt.Errorf("окно поиска не может быть отрицательным")
}
center := c.TimeStep(now)
half := uint64(window / c.Step)
start := uint64(0)
if center > half {
start = center - half
}
span := half*2 + 2 // +2, чтобы центр и обе границы попали в пары
matches, err := FindCounter(c, otp1, otp2, start, span)
if err != nil {
var nf *ErrNotFound
if errors.As(err, &nf) {
nf.Mode = "временного шага"
}
return nil, err
}
for i := range matches {
matches[i].Time = c.StepStart(matches[i].Counter)
}
return matches, nil
}
+139
View File
@@ -0,0 +1,139 @@
package otp
import (
"errors"
"testing"
"time"
)
func exampleConfig(t *testing.T) Config {
t.Helper()
s, err := ParseSeed(exampleSeed, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
return Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep}
}
func TestFindCounter_RoundTrip(t *testing.T) {
c := exampleConfig(t)
const want = uint64(41)
m, err := FindCounter(c, c.HOTP(want), c.HOTP(want+1), 0, 100000)
if err != nil {
t.Fatalf("FindCounter: %v", err)
}
if len(m) != 1 {
t.Fatalf("найдено %d совпадений, ожидалось 1: %+v", len(m), m)
}
if m[0].Counter != want {
t.Errorf("счётчик = %d, ожидалось %d", m[0].Counter, want)
}
}
func TestFindCounter_RFCVectors(t *testing.T) {
// Пара 287082/359152 из RFC 4226 — это счётчики 1 и 2.
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
m, err := FindCounter(c, "287082", "359152", 0, 1000)
if err != nil {
t.Fatalf("FindCounter: %v", err)
}
if m[0].Counter != 1 {
t.Errorf("счётчик = %d, ожидалось 1", m[0].Counter)
}
}
func TestFindCounter_NotFound(t *testing.T) {
c := exampleConfig(t)
_, err := FindCounter(c, "000000", "000000", 0, 1000)
var nf *ErrNotFound
if !errors.As(err, &nf) {
t.Fatalf("ожидалась ErrNotFound, получено %v", err)
}
}
func TestFindCounter_OutOfRange(t *testing.T) {
c := exampleConfig(t)
// Позиция 500 лежит вне диапазона [0, 100).
if _, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 0, 100); err == nil {
t.Error("ожидалась ошибка: позиция вне диапазона перебора")
}
// С правильным смещением находится.
m, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 450, 100)
if err != nil {
t.Fatalf("FindCounter со смещением: %v", err)
}
if m[0].Counter != 500 {
t.Errorf("счётчик = %d, ожидалось 500", m[0].Counter)
}
}
func TestFindCounter_SpanTooSmall(t *testing.T) {
c := exampleConfig(t)
if _, err := FindCounter(c, "123456", "654321", 0, 1); err == nil {
t.Error("span < 2 должен отвергаться")
}
}
func TestFindCounter_ReversedOrderNotMatched(t *testing.T) {
c := exampleConfig(t)
// Коды, поданные в обратном порядке, не должны находиться.
if _, err := FindCounter(c, c.HOTP(11), c.HOTP(10), 0, 10000); err == nil {
t.Error("перепутанный порядок кодов не должен давать совпадение")
}
}
func TestFindTimeStep_RoundTrip(t *testing.T) {
c := exampleConfig(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Токен «спешит» на 2 минуты: коды сняты на 4 шага вперёд.
step := c.TimeStep(now) + 4
m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Hour)
if err != nil {
t.Fatalf("FindTimeStep: %v", err)
}
if len(m) != 1 {
t.Fatalf("найдено %d совпадений, ожидалось 1", len(m))
}
if m[0].Counter != step {
t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step)
}
if want := c.StepStart(step); !m[0].Time.Equal(want) {
t.Errorf("время = %v, ожидалось %v", m[0].Time, want)
}
}
func TestFindTimeStep_Step60(t *testing.T) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: 60 * time.Second}
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
step := c.TimeStep(now) - 10
m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, 24*time.Hour)
if err != nil {
t.Fatalf("FindTimeStep: %v", err)
}
if m[0].Counter != step {
t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step)
}
}
func TestFindTimeStep_OutsideWindow(t *testing.T) {
c := exampleConfig(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
step := c.TimeStep(now) + 10000
if _, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Minute); err == nil {
t.Error("ожидалась ошибка: позиция вне окна поиска")
}
}
func BenchmarkFindCounter(b *testing.B) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep}
o1, o2 := c.HOTP(999998), c.HOTP(999999)
for b.Loop() {
if _, err := FindCounter(c, o1, o2, 0, 1000000); err != nil {
b.Fatal(err)
}
}
}
+186
View File
@@ -0,0 +1,186 @@
package otp
import (
"encoding/base32"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
// SeedFormat — способ кодирования вектора инициализации.
type SeedFormat string
const (
FormatAuto SeedFormat = "auto"
FormatHex SeedFormat = "hex"
FormatBase32 SeedFormat = "base32"
)
// Seed — разобранный вектор инициализации вместе с параметрами,
// которые удалось извлечь из otpauth:// URI (если он был на входе).
type Seed struct {
Secret []byte
Format SeedFormat // фактически распознанный формат
// Параметры из otpauth:// URI; nil, если не заданы.
Digits *int
Alg *Algorithm
Step *time.Duration
Counter *uint64
Label string
TOTP bool // true, если URI описывает totp
FromURI bool
}
// cleanSeed убирает разделители, которыми seed обычно печатают на бумаге.
func cleanSeed(s string) string {
return strings.Map(func(r rune) rune {
switch r {
case ' ', '\t', '\n', '\r', '-', ':', '_', '.':
return -1
}
return r
}, s)
}
func isHexString(s string) bool {
if s == "" || len(s)%2 != 0 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
func decodeBase32(s string) ([]byte, error) {
s = strings.ToUpper(strings.TrimRight(s, "="))
if s == "" {
return nil, fmt.Errorf("пустая строка")
}
if pad := len(s) % 8; pad != 0 {
s += strings.Repeat("=", 8-pad)
}
return base32.StdEncoding.DecodeString(s)
}
// ParseSeed разбирает вектор инициализации в hex, Base32 или otpauth:// URI.
func ParseSeed(s string, format SeedFormat) (Seed, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return Seed{}, fmt.Errorf("seed не задан")
}
if strings.HasPrefix(strings.ToLower(raw), "otpauth://") {
return parseOtpauth(raw)
}
clean := cleanSeed(raw)
if clean == "" {
return Seed{}, fmt.Errorf("seed не содержит значащих символов")
}
switch format {
case FormatHex:
b, err := hex.DecodeString(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не является корректным hex: %w", err)
}
return Seed{Secret: b, Format: FormatHex}, nil
case FormatBase32:
b, err := decodeBase32(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не является корректным Base32: %w", err)
}
return Seed{Secret: b, Format: FormatBase32}, nil
case FormatAuto, "":
if isHexString(clean) {
b, err := hex.DecodeString(clean)
if err == nil {
return Seed{Secret: b, Format: FormatHex}, nil
}
}
b, err := decodeBase32(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не распознан ни как hex, ни как Base32: %w", err)
}
return Seed{Secret: b, Format: FormatBase32}, nil
}
return Seed{}, fmt.Errorf("неизвестный формат seed %q (допустимо: auto, hex, base32)", format)
}
func parseOtpauth(raw string) (Seed, error) {
u, err := url.Parse(raw)
if err != nil {
return Seed{}, fmt.Errorf("не удалось разобрать otpauth URI: %w", err)
}
q := u.Query()
secret := q.Get("secret")
if secret == "" {
return Seed{}, fmt.Errorf("в otpauth URI нет параметра secret")
}
b, err := decodeBase32(cleanSeed(secret))
if err != nil {
return Seed{}, fmt.Errorf("secret из otpauth URI не является корректным Base32: %w", err)
}
sd := Seed{
Secret: b,
Format: FormatBase32,
Label: strings.TrimPrefix(u.Path, "/"),
TOTP: strings.EqualFold(u.Host, "totp"),
FromURI: true,
}
if v := q.Get("digits"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return Seed{}, fmt.Errorf("некорректный digits в otpauth URI: %w", err)
}
sd.Digits = &n
}
if v := q.Get("algorithm"); v != "" {
a, err := ParseAlgorithm(v)
if err != nil {
return Seed{}, err
}
sd.Alg = &a
}
if v := q.Get("period"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return Seed{}, fmt.Errorf("некорректный period в otpauth URI: %q", v)
}
d := time.Duration(n) * time.Second
sd.Step = &d
}
if v := q.Get("counter"); v != "" {
n, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return Seed{}, fmt.Errorf("некорректный counter в otpauth URI: %w", err)
}
sd.Counter = &n
}
return sd, nil
}
// Fingerprint возвращает укороченное представление seed для вывода на экран,
// чтобы не печатать секрет целиком.
func Fingerprint(secret []byte) string {
h := strings.ToUpper(hex.EncodeToString(secret))
if len(h) <= 12 {
return h
}
return h[:8] + "…" + h[len(h)-4:]
}
+117
View File
@@ -0,0 +1,117 @@
package otp
import (
"encoding/hex"
"strings"
"testing"
"time"
)
// Seed из примера задачи: eToken PASS, 20 байт в hex.
const exampleSeed = "765D7268D012090C28172A729BC9CC1AD82B382D"
func TestParseSeed_Hex(t *testing.T) {
for _, in := range []string{
exampleSeed,
strings.ToLower(exampleSeed),
"765D 7268 D012 090C 2817 2A72 9BC9 CC1A D82B 382D",
"76-5D-72-68-D0-12-09-0C-28-17-2A-72-9B-C9-CC-1A-D8-2B-38-2D",
} {
s, err := ParseSeed(in, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed(%q): %v", in, err)
}
if s.Format != FormatHex {
t.Errorf("ParseSeed(%q): формат %s, ожидался hex", in, s.Format)
}
if got := strings.ToUpper(hex.EncodeToString(s.Secret)); got != exampleSeed {
t.Errorf("ParseSeed(%q) = %s", in, got)
}
}
}
func TestParseSeed_Base32(t *testing.T) {
// Base32 от ASCII-ключа "12345678901234567890" из RFC 4226.
const b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
s, err := ParseSeed(b32, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
if s.Format != FormatBase32 {
t.Errorf("формат %s, ожидался base32", s.Format)
}
if string(s.Secret) != string(rfc4226Key) {
t.Errorf("secret = %q, ожидалось %q", s.Secret, rfc4226Key)
}
// Без padding и в нижнем регистре тоже должно работать.
if s2, err := ParseSeed("gezdgnbvgy3tqojq", FormatAuto); err != nil || len(s2.Secret) != 10 {
t.Errorf("Base32 без padding: %v, %d байт", err, len(s2.Secret))
}
}
func TestParseSeed_ForcedFormat(t *testing.T) {
// "12345678" — валидный hex и валидный набор символов не для Base32.
if _, err := ParseSeed(exampleSeed, FormatBase32); err == nil {
t.Error("hex-строка с цифрами 0/1 не должна разбираться как Base32")
}
if s, err := ParseSeed(exampleSeed, FormatHex); err != nil || len(s.Secret) != 20 {
t.Errorf("принудительный hex: %v", err)
}
}
func TestParseSeed_Errors(t *testing.T) {
for _, in := range []string{"", " ", "zzzz!!!", "765D7268D"} {
if _, err := ParseSeed(in, FormatAuto); err == nil {
t.Errorf("ParseSeed(%q) должен возвращать ошибку", in)
}
}
}
func TestParseSeed_OtpauthURI(t *testing.T) {
uri := "otpauth://totp/ACME:user@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" +
"&issuer=ACME&digits=8&algorithm=SHA256&period=60"
s, err := ParseSeed(uri, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
if !s.FromURI || !s.TOTP {
t.Error("URI должен распознаваться как TOTP")
}
if s.Digits == nil || *s.Digits != 8 {
t.Error("digits из URI не подхвачен")
}
if s.Alg == nil || *s.Alg != SHA256 {
t.Error("algorithm из URI не подхвачен")
}
if s.Step == nil || *s.Step != 60*time.Second {
t.Error("period из URI не подхвачен")
}
if s.Label != "ACME:user@example.com" {
t.Errorf("label = %q", s.Label)
}
hotpURI := "otpauth://hotp/tok?secret=GEZDGNBVGY3TQOJQ&counter=42"
h, err := ParseSeed(hotpURI, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed(hotp): %v", err)
}
if h.TOTP {
t.Error("hotp URI не должен помечаться как TOTP")
}
if h.Counter == nil || *h.Counter != 42 {
t.Error("counter из URI не подхвачен")
}
if _, err := ParseSeed("otpauth://totp/x?issuer=ACME", FormatAuto); err == nil {
t.Error("URI без secret должен отвергаться")
}
}
func TestFingerprint(t *testing.T) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
if got := Fingerprint(s.Secret); got != "765D7268…382D" {
t.Errorf("Fingerprint = %q", got)
}
if got := Fingerprint([]byte{0x01, 0x02}); got != "0102" {
t.Errorf("короткий Fingerprint = %q", got)
}
}