Initial commit: hotpcalc CLI
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 должен отвергаться")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user