Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
// Команда 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
}