Initial commit: hotpcalc CLI
This commit is contained in:
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user