// Команда 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 <код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 <код1> <код2>. // Заполняются только те поля, которые не заданы флагами, — чтобы вызов // "hotpcalc -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 <код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 }