495 lines
16 KiB
Go
495 lines
16 KiB
Go
// Команда hotpcalc — калькулятор одноразовых паролей для токенов
|
|
// SafeNet eToken PASS (HOTP по RFC 4226 и TOTP по RFC 6238).
|
|
//
|
|
// По вектору инициализации (seed) и двум последовательным значениям с токена
|
|
// программа определяет его текущую позицию и рассчитывает следующие N значений.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hotpcalc/internal/otp"
|
|
)
|
|
|
|
// version подставляется при сборке через -ldflags "-X main.version=...".
|
|
var version = "dev"
|
|
|
|
const usage = `hotpcalc — калькулятор HOTP/TOTP для токенов SafeNet eToken PASS
|
|
|
|
Использование:
|
|
hotpcalc [флаги] [seed] [код1] [код2]
|
|
|
|
По вектору инициализации и двум ПОСЛЕДОВАТЕЛЬНЫМ значениям с токена программа
|
|
находит его текущую позицию и печатает следующие N значений.
|
|
Если обязательные данные не заданы, они запрашиваются в диалоге.
|
|
|
|
Примеры:
|
|
hotpcalc 765D7268D012090C28172A729BC9CC1AD82B382D 755224 287082
|
|
hotpcalc -n 20 -seed 765D... -otp1 755224 -otp2 287082
|
|
hotpcalc -mode totp -step 60s -seed GEZDGNBVGY3TQOJQ -otp1 123456 -otp2 654321
|
|
hotpcalc -seed 765D... -counter 100 -n 5 # без поиска, прямо со счётчика
|
|
|
|
Команды (без команды работает калькулятор):
|
|
new выпустить новый токен: seed, серийник, запись в ini и выгрузка XML
|
|
add добавить в ini уже известный токен по его seed
|
|
import загрузить токены из XML-файла инициализации
|
|
list список токенов в хранилище
|
|
show параметры и состояние токена (счётчик не двигается)
|
|
next выдать следующее значение — «нажатие кнопки»
|
|
token интерактивный режим: Enter = нажатие кнопки
|
|
sync найти позицию по двум кодам и записать её в хранилище
|
|
set изменить параметры токена
|
|
export выгрузить XML-файл инициализации
|
|
remove удалить токен из хранилища
|
|
calc явный вызов калькулятора
|
|
|
|
hotpcalc <команда> -h — справка по команде
|
|
|
|
Флаги калькулятора:
|
|
`
|
|
|
|
type options struct {
|
|
mode string
|
|
seed string
|
|
format string
|
|
otp1 string
|
|
otp2 string
|
|
n int
|
|
digits int
|
|
alg string
|
|
step time.Duration
|
|
start uint64
|
|
span uint64
|
|
window string
|
|
counter int64
|
|
at string
|
|
all bool
|
|
asJSON bool
|
|
showVers bool
|
|
|
|
args []string // исходные аргументы, нужны changedFlags
|
|
}
|
|
|
|
func main() {
|
|
err := run()
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, errHelp):
|
|
os.Exit(2)
|
|
default:
|
|
fmt.Fprintln(os.Stderr, "Ошибка: "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
// Подкоманды. Первый позиционный аргумент сверяется со списком команд;
|
|
// если он не команда, работает режим калькулятора, поэтому все прежние
|
|
// вызовы вида "hotpcalc <seed> <код1> <код2>" продолжают работать.
|
|
args := os.Args[1:]
|
|
if len(args) > 0 {
|
|
if cmd := lookupCommand(args[0]); cmd != nil {
|
|
return cmd.run(args[1:])
|
|
}
|
|
}
|
|
return runCalc(args)
|
|
}
|
|
|
|
func runCalc(args []string) error {
|
|
var o options
|
|
fs := flag.NewFlagSet("hotpcalc", flag.ContinueOnError)
|
|
fs.Usage = func() {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
fs.PrintDefaults()
|
|
}
|
|
fs.StringVar(&o.mode, "mode", "hotp", "режим: hotp или totp")
|
|
fs.StringVar(&o.seed, "seed", "", "вектор инициализации (hex, Base32 или otpauth:// URI)")
|
|
fs.StringVar(&o.format, "format", "auto", "формат seed: auto, hex, base32")
|
|
fs.StringVar(&o.otp1, "otp1", "", "первое значение с токена")
|
|
fs.StringVar(&o.otp2, "otp2", "", "второе (следующее за первым) значение с токена")
|
|
fs.IntVar(&o.n, "n", 10, "сколько следующих значений рассчитать")
|
|
fs.IntVar(&o.digits, "digits", 0, "разрядность кода: 6 или 8 (по умолчанию — по длине введённых кодов)")
|
|
fs.StringVar(&o.alg, "alg", "sha1", "алгоритм HMAC: sha1, sha256, sha512")
|
|
fs.DurationVar(&o.step, "step", otp.DefaultStep, "шаг времени для TOTP (например 30s или 60s)")
|
|
fs.Uint64Var(&o.start, "start", 0, "HOTP: начало диапазона перебора счётчика")
|
|
fs.Uint64Var(&o.span, "span", 1000000, "HOTP: размер диапазона перебора счётчика")
|
|
fs.StringVar(&o.window, "window", "90d", "TOTP: окно перебора вокруг текущего времени (30d, 12h, 90m)")
|
|
fs.Int64Var(&o.counter, "counter", -1, "считать прямо с этого счётчика/шага, без поиска по кодам")
|
|
fs.StringVar(&o.at, "at", "", "TOTP: считать текущим моментом указанное время (RFC 3339)")
|
|
fs.BoolVar(&o.all, "all", false, "вывести результат для всех найденных совпадений, а не только для первого")
|
|
fs.BoolVar(&o.asJSON, "json", false, "машиночитаемый вывод в формате JSON")
|
|
fs.BoolVar(&o.showVers, "version", false, "показать версию и выйти")
|
|
|
|
if err := fs.Parse(permute(fs, args)); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if o.showVers {
|
|
fmt.Println("hotpcalc " + version)
|
|
return nil
|
|
}
|
|
|
|
// Позиционные аргументы как сокращение: hotpcalc <seed> <код1> <код2>.
|
|
// Заполняются только те поля, которые не заданы флагами, — чтобы вызов
|
|
// "hotpcalc -seed <seed> <код1> <код2>" тоже работал.
|
|
if err := assignPositional(&o, fs.Args()); err != nil {
|
|
return err
|
|
}
|
|
o.args = args
|
|
|
|
return execute(&o)
|
|
}
|
|
|
|
func execute(o *options) error {
|
|
totpMode, err := parseMode(o.mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if o.n < 1 {
|
|
return fmt.Errorf("количество значений (-n) должно быть больше нуля")
|
|
}
|
|
|
|
in := bufio.NewReader(os.Stdin)
|
|
|
|
if o.seed == "" {
|
|
o.seed, err = prompt(in, "seed", "Введите seed (hex или Base32): ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
seed, err := otp.ParseSeed(o.seed, otp.SeedFormat(o.format))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Параметры из otpauth:// URI используются как значения по умолчанию,
|
|
// но явно заданные флаги имеют приоритет.
|
|
setFlags := changedFlags(o.args)
|
|
if seed.FromURI {
|
|
if !setFlags["mode"] {
|
|
totpMode = seed.TOTP
|
|
}
|
|
if seed.Digits != nil && !setFlags["digits"] {
|
|
o.digits = *seed.Digits
|
|
}
|
|
if seed.Alg != nil && !setFlags["alg"] {
|
|
o.alg = seed.Alg.String()
|
|
}
|
|
if seed.Step != nil && !setFlags["step"] {
|
|
o.step = *seed.Step
|
|
}
|
|
if seed.Counter != nil && !setFlags["counter"] && o.counter < 0 {
|
|
o.counter = int64(*seed.Counter)
|
|
}
|
|
}
|
|
|
|
direct := o.counter >= 0
|
|
if !direct {
|
|
if o.otp1 == "" {
|
|
if o.otp1, err = prompt(in, "первый код с токена (-otp1)", "Введите первый код с токена: "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if o.otp2 == "" {
|
|
if o.otp2, err = prompt(in, "второй код с токена (-otp2)", "Введите второй (следующий) код: "); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := validateCode(o.otp1, "первый код"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCode(o.otp2, "второй код"); err != nil {
|
|
return err
|
|
}
|
|
if len(o.otp1) != len(o.otp2) {
|
|
return fmt.Errorf("коды имеют разную длину (%d и %d) — проверьте ввод", len(o.otp1), len(o.otp2))
|
|
}
|
|
}
|
|
|
|
alg, err := otp.ParseAlgorithm(o.alg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digits := o.digits
|
|
if digits == 0 {
|
|
if direct {
|
|
digits = 6
|
|
} else {
|
|
digits = len(o.otp1)
|
|
}
|
|
}
|
|
if o.step <= 0 {
|
|
return fmt.Errorf("шаг времени (-step) должен быть положительным")
|
|
}
|
|
|
|
cfg := otp.Config{Secret: seed.Secret, Digits: digits, Alg: alg, Step: o.step}
|
|
if err := cfg.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if o.at != "" {
|
|
if !totpMode {
|
|
return fmt.Errorf("флаг -at применим только в режиме TOTP")
|
|
}
|
|
if now, err = time.Parse(time.RFC3339, o.at); err != nil {
|
|
return fmt.Errorf("не удалось разобрать -at: %w", err)
|
|
}
|
|
}
|
|
|
|
res := result{
|
|
Mode: modeName(totpMode),
|
|
Algorithm: alg.String(),
|
|
Digits: digits,
|
|
SeedBytes: len(seed.Secret),
|
|
SeedShown: otp.Fingerprint(seed.Secret),
|
|
SeedForm: string(seed.Format),
|
|
Label: seed.Label,
|
|
}
|
|
if totpMode {
|
|
res.StepSeconds = int(o.step / time.Second)
|
|
}
|
|
|
|
var matches []otp.Match
|
|
if direct {
|
|
matches = []otp.Match{{Counter: uint64(o.counter)}}
|
|
if totpMode {
|
|
matches[0].Time = cfg.StepStart(matches[0].Counter)
|
|
}
|
|
res.Direct = true
|
|
} else {
|
|
if totpMode {
|
|
window, err := parseWindow(o.window)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches, err = otp.FindTimeStep(cfg, o.otp1, o.otp2, now, window)
|
|
if err != nil {
|
|
return withHints(err, totpMode)
|
|
}
|
|
} else {
|
|
matches, err = otp.FindCounter(cfg, o.otp1, o.otp2, o.start, o.span)
|
|
if err != nil {
|
|
return withHints(err, totpMode)
|
|
}
|
|
}
|
|
res.OTP1, res.OTP2 = o.otp1, o.otp2
|
|
}
|
|
|
|
res.Ambiguous = len(matches) > 1
|
|
if !o.all && len(matches) > 1 {
|
|
matches = matches[:1]
|
|
}
|
|
|
|
for _, m := range matches {
|
|
p := position{Counter: m.Counter}
|
|
|
|
// Первое рассчитываемое значение идёт сразу за введённой парой;
|
|
// в режиме -counter — начиная с самого указанного счётчика.
|
|
first := m.Counter
|
|
if !direct {
|
|
first = m.Counter + 2
|
|
}
|
|
|
|
if totpMode {
|
|
p.ValidFrom = cfg.StepStart(m.Counter)
|
|
p.ValidTo = p.ValidFrom.Add(o.step)
|
|
if !direct {
|
|
// Расхождение часов: насколько шаг второго введённого кода
|
|
// отстоит от текущего временного шага этой машины.
|
|
driftSteps := int64(m.Counter+1) - int64(cfg.TimeStep(now))
|
|
p.DriftSteps = driftSteps
|
|
p.DriftSeconds = driftSteps * int64(o.step/time.Second)
|
|
}
|
|
}
|
|
|
|
for i := 0; i < o.n; i++ {
|
|
c := first + uint64(i)
|
|
e := entry{Index: i + 1, Counter: c, Code: cfg.HOTP(c)}
|
|
if totpMode {
|
|
e.ValidFrom = cfg.StepStart(c)
|
|
e.ValidTo = e.ValidFrom.Add(o.step)
|
|
e.Current = !e.ValidFrom.After(now) && e.ValidTo.After(now)
|
|
}
|
|
p.Codes = append(p.Codes, e)
|
|
}
|
|
res.Positions = append(res.Positions, p)
|
|
}
|
|
|
|
if o.asJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(res)
|
|
}
|
|
printText(os.Stdout, res, totpMode)
|
|
return nil
|
|
}
|
|
|
|
func parseMode(s string) (totp bool, err error) {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "hotp", "":
|
|
return false, nil
|
|
case "totp":
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("неизвестный режим %q (допустимо: hotp, totp)", s)
|
|
}
|
|
|
|
func modeName(totp bool) string {
|
|
if totp {
|
|
return "totp"
|
|
}
|
|
return "hotp"
|
|
}
|
|
|
|
// assignPositional раскладывает позиционные аргументы по незаполненным полям
|
|
// в порядке seed, код1, код2.
|
|
func assignPositional(o *options, args []string) error {
|
|
slots := []*string{&o.seed, &o.otp1, &o.otp2}
|
|
i := 0
|
|
for _, v := range args {
|
|
for i < len(slots) && *slots[i] != "" {
|
|
i++
|
|
}
|
|
if i == len(slots) {
|
|
return fmt.Errorf("лишний аргумент %q: seed и оба кода уже заданы флагами", v)
|
|
}
|
|
*slots[i] = v
|
|
i++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// permute переставляет аргументы так, чтобы флаги шли перед позиционными
|
|
// аргументами. Пакет flag прекращает разбор на первом позиционном аргументе,
|
|
// а команду удобнее вызывать в виде "hotpcalc <seed> <код1> <код2> -n 20".
|
|
func permute(fs *flag.FlagSet, args []string) []string {
|
|
flags := make([]string, 0, len(args))
|
|
rest := make([]string, 0, len(args))
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if a == "--" {
|
|
rest = append(rest, args[i+1:]...)
|
|
break
|
|
}
|
|
if len(a) < 2 || a[0] != '-' {
|
|
rest = append(rest, a)
|
|
continue
|
|
}
|
|
flags = append(flags, a)
|
|
|
|
name := strings.TrimLeft(a, "-")
|
|
if strings.ContainsRune(name, '=') {
|
|
continue // значение указано как -flag=value
|
|
}
|
|
f := fs.Lookup(name)
|
|
if f == nil {
|
|
continue // неизвестный флаг: пусть о нём сообщит сам flag.Parse
|
|
}
|
|
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
|
|
continue // булев флаг значения не требует
|
|
}
|
|
if i+1 < len(args) {
|
|
i++
|
|
flags = append(flags, args[i])
|
|
}
|
|
}
|
|
return append(flags, rest...)
|
|
}
|
|
|
|
// changedFlags возвращает множество флагов, явно заданных в командной строке.
|
|
func changedFlags(args []string) map[string]bool {
|
|
set := map[string]bool{}
|
|
for _, a := range args {
|
|
if strings.HasPrefix(a, "-") {
|
|
name := strings.TrimLeft(a, "-")
|
|
if i := strings.IndexByte(name, '='); i >= 0 {
|
|
name = name[:i]
|
|
}
|
|
set[name] = true
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func validateCode(s, what string) error {
|
|
if s == "" {
|
|
return fmt.Errorf("%s не задан", what)
|
|
}
|
|
if len(s) < 6 || len(s) > 10 {
|
|
return fmt.Errorf("%s: длина %d вне диапазона 6..10 разрядов", what, len(s))
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return fmt.Errorf("%s должен состоять только из цифр, получено %q", what, s)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseWindow разбирает длительность, дополнительно понимая суффиксы d и w.
|
|
func parseWindow(s string) (time.Duration, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, fmt.Errorf("окно поиска (-window) не задано")
|
|
}
|
|
unit := s[len(s)-1]
|
|
if unit == 'd' || unit == 'w' {
|
|
n, err := strconv.ParseFloat(s[:len(s)-1], 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("не удалось разобрать -window %q: %w", s, err)
|
|
}
|
|
mult := 24 * time.Hour
|
|
if unit == 'w' {
|
|
mult = 7 * 24 * time.Hour
|
|
}
|
|
return time.Duration(n * float64(mult)), nil
|
|
}
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("не удалось разобрать -window %q: %w", s, err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// withHints дополняет ошибку поиска подсказками, что обычно идёт не так.
|
|
func withHints(err error, totpMode bool) error {
|
|
var nf *otp.ErrNotFound
|
|
if !errors.As(err, &nf) {
|
|
return err
|
|
}
|
|
hint := "расширьте диапазон перебора флагами -start/-span"
|
|
if totpMode {
|
|
hint = "увеличьте окно поиска флагом -window (например -window 365d)"
|
|
}
|
|
return fmt.Errorf("%w.\nПроверьте: seed, разрядность (-digits), режим (-mode hotp|totp), "+
|
|
"порядок кодов (второй должен идти сразу за первым).\nИначе %s", err, hint)
|
|
}
|
|
|
|
// prompt запрашивает недостающее значение у пользователя. Приглашение идёт в
|
|
// stderr, чтобы не смешиваться с результатом при перенаправлении вывода.
|
|
func prompt(in *bufio.Reader, what, text string) (string, error) {
|
|
fmt.Fprint(os.Stderr, text)
|
|
line, err := in.ReadString('\n')
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr)
|
|
}
|
|
return "", fmt.Errorf("не задан обязательный параметр: %s. "+
|
|
"Укажите его флагом или введите в диалоге", what)
|
|
}
|
|
return line, nil
|
|
}
|