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