Files
hotp_calc/internal/otp/seed.go
T

187 lines
4.9 KiB
Go

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:]
}