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