Initial commit: hotpcalc CLI
This commit is contained in:
@@ -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