890 lines
25 KiB
Go
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)
|
|
}
|