Files
hotp_calc/cmd/hotpcalc/cmd_token.go
T

890 lines
25 KiB
Go

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)
}