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
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"bufio"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
// Тесты запускают саму команду как подпроцесс: тестовый бинарник, увидев
// HOTPCALC_TEST_EXEC, работает как hotpcalc. Это даёт настоящую проверку
// разбора аргументов, вывода, кодов возврата и межпроцессной блокировки.
func TestMain(m *testing.M) {
if os.Getenv("HOTPCALC_TEST_EXEC") == "1" {
os.Args = append([]string{"hotpcalc"}, os.Args[1:]...)
main()
return
}
os.Exit(m.Run())
}
type runResult struct {
stdout, stderr string
code int
}
func runCLI(t *testing.T, args ...string) runResult {
t.Helper()
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1", "HOTPCALC_STORE=")
var out, errBuf strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errBuf
err := cmd.Run()
res := runResult{stdout: out.String(), stderr: errBuf.String()}
if err != nil {
var ee *exec.ExitError
if !asExitError(err, &ee) {
t.Fatalf("запуск %v: %v", args, err)
}
res.code = ee.ExitCode()
}
return res
}
func asExitError(err error, target **exec.ExitError) bool {
e, ok := err.(*exec.ExitError)
if ok {
*target = e
}
return ok
}
func mustRun(t *testing.T, args ...string) runResult {
t.Helper()
r := runCLI(t, args...)
if r.code != 0 {
t.Fatalf("%v завершилась с кодом %d\nstdout: %s\nstderr: %s", args, r.code, r.stdout, r.stderr)
}
return r
}
func storePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "tokens.ini")
}
// newToken выпускает токен и возвращает его серийник и seed.
func newToken(t *testing.T, store string, extra ...string) (serial, seed string) {
t.Helper()
args := append([]string{"new", "-store", store, "-json"}, extra...)
r := mustRun(t, args...)
var tokens []struct {
Serial string `json:"serial"`
Seed string `json:"seed"`
}
if err := json.Unmarshal([]byte(r.stdout), &tokens); err != nil {
t.Fatalf("разбор JSON: %v\n%s", err, r.stdout)
}
if len(tokens) != 1 {
t.Fatalf("ожидался один токен, получено %d", len(tokens))
}
return tokens[0].Serial, tokens[0].Seed
}
func TestNewNextMatchesCalculator(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st)
if len(seed) != 40 {
t.Errorf("seed = %q, ожидалось 40 hex-символов", seed)
}
// Три «нажатия кнопки».
var pressed []string
for i := 0; i < 3; i++ {
r := mustRun(t, "next", serial, "-store", st)
pressed = append(pressed, strings.TrimSpace(r.stdout))
}
// Независимая сверка: калькулятор от того же seed со счётчика 0.
r := mustRun(t, "-seed", seed, "-counter", "0", "-n", "3", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
if err := json.Unmarshal([]byte(r.stdout), &calc); err != nil {
t.Fatalf("разбор JSON калькулятора: %v\n%s", err, r.stdout)
}
for i, c := range calc.Positions[0].Codes {
if pressed[i] != c.Code {
t.Errorf("нажатие %d: токен выдал %s, калькулятор — %s", i+1, pressed[i], c.Code)
}
}
// Счётчик доехал до 3 и служебная строка ушла в stderr, а не в stdout.
r = mustRun(t, "show", serial, "-store", st, "-json")
var shown struct {
Counter uint64 `json:"counter"`
}
json.Unmarshal([]byte(r.stdout), &shown)
if shown.Counter != 3 {
t.Errorf("счётчик = %d, ожидалось 3", shown.Counter)
}
}
func TestShowDoesNotAdvance(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
mustRun(t, "next", serial, "-store", st)
before := counterOf(t, st, serial)
mustRun(t, "show", serial, "-store", st, "-peek", "5")
if after := counterOf(t, st, serial); after != before {
t.Errorf("show сдвинул счётчик: %d → %d", before, after)
}
}
func counterOf(t *testing.T, store, serial string) uint64 {
t.Helper()
r := mustRun(t, "show", serial, "-store", store, "-json")
var v struct {
Counter uint64 `json:"counter"`
}
if err := json.Unmarshal([]byte(r.stdout), &v); err != nil {
t.Fatalf("разбор JSON: %v\n%s", err, r.stdout)
}
return v.Counter
}
func TestNextOutputIsScriptable(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
r := mustRun(t, "next", serial, "-store", st)
code := strings.TrimSpace(r.stdout)
if len(code) != 6 {
t.Errorf("stdout должен содержать только код, получено %q", r.stdout)
}
if _, err := strconv.Atoi(code); err != nil {
t.Errorf("код %q не число", code)
}
if !strings.Contains(r.stderr, "счётчик 0 → 1") {
t.Errorf("служебная строка не попала в stderr: %q", r.stderr)
}
}
func TestSyncSetsCounter(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st)
// Коды счётчиков 10 и 11, полученные независимо через калькулятор.
r := mustRun(t, "-seed", seed, "-counter", "10", "-n", "2", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(r.stdout), &calc)
c1 := calc.Positions[0].Codes[0].Code
c2 := calc.Positions[0].Codes[1].Code
mustRun(t, "sync", serial, c1, c2, "-store", st)
if got := counterOf(t, st, serial); got != 12 {
t.Errorf("после sync счётчик = %d, ожидалось 12", got)
}
// Следующее значение продолжает ту же последовательность.
r = mustRun(t, "next", serial, "-store", st)
expect := mustRun(t, "-seed", seed, "-counter", "12", "-n", "1", "-json")
var one struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(expect.stdout), &one)
if strings.TrimSpace(r.stdout) != one.Positions[0].Codes[0].Code {
t.Errorf("после sync выдан %q, ожидалось %q",
strings.TrimSpace(r.stdout), one.Positions[0].Codes[0].Code)
}
}
func TestExportImportRoundTrip(t *testing.T) {
st := storePath(t)
xml := filepath.Join(filepath.Dir(st), "init.xml")
serial, seed := newToken(t, st, "-xml", xml)
data, err := os.ReadFile(xml)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "<Seed>"+seed+"</Seed>") {
t.Errorf("seed не найден в XML:\n%s", data)
}
other := filepath.Join(filepath.Dir(st), "other.ini")
mustRun(t, "import", xml, "-store", other)
r := mustRun(t, "list", "-store", other, "-json", "-show-seed")
var tokens []struct {
Serial string `json:"serial"`
Seed string `json:"seed"`
}
json.Unmarshal([]byte(r.stdout), &tokens)
if len(tokens) != 1 || tokens[0].Serial != serial || tokens[0].Seed != seed {
t.Errorf("импорт вернул %+v, ожидался %s / %s", tokens, serial, seed)
}
// Повторный импорт без -force ничего не меняет.
r = mustRun(t, "import", xml, "-store", other)
if !strings.Contains(r.stderr, "пропущены") {
t.Errorf("повторный импорт должен предупреждать о пропуске: %q", r.stderr)
}
}
func TestExportWarnsAboutUnsupportedParameters(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st, "-digits", "8")
r := mustRun(t, "export", serial, "-store", st)
if !strings.Contains(r.stderr, "разрядность") {
t.Errorf("ожидалось предупреждение о разрядности, получено: %q", r.stderr)
}
if !strings.Contains(r.stdout, "<Tokens>") {
t.Errorf("XML не выведен в stdout:\n%s", r.stdout)
}
}
func TestTOTPTokenLifecycle(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st, "-type", "totp", "-period", "60s")
r := mustRun(t, "next", serial, "-store", st)
code := strings.TrimSpace(r.stdout)
if !strings.Contains(r.stderr, "шаг ") {
t.Errorf("для TOTP ожидалась строка про шаг: %q", r.stderr)
}
// Токен записал выданный временной шаг.
var shown struct {
LastStep uint64 `json:"last_step"`
Period int `json:"period_seconds"`
}
json.Unmarshal([]byte(mustRun(t, "show", serial, "-store", st, "-json").stdout), &shown)
if shown.LastStep == 0 || shown.Period != 60 {
t.Fatalf("состояние TOTP записано неверно: %+v", shown)
}
// Тот же шаг, посчитанный калькулятором от того же seed, даёт тот же код.
r = mustRun(t, "-mode", "totp", "-step", "60s", "-seed", seed,
"-counter", strconv.FormatUint(shown.LastStep, 10), "-n", "1", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(r.stdout), &calc)
if got := calc.Positions[0].Codes[0].Code; got != code {
t.Errorf("токен выдал %s, калькулятор на шаге %d — %s", code, shown.LastStep, got)
}
}
func TestConcurrentNextGivesDistinctCounters(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
const n = 8
codes := make([]string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
r := runCLI(t, "next", serial, "-store", st)
if r.code == 0 {
codes[i] = strings.TrimSpace(r.stdout)
}
}(i)
}
wg.Wait()
seen := map[string]bool{}
got := 0
for _, c := range codes {
if c == "" {
continue
}
if seen[c] {
t.Errorf("код %s выдан дважды — блокировка не сработала", c)
}
seen[c] = true
got++
}
if got != n {
t.Fatalf("успешных нажатий %d из %d — блокировка не пропустила все", got, n)
}
if c := counterOf(t, st, serial); c != n {
t.Errorf("счётчик = %d, ожидалось %d", c, n)
}
}
func TestUnknownTokenAndBadArgs(t *testing.T) {
st := storePath(t)
newToken(t, st)
if r := runCLI(t, "next", "НЕТ-ТАКОГО", "-store", st); r.code == 0 {
t.Error("несуществующий токен должен давать ненулевой код возврата")
}
if r := runCLI(t, "next", "-store", st); r.code == 0 {
t.Error("вызов без серийника должен давать ошибку")
}
if r := runCLI(t, "set", "AAA", "-store", st); r.code == 0 {
t.Error("set без изменений должен давать ошибку")
}
}
func TestCalculatorStillWorks(t *testing.T) {
// Регрессия: прежние вызовы не должны сломаться подкомандами.
const seed = "765D7268D012090C28172A729BC9CC1AD82B382D"
r := mustRun(t, seed, "941595", "057938", "-n", "2")
if !strings.Contains(r.stdout, "счётчик 41") {
t.Errorf("калькулятор сломан:\n%s", r.stdout)
}
r = mustRun(t, "calc", "-seed", seed, "-counter", "41", "-n", "1")
if !strings.Contains(r.stdout, "941595") {
t.Errorf("явный вызов calc сломан:\n%s", r.stdout)
}
}
func TestListEmptyStore(t *testing.T) {
st := storePath(t)
r := mustRun(t, "list", "-store", st)
if !strings.Contains(r.stdout, "пусто") {
t.Errorf("ожидалось сообщение о пустом хранилище:\n%s", r.stdout)
}
r = mustRun(t, "list", "-store", st, "-json")
if strings.TrimSpace(r.stdout) != "[]" {
t.Errorf("для пустого хранилища JSON должен быть [], получено %q", r.stdout)
}
}
func TestTokenInteractive(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
cmd := exec.Command(os.Args[0], "token", serial, "-store", st)
cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1")
cmd.Stdin = strings.NewReader("\n\nq\n")
out, err := cmd.Output()
if err != nil {
t.Fatalf("token: %v", err)
}
var codes int
sc := bufio.NewScanner(strings.NewReader(string(out)))
for sc.Scan() {
if strings.Contains(sc.Text(), "(счётчик ") {
codes++
}
}
if codes != 2 {
t.Errorf("выдано %d значений, ожидалось 2:\n%s", codes, out)
}
if c := counterOf(t, st, serial); c != 2 {
t.Errorf("счётчик = %d, ожидалось 2", c)
}
}
+889
View File
@@ -0,0 +1,889 @@
package main
import (
"bufio"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"hotpcalc/internal/initfile"
"hotpcalc/internal/otp"
"hotpcalc/internal/store"
)
// tokenJSON — представление токена для машиночитаемого вывода.
type tokenJSON struct {
Serial string `json:"serial"`
Type string `json:"type"`
Algorithm string `json:"algorithm"`
Digits int `json:"digits"`
Period int `json:"period_seconds,omitempty"`
Counter uint64 `json:"counter"`
LastStep uint64 `json:"last_step,omitempty"`
Label string `json:"label,omitempty"`
Seed string `json:"seed,omitempty"`
Created string `json:"created,omitempty"`
LastUsed string `json:"last_used,omitempty"`
Codes []string `json:"codes,omitempty"`
}
func toJSON(t store.Token, withSeed bool) tokenJSON {
j := tokenJSON{
Serial: t.Serial, Type: t.Type, Algorithm: t.Alg.String(), Digits: t.Digits,
Counter: t.Counter, LastStep: t.LastStep, Label: t.Label,
}
if t.IsTOTP() {
j.Period = int(t.Config().Step / time.Second)
}
if withSeed {
j.Seed = strings.ToUpper(hex.EncodeToString(t.Secret))
}
if !t.Created.IsZero() {
j.Created = t.Created.Format(time.RFC3339)
}
if !t.LastUsed.IsZero() {
j.LastUsed = t.LastUsed.Format(time.RFC3339)
}
return j
}
func printJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// ---------------------------------------------------------------- new / add
type issueOptions struct {
storePath string
serial string
prefix string
label string
typ string
digits int
alg string
period time.Duration
counter uint64
seed string
format string
count int
xmlPath string
showSeed bool
asJSON bool
force bool
}
func issueFlags(fs *flag.FlagSet, o *issueOptions, withSeed bool) {
storeFlag(fs, &o.storePath)
fs.StringVar(&o.serial, "serial", "", "серийный номер (по умолчанию генерируется)")
fs.StringVar(&o.prefix, "serial-prefix", store.DefaultSerialPrefix, "префикс генерируемого серийного номера")
fs.StringVar(&o.label, "label", "", "метка: владелец или назначение токена")
fs.StringVar(&o.typ, "type", store.TypeHOTP, "тип токена: hotp или totp")
fs.IntVar(&o.digits, "digits", 6, "разрядность кода: 6 или 8")
fs.StringVar(&o.alg, "alg", "sha1", "алгоритм HMAC: sha1, sha256, sha512")
fs.DurationVar(&o.period, "period", otp.DefaultStep, "шаг времени для TOTP")
fs.Uint64Var(&o.counter, "counter", 0, "начальное значение счётчика")
fs.StringVar(&o.xmlPath, "xml", "", "записать XML-файл инициализации по этому пути")
fs.BoolVar(&o.showSeed, "show-seed", false, "показать seed полностью, а не сокращённо")
fs.BoolVar(&o.asJSON, "json", false, "машиночитаемый вывод (включает seed целиком)")
fs.BoolVar(&o.force, "force", false, "перезаписать токен, если серийный номер уже занят")
if withSeed {
fs.StringVar(&o.seed, "seed", "", "вектор инициализации (hex, Base32 или otpauth:// URI)")
fs.StringVar(&o.format, "format", "auto", "формат seed: auto, hex, base32")
} else {
fs.IntVar(&o.count, "count", 1, "сколько токенов выпустить за раз")
}
}
func cmdNew(args []string) error {
fs := newFlagSet("new")
var o issueOptions
issueFlags(fs, &o, false)
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 0, 0, ""); err != nil {
return err
}
if o.count < 1 {
return fmt.Errorf("количество токенов (-count) должно быть больше нуля")
}
if o.count > 1 && o.serial != "" {
return fmt.Errorf("флаг -serial несовместим с -count больше 1: серийники генерируются")
}
return issue(&o, nil)
}
func cmdAdd(args []string) error {
fs := newFlagSet("add")
var o issueOptions
issueFlags(fs, &o, true)
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 0, 1, "seed добавляемого токена"); err != nil {
return err
}
if o.seed == "" && fs.NArg() == 1 {
o.seed = fs.Arg(0)
}
if o.seed == "" {
fs.Usage()
return fmt.Errorf("не задан seed добавляемого токена")
}
o.count = 1
parsed, err := otp.ParseSeed(o.seed, otp.SeedFormat(o.format))
if err != nil {
return err
}
return issue(&o, parsed.Secret)
}
// issue создаёт токены, сохраняет их в хранилище и при необходимости
// выгружает XML-файл инициализации. secret != nil — использовать готовый seed.
func issue(o *issueOptions, secret []byte) error {
alg, err := otp.ParseAlgorithm(o.alg)
if err != nil {
return err
}
if o.typ != store.TypeHOTP && o.typ != store.TypeTOTP {
return fmt.Errorf("неизвестный тип токена %q (допустимо: hotp, totp)", o.typ)
}
s, err := openStore(o.storePath)
if err != nil {
return err
}
defer s.Close()
created := make([]store.Token, 0, o.count)
for i := 0; i < o.count; i++ {
t := store.Token{
Type: o.typ, Digits: o.digits, Alg: alg, Counter: o.counter,
Label: o.label, Created: time.Now().Truncate(time.Second),
}
if t.IsTOTP() {
t.Period = o.period
}
if secret != nil {
t.Secret = secret
} else if t.Secret, err = store.NewSeed(store.SeedBytes); err != nil {
return err
}
if o.serial != "" {
t.Serial = strings.ToUpper(o.serial)
if _, exists, _ := s.Get(t.Serial); exists && !o.force {
return fmt.Errorf("токен %s уже есть в хранилище; используйте -force для перезаписи "+
"или set для правки", t.Serial)
}
} else if t.Serial, err = s.NewUniqueSerial(o.prefix); err != nil {
return err
}
if err := s.Put(t); err != nil {
return err
}
created = append(created, t)
}
if err := s.Commit(); err != nil {
return err
}
if o.xmlPath != "" {
if err := writeInitFile(o.xmlPath, created, initfile.Options{}); err != nil {
return err
}
}
if o.asJSON {
out := make([]tokenJSON, len(created))
for i, t := range created {
out[i] = toJSON(t, true)
}
return printJSON(out)
}
fmt.Printf("Выпущено токенов: %d\n", len(created))
for _, t := range created {
printTokenDetails(os.Stdout, t, o.showSeed)
}
fmt.Printf("\nХранилище: %s\n", s.Path())
if o.xmlPath != "" {
fmt.Printf("XML: %s\n", o.xmlPath)
}
printIssueWarnings(created, o.xmlPath)
return nil
}
func printIssueWarnings(tokens []store.Token, xmlPath string) {
shown := map[string]bool{}
for _, t := range tokens {
for _, w := range initfile.Warnings(t) {
if !shown[w] {
warn("%s", w)
shown[w] = true
}
}
}
if xmlPath != "" {
warn("файл %s содержит seed в открытом виде — удалите его после импорта на сервер", xmlPath)
}
}
// writeInitFile записывает XML-файл инициализации с правами 0600.
func writeInitFile(path string, tokens []store.Token, opts initfile.Options) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("не удалось создать %s: %w", path, err)
}
defer f.Close()
if err := initfile.WriteSafeNet(f, tokens, opts); err != nil {
return err
}
return f.Close()
}
// ------------------------------------------------------------------ import
func cmdImport(args []string) error {
fs := newFlagSet("import")
var storePath string
var force, asJSON bool
storeFlag(fs, &storePath)
fs.BoolVar(&force, "force", false, "перезаписывать токены, которые уже есть в хранилище")
fs.BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, 1, "путь к XML-файлу"); err != nil {
return err
}
path := fs.Arg(0)
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("не удалось открыть %s: %w", path, err)
}
defer f.Close()
tokens, err := initfile.ReadSafeNet(f)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
var added, skipped []string
for _, t := range tokens {
if _, exists, _ := s.Get(t.Serial); exists && !force {
skipped = append(skipped, t.Serial)
continue
}
t.Created = time.Now().Truncate(time.Second)
if err := s.Put(t); err != nil {
return err
}
added = append(added, t.Serial)
}
if err := s.Commit(); err != nil {
return err
}
if asJSON {
return printJSON(map[string]any{"imported": added, "skipped": skipped})
}
fmt.Printf("Импортировано токенов: %d из %d\n", len(added), len(tokens))
for _, serial := range added {
fmt.Printf(" + %s\n", serial)
}
if len(skipped) > 0 {
warn("уже есть в хранилище и пропущены (-force перезапишет): %s", strings.Join(skipped, ", "))
}
return nil
}
// -------------------------------------------------------------- list / show
func cmdList(args []string) error {
fs := newFlagSet("list")
var storePath string
var asJSON, showSeed bool
storeFlag(fs, &storePath)
fs.BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
fs.BoolVar(&showSeed, "show-seed", false, "включить seed в вывод")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 0, 0, ""); err != nil {
return err
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
tokens, err := s.List()
if err != nil {
return err
}
if asJSON {
out := make([]tokenJSON, len(tokens))
for i, t := range tokens {
out[i] = toJSON(t, showSeed)
}
if out == nil {
out = []tokenJSON{}
}
return printJSON(out)
}
printTokenList(os.Stdout, tokens, s.Path())
return nil
}
func cmdShow(args []string) error {
fs := newFlagSet("show")
var storePath string
var asJSON, showSeed bool
var peek int
storeFlag(fs, &storePath)
fs.BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
fs.BoolVar(&showSeed, "show-seed", false, "показать seed полностью")
fs.IntVar(&peek, "peek", 0, "показать столько будущих значений, не двигая счётчик")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, 1, "серийный номер токена"); err != nil {
return err
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
t, err := getToken(s, strings.ToUpper(fs.Arg(0)))
if err != nil {
return err
}
var codes []string
if peek > 0 {
codes, _ = generate(t, peek, time.Now())
}
if asJSON {
j := toJSON(t, showSeed)
j.Codes = codes
return printJSON(j)
}
printTokenDetails(os.Stdout, t, showSeed)
if len(codes) > 0 {
fmt.Printf("\n Будущие значения (счётчик не изменён):\n")
for i, c := range codes {
fmt.Printf(" %2d %s\n", i+1, c)
}
}
return nil
}
// ------------------------------------------------------------- next / token
// generate возвращает n следующих значений токена и позицию, с которой они
// рассчитаны. Состояние токена не меняется.
func generate(t store.Token, n int, now time.Time) (codes []string, from uint64) {
cfg := t.Config()
from = t.Counter
if t.IsTOTP() {
from = cfg.TimeStep(now)
}
for i := 0; i < n; i++ {
codes = append(codes, cfg.HOTP(from+uint64(i)))
}
return codes, from
}
// advance выдаёт n значений и продвигает состояние токена.
func advance(t *store.Token, n int, now time.Time) (codes []string, from uint64) {
codes, from = generate(*t, n, now)
if t.IsTOTP() {
t.LastStep = from + uint64(n) - 1
} else {
t.Counter = from + uint64(n)
}
t.LastUsed = now.Truncate(time.Second)
return codes, from
}
func cmdNext(args []string) error {
fs := newFlagSet("next")
var storePath string
var n int
var asJSON bool
storeFlag(fs, &storePath)
fs.IntVar(&n, "n", 1, "сколько значений выдать подряд (нажатий кнопки)")
fs.BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, 1, "серийный номер токена"); err != nil {
return err
}
if n < 1 {
return fmt.Errorf("количество значений (-n) должно быть больше нуля")
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
serial := strings.ToUpper(fs.Arg(0))
t, err := getToken(s, serial)
if err != nil {
return err
}
now := time.Now()
codes, from := advance(&t, n, now)
// Состояние сохраняется ДО вывода: при сбое значение будет потеряно,
// но не выдано повторно.
if err := s.Put(t); err != nil {
return err
}
if err := s.Commit(); err != nil {
return err
}
if asJSON {
j := toJSON(t, false)
j.Codes = codes
return printJSON(j)
}
for _, c := range codes {
fmt.Println(c)
}
printAdvanceNote(t, from, len(codes), now)
return nil
}
// printAdvanceNote печатает служебную строку в stderr, чтобы stdout оставался
// пригодным для подстановки в скрипты.
func printAdvanceNote(t store.Token, from uint64, n int, now time.Time) {
if t.IsTOTP() {
valid := t.Config().StepStart(from).Add(t.Config().Step)
fmt.Fprintf(os.Stderr, "%s: шаг %d, действует до %s\n",
t.Serial, from, valid.Local().Format(tsLayout))
return
}
fmt.Fprintf(os.Stderr, "%s: счётчик %d → %d\n", t.Serial, from, t.Counter)
}
func cmdToken(args []string) error {
fs := newFlagSet("token")
var storePath string
storeFlag(fs, &storePath)
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, 1, "серийный номер токена"); err != nil {
return err
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
t, err := getToken(s, strings.ToUpper(fs.Arg(0)))
if err != nil {
return err
}
fmt.Printf("Токен %s", t.Serial)
if t.Label != "" {
fmt.Printf(" (%s)", t.Label)
}
fmt.Printf("\n%s, %s, %d разрядов\n\n", upper(t.Type), t.Alg, t.Digits)
fmt.Println("Enter — нажатие кнопки, q — выход.")
in := bufio.NewReader(os.Stdin)
for {
fmt.Print("\n> ")
line, readErr := in.ReadString('\n')
cmd := strings.ToLower(strings.TrimSpace(line))
if cmd == "q" || cmd == "quit" || cmd == "exit" {
return nil
}
if cmd != "" {
fmt.Println("Нажмите Enter, чтобы получить следующее значение, или q для выхода.")
if readErr != nil {
return nil
}
continue
}
now := time.Now()
codes, from := advance(&t, 1, now)
if err := s.Put(t); err != nil {
return err
}
if err := s.Commit(); err != nil {
return err
}
if t.IsTOTP() {
cfg := t.Config()
fmt.Printf("%s (действует до %s)\n", codes[0],
cfg.StepStart(from).Add(cfg.Step).Local().Format("15:04:05"))
} else {
fmt.Printf("%s (счётчик %d)\n", codes[0], from)
}
if readErr != nil { // stdin закончился — например, при вводе из конвейера
return nil
}
}
}
// -------------------------------------------------------------- sync / set
func cmdSync(args []string) error {
fs := newFlagSet("sync")
var storePath, window string
var start, span uint64
var asJSON bool
storeFlag(fs, &storePath)
fs.Uint64Var(&start, "start", 0, "HOTP: начало диапазона перебора счётчика")
fs.Uint64Var(&span, "span", 1000000, "HOTP: размер диапазона перебора счётчика")
fs.StringVar(&window, "window", "90d", "TOTP: окно перебора вокруг текущего времени")
fs.BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 3, 3, "серийный номер и два последовательных кода"); err != nil {
return err
}
otp1, otp2 := fs.Arg(1), fs.Arg(2)
if err := validateCode(otp1, "первый код"); err != nil {
return err
}
if err := validateCode(otp2, "второй код"); err != nil {
return err
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
t, err := getToken(s, strings.ToUpper(fs.Arg(0)))
if err != nil {
return err
}
if len(otp1) != t.Digits {
return fmt.Errorf("токен %s настроен на %d разрядов, а введён код из %d",
t.Serial, t.Digits, len(otp1))
}
cfg := t.Config()
var matches []otp.Match
if t.IsTOTP() {
w, err := parseWindow(window)
if err != nil {
return err
}
matches, err = otp.FindTimeStep(cfg, otp1, otp2, time.Now(), w)
if err != nil {
return withHints(err, true)
}
} else {
matches, err = otp.FindCounter(cfg, otp1, otp2, start, span)
if err != nil {
return withHints(err, false)
}
}
if len(matches) > 1 {
warn("паре кодов соответствует несколько позиций, взята первая")
}
found := matches[0].Counter
old := t.Counter
if t.IsTOTP() {
t.LastStep = found + 1
} else {
t.Counter = found + 2 // следующее ещё не использованное значение
}
t.LastUsed = time.Now().Truncate(time.Second)
if err := s.Put(t); err != nil {
return err
}
if err := s.Commit(); err != nil {
return err
}
if asJSON {
return printJSON(map[string]any{
"serial": t.Serial, "found_position": found,
"counter_before": old, "counter_after": t.Counter,
})
}
unit := "счётчик"
if t.IsTOTP() {
unit = "шаг"
}
fmt.Printf("Токен %s синхронизирован\n", t.Serial)
fmt.Printf(" код %s → %s %d, код %s → %s %d\n", otp1, unit, found, otp2, unit, found+1)
if t.IsTOTP() {
drift := int64(found+1) - int64(cfg.TimeStep(time.Now()))
fmt.Printf(" расхождение часов: %s\n", driftText(drift, drift*int64(cfg.Step/time.Second)))
} else {
fmt.Printf(" счётчик в хранилище: %d → %d\n", old, t.Counter)
}
return nil
}
func cmdSet(args []string) error {
fs := newFlagSet("set")
var storePath, label, alg string
var counter int64
var digits int
var period time.Duration
storeFlag(fs, &storePath)
fs.Int64Var(&counter, "counter", -1, "новое значение счётчика")
fs.StringVar(&label, "label", "", "новая метка")
fs.IntVar(&digits, "digits", 0, "новая разрядность: 6 или 8")
fs.StringVar(&alg, "alg", "", "новый алгоритм: sha1, sha256, sha512")
fs.DurationVar(&period, "period", 0, "новый шаг времени для TOTP")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, 1, "серийный номер токена"); err != nil {
return err
}
set := changedFlags(args)
if !set["counter"] && !set["label"] && !set["digits"] && !set["alg"] && !set["period"] {
fs.Usage()
return fmt.Errorf("не указано, что менять")
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
t, err := getToken(s, strings.ToUpper(fs.Arg(0)))
if err != nil {
return err
}
var changes []string
if set["counter"] {
if counter < 0 {
return fmt.Errorf("счётчик не может быть отрицательным")
}
changes = append(changes, fmt.Sprintf("счётчик %d → %d", t.Counter, counter))
t.Counter = uint64(counter)
}
if set["label"] {
changes = append(changes, fmt.Sprintf("метка %q → %q", t.Label, label))
t.Label = label
}
if set["digits"] {
changes = append(changes, fmt.Sprintf("разрядность %d → %d", t.Digits, digits))
t.Digits = digits
}
if set["alg"] {
a, err := otp.ParseAlgorithm(alg)
if err != nil {
return err
}
changes = append(changes, fmt.Sprintf("алгоритм %s → %s", t.Alg, a))
t.Alg = a
}
if set["period"] {
if !t.IsTOTP() {
return fmt.Errorf("шаг времени применим только к TOTP-токенам")
}
changes = append(changes, fmt.Sprintf("шаг %s → %s", t.Config().Step, period))
t.Period = period
}
if err := s.Put(t); err != nil {
return err
}
if err := s.Commit(); err != nil {
return err
}
fmt.Printf("Токен %s изменён:\n", t.Serial)
for _, c := range changes {
fmt.Printf(" %s\n", c)
}
return nil
}
// ------------------------------------------------------------------ remove
func cmdRemove(args []string) error {
fs := newFlagSet("remove")
var storePath string
var yes bool
storeFlag(fs, &storePath)
fs.BoolVar(&yes, "yes", false, "не спрашивать подтверждения")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := requireArgs(fs, 1, -1, "серийные номера удаляемых токенов"); err != nil {
return err
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
serials := make([]string, 0, fs.NArg())
for _, a := range fs.Args() {
serial := strings.ToUpper(a)
if _, err := getToken(s, serial); err != nil {
return err
}
serials = append(serials, serial)
}
if !yes {
fmt.Fprintf(os.Stderr, "Удалить безвозвратно (%s)? Введите «да» для подтверждения: ",
strings.Join(serials, ", "))
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "да", "yes", "y":
default:
return fmt.Errorf("отменено")
}
}
for _, serial := range serials {
s.Remove(serial)
}
if err := s.Commit(); err != nil {
return err
}
fmt.Printf("Удалено токенов: %d (%s)\n", len(serials), strings.Join(serials, ", "))
return nil
}
// ------------------------------------------------------------------ export
func cmdExport(args []string) error {
fs := newFlagSet("export")
var storePath, out, connectorID, productName, caseModel, model, date string
var all bool
storeFlag(fs, &storePath)
fs.StringVar(&out, "o", "", "записать в файл (по умолчанию — в стандартный вывод)")
fs.BoolVar(&all, "all", false, "выгрузить все токены хранилища")
fs.StringVar(&connectorID, "connector-id", "", "значение атрибута ConnectorID")
fs.StringVar(&productName, "product-name", "", "значение элемента ProductName")
fs.StringVar(&caseModel, "case-model", "", "значение элемента CaseModel")
fs.StringVar(&model, "model", "", "значение элемента Model")
fs.StringVar(&date, "production-date", "", "дата выпуска в формате ГГГГ-ММ-ДД")
if err := parseFlags(fs, args); err != nil {
return err
}
if !all && fs.NArg() == 0 {
fs.Usage()
return fmt.Errorf("укажите серийные номера или флаг -all")
}
if all && fs.NArg() > 0 {
return fmt.Errorf("флаг -all несовместим с явным списком серийных номеров")
}
opts := initfile.Options{
ConnectorID: connectorID, ProductName: productName,
CaseModel: caseModel, Model: model,
}
if date != "" {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return fmt.Errorf("не удалось разобрать -production-date: %w", err)
}
opts.ProductionDate = d
}
s, err := openStore(storePath)
if err != nil {
return err
}
defer s.Close()
var tokens []store.Token
if all {
if tokens, err = s.List(); err != nil {
return err
}
if len(tokens) == 0 {
return fmt.Errorf("хранилище %s пусто", s.Path())
}
} else {
for _, a := range fs.Args() {
t, err := getToken(s, strings.ToUpper(a))
if err != nil {
return err
}
tokens = append(tokens, t)
}
}
if out == "" {
if err := initfile.WriteSafeNet(os.Stdout, tokens, opts); err != nil {
return err
}
} else if err := writeInitFile(out, tokens, opts); err != nil {
return err
}
if out != "" {
fmt.Fprintf(os.Stderr, "Выгружено токенов: %d → %s\n", len(tokens), out)
}
printIssueWarnings(tokens, out)
return nil
}
// counterString — значение счётчика для табличного вывода.
func counterString(t store.Token) string {
if t.IsTOTP() {
if t.LastStep == 0 {
return "—"
}
return strconv.FormatUint(t.LastStep, 10)
}
return strconv.FormatUint(t.Counter, 10)
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"strings"
"hotpcalc/internal/store"
)
// command — подкоманда командной строки.
type command struct {
name string
brief string
args string // краткое описание позиционных аргументов для справки
run func(args []string) error
}
func commands() []*command {
return []*command{
{"new", "выпустить новый токен и выгрузить XML", "", cmdNew},
{"add", "добавить в хранилище известный токен по его seed", "<seed>", cmdAdd},
{"import", "загрузить токены из XML-файла инициализации", "<файл.xml>", cmdImport},
{"list", "список токенов в хранилище", "", cmdList},
{"show", "параметры и состояние токена, счётчик не двигается", "<серийник>", cmdShow},
{"next", "выдать следующее значение — «нажатие кнопки»", "<серийник>", cmdNext},
{"token", "интерактивный режим: Enter = нажатие кнопки", "<серийник>", cmdToken},
{"sync", "найти позицию по двум кодам и записать её в хранилище", "<серийник> <код1> <код2>", cmdSync},
{"set", "изменить параметры токена", "<серийник>", cmdSet},
{"export", "выгрузить XML-файл инициализации", "[серийник...]", cmdExport},
{"remove", "удалить токен из хранилища", "<серийник>...", cmdRemove},
{"calc", "явный вызов калькулятора", "[seed] [код1] [код2]", runCalc},
}
}
func lookupCommand(name string) *command {
for _, c := range commands() {
if c.name == name {
return c
}
}
return nil
}
// newFlagSet готовит набор флагов подкоманды с общей справкой.
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet("hotpcalc "+name, flag.ContinueOnError)
cmd := lookupCommand(name)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "hotpcalc %s %s\n\n %s\n\nФлаги:\n", name, cmd.args, cmd.brief)
fs.PrintDefaults()
}
return fs
}
// storeFlag добавляет флаг пути к хранилищу.
func storeFlag(fs *flag.FlagSet, dst *string) {
fs.StringVar(dst, "store", store.DefaultPath(),
"файл хранилища токенов (переменная "+store.EnvStore+")")
}
// parseFlags разбирает аргументы подкоманды с перестановкой флагов и
// позиционных аргументов.
func parseFlags(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(permute(fs, args)); err != nil {
if errors.Is(err, flag.ErrHelp) {
return errHelp
}
return err
}
return nil
}
// errHelp сигнализирует, что была напечатана справка и работа окончена.
var errHelp = errors.New("справка напечатана")
// warn печатает предупреждение в stderr.
func warn(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Внимание: "+format+"\n", args...)
}
// openStore открывает хранилище с блокировкой.
func openStore(path string) (*store.Store, error) {
return store.Open(path, func(m string) { warn("%s", m) })
}
// getToken читает токен, требуя, чтобы он существовал.
func getToken(s *store.Store, serial string) (store.Token, error) {
t, ok, err := s.Get(serial)
if err != nil {
return store.Token{}, err
}
if !ok {
hint := "хранилище пусто"
if serials := s.Serials(); len(serials) > 0 {
hint = "известные токены: " + strings.Join(serials, ", ")
}
return store.Token{}, fmt.Errorf("токен %s не найден в %s (%s)", serial, s.Path(), hint)
}
return t, nil
}
// requireArgs проверяет число позиционных аргументов.
func requireArgs(fs *flag.FlagSet, min, max int, what string) error {
n := fs.NArg()
if n < min {
fs.Usage()
return fmt.Errorf("не хватает аргументов: нужен %s", what)
}
if max >= 0 && n > max {
return fmt.Errorf("лишние аргументы: %s", strings.Join(fs.Args()[max:], " "))
}
return nil
}
+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
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"bytes"
"flag"
"strings"
"testing"
"time"
)
func testFlagSet() *flag.FlagSet {
fs := flag.NewFlagSet("t", flag.ContinueOnError)
fs.Int("n", 10, "")
fs.String("seed", "", "")
fs.Bool("json", false, "")
return fs
}
func TestPermute(t *testing.T) {
cases := []struct {
in []string
want []string
}{
{[]string{"SEED", "111111", "222222", "-n", "4"},
[]string{"-n", "4", "SEED", "111111", "222222"}},
{[]string{"-n", "4", "SEED"}, []string{"-n", "4", "SEED"}},
{[]string{"SEED", "-json", "111111"}, []string{"-json", "SEED", "111111"}},
{[]string{"SEED", "-n=4"}, []string{"-n=4", "SEED"}},
{[]string{"--", "-n", "4"}, []string{"-n", "4"}},
{[]string{}, []string{}},
}
for _, tc := range cases {
got := permute(testFlagSet(), tc.in)
if strings.Join(got, " ") != strings.Join(tc.want, " ") {
t.Errorf("permute(%v) = %v, ожидалось %v", tc.in, got, tc.want)
}
}
}
func TestAssignPositional(t *testing.T) {
// Все три значения позиционно.
var o options
if err := assignPositional(&o, []string{"SEED", "111111", "222222"}); err != nil {
t.Fatal(err)
}
if o.seed != "SEED" || o.otp1 != "111111" || o.otp2 != "222222" {
t.Errorf("получено %+v", o)
}
// seed задан флагом — позиционные аргументы должны стать кодами.
o = options{seed: "SEED"}
if err := assignPositional(&o, []string{"111111", "222222"}); err != nil {
t.Fatal(err)
}
if o.otp1 != "111111" || o.otp2 != "222222" {
t.Errorf("seed из флага: получено otp1=%q otp2=%q", o.otp1, o.otp2)
}
// Коды заданы флагами — позиционный аргумент должен стать seed.
o = options{otp1: "111111", otp2: "222222"}
if err := assignPositional(&o, []string{"SEED"}); err != nil {
t.Fatal(err)
}
if o.seed != "SEED" {
t.Errorf("seed = %q", o.seed)
}
// Больше аргументов, чем свободных полей.
o = options{}
if err := assignPositional(&o, []string{"SEED", "1", "2", "3"}); err == nil {
t.Error("лишний аргумент должен отвергаться")
}
o = options{seed: "SEED", otp1: "1", otp2: "2"}
if err := assignPositional(&o, []string{"X"}); err == nil {
t.Error("лишний аргумент при всех заданных флагах должен отвергаться")
}
}
func TestParseWindow(t *testing.T) {
cases := map[string]time.Duration{
"90d": 90 * 24 * time.Hour,
"2w": 14 * 24 * time.Hour,
"12h": 12 * time.Hour,
"90m": 90 * time.Minute,
"1h30m": 90 * time.Minute,
}
for in, want := range cases {
got, err := parseWindow(in)
if err != nil || got != want {
t.Errorf("parseWindow(%q) = %v, %v; ожидалось %v", in, got, err, want)
}
}
for _, in := range []string{"", "abc", "5x", "d"} {
if _, err := parseWindow(in); err == nil {
t.Errorf("parseWindow(%q) должен возвращать ошибку", in)
}
}
}
func TestValidateCode(t *testing.T) {
for _, ok := range []string{"755224", "00000000"} {
if err := validateCode(ok, "код"); err != nil {
t.Errorf("validateCode(%q): %v", ok, err)
}
}
for _, bad := range []string{"", "12345", "12345678901", "12a456", "755 224"} {
if err := validateCode(bad, "код"); err == nil {
t.Errorf("validateCode(%q) должен возвращать ошибку", bad)
}
}
}
func TestParseMode(t *testing.T) {
if v, err := parseMode("TOTP"); err != nil || !v {
t.Errorf("parseMode(TOTP) = %v, %v", v, err)
}
if v, err := parseMode(""); err != nil || v {
t.Errorf("parseMode(\"\") = %v, %v", v, err)
}
if _, err := parseMode("ocra"); err == nil {
t.Error("неизвестный режим должен отвергаться")
}
}
func TestPlural(t *testing.T) {
cases := map[int64]string{1: "шаг", 2: "шага", 5: "шагов", 11: "шагов",
13: "шагов", 21: "шаг", 22: "шага", 25: "шагов", 101: "шаг", 112: "шагов"}
for n, want := range cases {
if got := plural(n, "шаг", "шага", "шагов"); got != want {
t.Errorf("plural(%d) = %q, ожидалось %q", n, got, want)
}
}
}
func TestPrintText_HOTP(t *testing.T) {
r := result{
Mode: "hotp", Algorithm: "SHA1", Digits: 6, SeedBytes: 20,
SeedShown: "765D7268…382D", SeedForm: "hex",
OTP1: "941595", OTP2: "057938",
Positions: []position{{
Counter: 41,
Codes: []entry{{Index: 1, Counter: 43, Code: "937300"}},
}},
}
var buf bytes.Buffer
printText(&buf, r, false)
out := buf.String()
for _, want := range []string{"HOTP, SHA1, 6 разрядов", "счётчик 41", "счётчик 42", "937300", "Счётчик"} {
if !strings.Contains(out, want) {
t.Errorf("вывод не содержит %q:\n%s", want, out)
}
}
if strings.Contains(out, "Внимание") {
t.Errorf("для 20-байтного seed предупреждения быть не должно:\n%s", out)
}
}
func TestPrintText_TOTPAndWarnings(t *testing.T) {
from := time.Date(2026, 1, 1, 0, 3, 0, 0, time.UTC)
r := result{
Mode: "totp", Algorithm: "SHA1", Digits: 6, StepSeconds: 30, SeedBytes: 10,
SeedShown: "3132…3930", SeedForm: "base32",
OTP1: "328000", OTP2: "787414", Ambiguous: true,
Positions: []position{{
Counter: 58907524, DriftSteps: 5, DriftSeconds: 150,
Codes: []entry{{Index: 1, Counter: 58907526, Code: "434361",
ValidFrom: from, ValidTo: from.Add(30 * time.Second), Current: true}},
}},
}
var buf bytes.Buffer
printText(&buf, r, true)
out := buf.String()
for _, want := range []string{
"TOTP, SHA1, 6 разрядов, шаг 30 с",
"шаг 58907524",
"опережает эту машину примерно на 150 с (5 шагов)",
"действует сейчас",
"ожидается 20 байт",
"несколько позиций",
} {
if !strings.Contains(out, want) {
t.Errorf("вывод не содержит %q:\n%s", want, out)
}
}
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"encoding/hex"
"fmt"
"io"
"strings"
"text/tabwriter"
"time"
"hotpcalc/internal/otp"
"hotpcalc/internal/store"
)
// entry — одно рассчитанное значение.
type entry struct {
Index int `json:"index"`
Counter uint64 `json:"counter"`
Code string `json:"code"`
ValidFrom time.Time `json:"valid_from,omitzero"`
ValidTo time.Time `json:"valid_to,omitzero"`
Current bool `json:"current,omitempty"`
}
// position — найденная позиция токена и рассчитанные от неё значения.
type position struct {
Counter uint64 `json:"counter"` // позиция первого введённого кода
ValidFrom time.Time `json:"valid_from,omitzero"`
ValidTo time.Time `json:"valid_to,omitzero"`
DriftSteps int64 `json:"drift_steps,omitempty"`
DriftSeconds int64 `json:"drift_seconds,omitempty"`
Codes []entry `json:"codes"`
}
// result — полный результат работы программы.
type result struct {
Mode string `json:"mode"`
Algorithm string `json:"algorithm"`
Digits int `json:"digits"`
StepSeconds int `json:"step_seconds,omitempty"`
SeedBytes int `json:"seed_bytes"`
SeedShown string `json:"seed"`
SeedForm string `json:"seed_format"`
Label string `json:"label,omitempty"`
OTP1 string `json:"otp1,omitempty"`
OTP2 string `json:"otp2,omitempty"`
Direct bool `json:"direct,omitempty"`
Ambiguous bool `json:"ambiguous"`
Positions []position `json:"positions"`
}
const tsLayout = "2006-01-02 15:04:05"
func printText(w io.Writer, r result, totpMode bool) {
unit, unitGen := "счётчик", "счётчика"
if totpMode {
unit, unitGen = "шаг", "шага"
}
fmt.Fprintf(w, "Seed: %s (%d байт, %s)\n", r.SeedShown, r.SeedBytes, r.SeedForm)
if r.Label != "" {
fmt.Fprintf(w, "Токен: %s\n", r.Label)
}
fmt.Fprintf(w, "Режим: %s, %s, %d разрядов", upper(r.Mode), r.Algorithm, r.Digits)
if totpMode {
fmt.Fprintf(w, ", шаг %d с", r.StepSeconds)
}
fmt.Fprintln(w)
if r.SeedBytes != 20 {
fmt.Fprintf(w, "Внимание: длина seed %d байт; для eToken PASS ожидается 20 байт.\n", r.SeedBytes)
}
for i, p := range r.Positions {
if len(r.Positions) > 1 {
fmt.Fprintf(w, "\n=== Совпадение %d из %d ===\n", i+1, len(r.Positions))
}
if r.Direct {
fmt.Fprintf(w, "Расчёт: начиная с %s %d (поиск не выполнялся)\n", unitGen, p.Counter)
} else {
fmt.Fprintf(w, "Позиция: код %s → %s %d, код %s → %s %d\n",
r.OTP1, unit, p.Counter, r.OTP2, unit, p.Counter+1)
if totpMode {
fmt.Fprintf(w, "Часы: %s\n", driftText(p.DriftSteps, p.DriftSeconds))
}
}
fmt.Fprintf(w, "\nСледующие значения (%d):\n\n", len(p.Codes))
// Каждая ячейка завершается табуляцией, иначе tabwriter не выравнивает
// последнюю колонку строки.
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', tabwriter.AlignRight)
if totpMode {
fmt.Fprintf(tw, " #\t%s\tКод\t Действителен\n", upperFirst(unit))
} else {
fmt.Fprintf(tw, " #\t%s\tКод\t\n", upperFirst(unit))
}
for _, e := range p.Codes {
if totpMode {
mark := ""
if e.Current {
mark = " ← действует сейчас"
}
fmt.Fprintf(tw, " %d\t%d\t%s\t %s – %s%s\n", e.Index, e.Counter, e.Code,
e.ValidFrom.Local().Format(tsLayout), e.ValidTo.Local().Format("15:04:05"), mark)
} else {
fmt.Fprintf(tw, " %d\t%d\t%s\t\n", e.Index, e.Counter, e.Code)
}
}
tw.Flush()
}
if r.Ambiguous {
fmt.Fprintln(w, "\nВнимание: введённой паре кодов соответствует несколько позиций.")
if len(r.Positions) == 1 {
fmt.Fprintln(w, "Показана первая из них; полный список — с флагом -all.")
}
fmt.Fprintln(w, "Сузьте диапазон перебора или снимите с токена ещё одно значение.")
}
}
func driftText(steps, seconds int64) string {
switch {
case steps == 0:
return "совпадают с этой машиной"
case steps > 0:
return fmt.Sprintf("токен опережает эту машину примерно на %d с (%d %s)",
seconds, steps, plural(steps, "шаг", "шага", "шагов"))
default:
return fmt.Sprintf("токен отстаёт от этой машины примерно на %d с (%d %s)",
-seconds, -steps, plural(-steps, "шаг", "шага", "шагов"))
}
}
// plural подбирает форму русского существительного для числа n.
func plural(n int64, one, few, many string) string {
if n < 0 {
n = -n
}
switch {
case n%100 >= 11 && n%100 <= 14:
return many
case n%10 == 1:
return one
case n%10 >= 2 && n%10 <= 4:
return few
}
return many
}
func upper(s string) string {
return strings.ToUpper(s)
}
func upperFirst(s string) string {
r := []rune(s)
if len(r) == 0 {
return s
}
return strings.ToUpper(string(r[0])) + string(r[1:])
}
// printTokenDetails печатает параметры и состояние одного токена.
func printTokenDetails(w io.Writer, t store.Token, showSeed bool) {
seed := otp.Fingerprint(t.Secret)
if showSeed {
seed = strings.ToUpper(hex.EncodeToString(t.Secret))
}
fmt.Fprintf(w, "\n %s", t.Serial)
if t.Label != "" {
fmt.Fprintf(w, " (%s)", t.Label)
}
fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0)
fmt.Fprintf(tw, " тип\t%s, %s, %d разрядов", upper(t.Type), t.Alg, t.Digits)
if t.IsTOTP() {
fmt.Fprintf(tw, ", шаг %d с", int(t.Config().Step/time.Second))
}
fmt.Fprintln(tw)
fmt.Fprintf(tw, " seed\t%s (%d байт)\n", seed, len(t.Secret))
if t.IsTOTP() {
if t.LastStep > 0 {
fmt.Fprintf(tw, " последний шаг\t%d\n", t.LastStep)
}
} else {
fmt.Fprintf(tw, " счётчик\t%d (значение следующего нажатия)\n", t.Counter)
}
if !t.Created.IsZero() {
fmt.Fprintf(tw, " выпущен\t%s\n", t.Created.Local().Format(tsLayout))
}
if !t.LastUsed.IsZero() {
fmt.Fprintf(tw, " использован\t%s\n", t.LastUsed.Local().Format(tsLayout))
}
tw.Flush()
}
// printTokenList печатает таблицу токенов хранилища.
func printTokenList(w io.Writer, tokens []store.Token, path string) {
if len(tokens) == 0 {
fmt.Fprintf(w, "Хранилище %s пусто.\nСоздайте токен командой: hotpcalc new\n", path)
return
}
fmt.Fprintf(w, "Хранилище: %s\n\n", path)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " СЕРИЙНЫЙ НОМЕР\tТИП\tРАЗР.\tСЧЁТЧИК\tИСПОЛЬЗОВАН\tМЕТКА")
for _, t := range tokens {
used := "—"
if !t.LastUsed.IsZero() {
used = t.LastUsed.Local().Format(tsLayout)
}
label := t.Label
if label == "" {
label = "—"
}
fmt.Fprintf(tw, " %s\t%s\t%d\t%s\t%s\t%s\n",
t.Serial, upper(t.Type), t.Digits, counterString(t), used, label)
}
tw.Flush()
fmt.Fprintf(w, "\nВсего токенов: %d\n", len(tokens))
}