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