187 lines
4.9 KiB
Go
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:]
|
|
}
|