commit 8f42dc690eb2f5a786a890b76883e78c9606492d Author: a.gritsenko Date: Sun Sep 20 20:04:18 2026 +0300 Initial commit: hotpcalc CLI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9de494b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# сборочные артефакты +/dist/ +/hotpcalc +/hotpcalc.exe + +# хранилища и выгрузки с реальными секретами (seed) — никогда не коммитить +*.ini +*.ini.lock +*.xml + +# служебное +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22dc82d --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +BIN := hotpcalc +PKG := ./cmd/hotpcalc +VERSION ?= 1.1.0 +LDFLAGS := -s -w -X main.version=$(VERSION) +DIST := dist + +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 + +.PHONY: all build test vet fmt dist clean + +all: test build + +build: + go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) $(PKG) + +test: + go test ./... + +vet: + go vet ./... + +fmt: + gofmt -l -w . + +## dist — статические бинарники под Linux, macOS и Windows (amd64 + arm64) +dist: clean + @mkdir -p $(DIST) + @for p in $(PLATFORMS); do \ + os=$${p%/*}; arch=$${p#*/}; ext=""; \ + [ "$$os" = "windows" ] && ext=".exe"; \ + echo " сборка $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + go build -trimpath -ldflags "$(LDFLAGS)" -o $(DIST)/$(BIN)-$$os-$$arch$$ext $(PKG) || exit 1; \ + done + @cd $(DIST) && shasum -a 256 * > SHA256SUMS 2>/dev/null || true + @ls -l $(DIST) + +clean: + @rm -rf $(DIST) $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..47d63a5 --- /dev/null +++ b/README.md @@ -0,0 +1,353 @@ +# hotpcalc + +Инструмент для работы с токенами **SafeNet eToken PASS** — HOTP (RFC 4226) +и TOTP (RFC 6238). Умеет три вещи: + +1. **Калькулятор.** По вектору инициализации (seed) и **двум последовательным** + значениям, снятым с токена, определяет его текущую позицию (счётчик для HOTP, + временной шаг для TOTP) и рассчитывает следующие N значений. +2. **Программный токен.** Хранит seed и счётчик в ini-файле и по команде выдаёт + следующее значение — как физический брелок. +3. **Выпуск токенов.** Генерирует новый токен и выгружает файл инициализации + в XML-формате SafeNet/Aladdin для импорта на сервер аутентификации. + +Написан на Go без внешних зависимостей; собирается в один статический бинарник +под Linux, macOS и Windows (amd64 и arm64). + +## Сборка + +```sh +make build # локальный бинарник ./hotpcalc +make test # тесты, включая контрольные векторы RFC 4226 и RFC 6238 +make dist # бинарники под linux/macos/windows, amd64/arm64 в каталоге dist/ +``` + +Требуется Go 1.26 или новее. Готовые бинарники в `dist/` ни от чего не зависят — +достаточно скопировать нужный файл и сделать его исполняемым: + +```sh +chmod +x hotpcalc-linux-amd64 +./hotpcalc-linux-amd64 -version +``` + +На macOS файл, скачанный через браузер, помечается карантином; снять метку: + +```sh +xattr -d com.apple.quarantine hotpcalc-darwin-arm64 +``` + +В консоли Windows перед запуском может понадобиться переключение на UTF-8, +иначе русский текст выведется нечитаемо (в Windows Terminal это не нужно): + +``` +chcp 65001 +hotpcalc-windows-amd64.exe -seed 765D... -otp1 941595 -otp2 057938 +``` + +## Команды + +``` +hotpcalc [флаги] [seed] [код1] [код2] калькулятор (режим по умолчанию) + +hotpcalc new выпустить новый токен: seed, серийник, запись в ini, выгрузка XML +hotpcalc add добавить в ini уже известный токен по его seed +hotpcalc import загрузить токены из XML-файла инициализации +hotpcalc list список токенов в хранилище +hotpcalc show параметры и состояние токена (счётчик не двигается) +hotpcalc next выдать следующее значение — «нажатие кнопки» +hotpcalc token интерактивный режим: Enter = нажатие кнопки +hotpcalc sync найти позицию по двум кодам и записать её в хранилище +hotpcalc set изменить параметры токена +hotpcalc export выгрузить XML-файл инициализации +hotpcalc remove удалить токен из хранилища +hotpcalc calc явный вызов калькулятора +``` + +Справка по любой команде — `hotpcalc <команда> -h`. + +## Калькулятор + +``` +hotpcalc [флаги] [seed] [код1] [код2] +``` + +Самый короткий вызов — три позиционных аргумента: + +```sh +./hotpcalc 765D7268D012090C28172A729BC9CC1AD82B382D 941595 057938 +``` + +``` +Seed: 765D7268…382D (20 байт, hex) +Режим: HOTP, SHA1, 6 разрядов +Позиция: код 941595 → счётчик 41, код 057938 → счётчик 42 + +Следующие значения (10): + + # Счётчик Код + 1 43 937300 + 2 44 991694 + 3 45 146545 + ... +``` + +Без аргументов программа спрашивает seed и коды в диалоге: + +```sh +./hotpcalc +Введите seed (hex или Base32): ... +Введите первый код с токена: ... +Введите второй (следующий) код: ... +``` + +### Флаги + +| Флаг | Назначение | По умолчанию | +|------|------------|--------------| +| `-mode` | режим: `hotp` или `totp` | `hotp` | +| `-seed` | вектор инициализации | — | +| `-format` | формат seed: `auto`, `hex`, `base32` | `auto` | +| `-otp1`, `-otp2` | два последовательных значения с токена | — | +| `-n` | сколько следующих значений рассчитать | `10` | +| `-digits` | разрядность кода (6 или 8) | по длине введённых кодов | +| `-alg` | `sha1`, `sha256`, `sha512` | `sha1` | +| `-step` | шаг времени TOTP | `30s` | +| `-start`, `-span` | диапазон перебора счётчика (HOTP) | `0`, `1000000` | +| `-window` | окно перебора вокруг текущего времени (TOTP); понимает `d` и `w` | `90d` | +| `-counter` | считать прямо с указанного счётчика/шага, без поиска | — | +| `-at` | считать текущим моментом указанное время, RFC 3339 (TOTP) | — | +| `-all` | вывести результат для всех найденных совпадений | выкл. | +| `-json` | машиночитаемый вывод | выкл. | +| `-version` | показать версию | — | + +Флаги можно ставить и после позиционных аргументов: `./hotpcalc <код1> <код2> -n 20`. + +### Примеры + +Двадцать следующих значений в режиме HOTP: + +```sh +./hotpcalc -seed 765D7268D012090C28172A729BC9CC1AD82B382D -otp1 941595 -otp2 057938 -n 20 +``` + +Восьмиразрядный токен (разрядность определяется автоматически по длине кодов): + +```sh +./hotpcalc -seed 765D... -otp1 12345678 -otp2 87654321 +``` + +TOTP с шагом 60 секунд и seed в Base32: + +```sh +./hotpcalc -mode totp -step 60s -seed GEZDGNBVGY3TQOJQ -otp1 123456 -otp2 654321 +``` + +Расчёт прямо с известного счётчика, без поиска: + +```sh +./hotpcalc -seed 765D... -counter 100 -n 5 +``` + +Токен с большим значением счётчика — расширяем диапазон перебора: + +```sh +./hotpcalc -seed 765D... -otp1 941595 -otp2 057938 -span 10000000 +``` + +Машиночитаемый вывод: + +```sh +./hotpcalc -seed 765D... -otp1 941595 -otp2 057938 -n 3 -json +``` + +## Программный токен + +Хранилище — ini-файл, по умолчанию `tokens.ini` рядом с исполняемым файлом. +Путь меняется флагом `-store` или переменной окружения `HOTPCALC_STORE`. + +Выпуск токена: генерируется случайный seed (`crypto/rand`, 20 байт), серийный номер +и файл инициализации для сервера. + +```sh +$ ./hotpcalc new -label "Иванов И.И." -xml ivanov.xml +Выпущено токенов: 1 + + 0004003D473F7D (Иванов И.И.) + тип HOTP, SHA1, 6 разрядов + seed 281CCDCD…84F1 (20 байт) + счётчик 0 (значение следующего нажатия) + выпущен 2026-09-04 11:23:14 + +Хранилище: /opt/hotpcalc/tokens.ini +XML: ivanov.xml +``` + +Дальше токен работает как брелок — каждое `next` это одно нажатие кнопки: + +```sh +$ ./hotpcalc next 0004003D473F7D +521232 +``` + +Код уходит в stdout, служебная строка (`0004003D473F7D: счётчик 0 → 1`) — в stderr, +поэтому команду можно подставлять прямо в скрипты. Флаг `-n` выдаёт несколько +значений за раз. + +Интерактивный режим ближе к настоящему токену: + +```sh +$ ./hotpcalc token 0004003D473F7D +Токен 0004003D473F7D (Иванов И.И.) +HOTP, SHA1, 6 разрядов + +Enter — нажатие кнопки, q — выход. + +> 448181 (счётчик 0) +> 257719 (счётчик 1) +> q +``` + +Остальные команды: + +```sh +./hotpcalc list # что вообще есть в хранилище +./hotpcalc show 0004003D473F7D -peek 5 # состояние и будущие коды, счётчик не двигая +./hotpcalc sync 0004003D473F7D 394216 767221 # ресинхронизация по двум кодам +./hotpcalc set 0004003D473F7D -counter 100 # правка вручную +./hotpcalc add -label "Старый токен" 765D7268... # завести токен по известному seed +./hotpcalc remove 0004003D473F7D # удалить +``` + +Состояние сохраняется **до** вывода кода, а хранилище на время работы блокируется +файлом `tokens.ini.lock`, поэтому параллельные вызовы не могут получить один и тот же +счётчик. Замок старше минуты считается брошенным и снимается автоматически. + +### Формат хранилища + +```ini +; hotpcalc — хранилище токенов +; Файл содержит секреты в открытом виде: защищайте его как пароль. +; Одна секция — один токен, имя секции — серийный номер. + +[0004003D473F7D] +type = hotp +seed = 281CCDCD4E26ACFD57A09450358FF257FEA484F1 +digits = 6 +algorithm = sha1 +counter = 0 +label = Иванов И.И. +created = 2026-09-04T11:23:14+03:00 +last_used = 2026-09-04T11:23:24+03:00 +``` + +Файл можно править вручную: комментарии, порядок секций и порядок ключей +сохраняются при перезаписи. Для TOTP вместо `counter` пишутся `period` и `last_step`. + +## Файл инициализации (XML) + +`new -xml`, `add -xml` и `export` выгружают токены в родном формате SafeNet/Aladdin — +том самом, что поставляется с закупленными eToken PASS. Его импортируют SafeNet +Authentication Manager, privacyIDEA, LinOTP и совместимые серверы. + +```xml + + + + 5 + 101 + 09/04/2026 + eToken PASS + + + 281CCDCD4E26ACFD57A09450358FF257FEA484F1 + 0 + + + + +``` + +Заводские поля меняются флагами `-connector-id`, `-product-name`, `-case-model`, +`-model`, `-production-date`. + +**Ограничение формата.** В нём нет полей для разрядности, режима и периода: он +описывает шестиразрядный HOTP, а алгоритм импортирующая сторона выводит из длины +seed (64 hex-символа — SHA256, иначе SHA1). Если выгружается токен с другими +параметрами, программа предупреждает об этом явно: + +``` +Внимание: формат не хранит разрядность — сервер импортирует токен как 6-разрядный, а не 8-разрядный +``` + +Выпуск партии токенов одним файлом: + +```sh +./hotpcalc new -count 50 -label "Отдел продаж" -xml batch.xml +./hotpcalc export -all -o all.xml # выгрузить всё хранилище +./hotpcalc import postavka.xml # загрузить закупленные токены +``` + +## Форматы seed + +Формат определяется автоматически: + +* **hex** — как на бланке eToken PASS: 40 шестнадцатеричных символов (20 байт). + Пробелы, дефисы и двоеточия игнорируются; +* **Base32** — как в Google Authenticator, регистр и padding `=` не важны; +* **`otpauth://` URI** — из QR-кода. Параметры `digits`, `algorithm`, `period` + и `counter` берутся из URI, если не заданы флагами явно. + +## Как работает поиск позиции + +Токен eToken PASS в режиме HOTP хранит счётчик, который увеличивается при каждом +нажатии кнопки. Два подряд снятых значения однозначно задают позицию: программа +перебирает счётчики в диапазоне `[-start, -start + -span)` и ищет тот, для которого +первый код приходится на счётчик `C`, а второй — на `C+1`. + +В режиме TOTP перебираются временные шаги вокруг текущего момента в пределах `±-window`, +что позволяет заодно измерить расхождение часов токена и этой машины. + +Если совпадений найдено несколько (вероятность порядка `span / 10^12` — практически +исключено), программа предупреждает об этом; полный список выводится с флагом `-all`. +Если совпадений нет, стоит проверить seed, разрядность, режим и порядок кодов, +а затем расширить диапазон перебора. + +## Проверка корректности + +Реализация проходит контрольные векторы: + +* RFC 4226, Приложение D — все десять значений HOTP; +* RFC 6238, Приложение B — TOTP для SHA1, SHA256 и SHA512. + +Отдельно проверяется, что программный токен и калькулятор дают одно и то же: +коды из `next` сверяются с расчётом по seed из выгруженного XML, а параллельные +нажатия не выдают повторов. + +```sh +make test +``` + +Сверка с внешним инструментом: + +```sh +oathtool --hotp -c 0 3132333435363738393031323334353637383930 # 755224 +./hotpcalc -seed 3132333435363738393031323334353637383930 -counter 0 -n 1 +``` + +## Безопасность + +Seed — это секрет, эквивалентный самому токену. Программа никуда его не передаёт: +сетевых обращений нет. По умолчанию seed печатается сокращённо (`765D7268…382D`), +полностью — только по флагу `-show-seed` и в выводе `-json`. + +Ini-хранилище и XML-файлы создаются с правами `0600` (на Windows биты прав не +действуют — полагайтесь на права каталога). Оба содержат секреты открытым текстом: + +- удаляйте XML-файл инициализации после импорта на сервер — об этом программа + предупреждает сама; +- относитесь к `tokens.ini` как к файлу паролей; +- шифрование хранилища не реализовано: если это важно, держите файл на + зашифрованном разделе. + +Seed, переданный флагом, попадает в историю командной строки и в список процессов — +в чувствительных случаях вводите его в диалоге. diff --git a/cmd/hotpcalc/cli_test.go b/cmd/hotpcalc/cli_test.go new file mode 100644 index 0000000..1b39b1e --- /dev/null +++ b/cmd/hotpcalc/cli_test.go @@ -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+"") { + 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, "") { + 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) + } +} diff --git a/cmd/hotpcalc/cmd_token.go b/cmd/hotpcalc/cmd_token.go new file mode 100644 index 0000000..339cb0e --- /dev/null +++ b/cmd/hotpcalc/cmd_token.go @@ -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) +} diff --git a/cmd/hotpcalc/commands.go b/cmd/hotpcalc/commands.go new file mode 100644 index 0000000..7e4d470 --- /dev/null +++ b/cmd/hotpcalc/commands.go @@ -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", "", 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 +} diff --git a/cmd/hotpcalc/main.go b/cmd/hotpcalc/main.go new file mode 100644 index 0000000..4420e48 --- /dev/null +++ b/cmd/hotpcalc/main.go @@ -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 <код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 <код1> <код2>. + // Заполняются только те поля, которые не заданы флагами, — чтобы вызов + // "hotpcalc -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 <код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 +} diff --git a/cmd/hotpcalc/main_test.go b/cmd/hotpcalc/main_test.go new file mode 100644 index 0000000..545973a --- /dev/null +++ b/cmd/hotpcalc/main_test.go @@ -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) + } + } +} diff --git a/cmd/hotpcalc/output.go b/cmd/hotpcalc/output.go new file mode 100644 index 0000000..6b83efa --- /dev/null +++ b/cmd/hotpcalc/output.go @@ -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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..776d86d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module hotpcalc + +go 1.26 diff --git a/internal/ini/ini.go b/internal/ini/ini.go new file mode 100644 index 0000000..4f6cd86 --- /dev/null +++ b/internal/ini/ini.go @@ -0,0 +1,269 @@ +// Package ini реализует минимальный разбор и запись ini-файлов без внешних +// зависимостей. Порядок секций, порядок ключей и строки комментариев +// сохраняются, чтобы файл, отредактированный пользователем вручную, +// не портился при перезаписи. +package ini + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" +) + +// Section — одна секция ini-файла. +type Section struct { + name string + + // lines хранит строки секции в исходном порядке: комментарии, пустые + // строки и пары ключ-значение. Это позволяет перезаписать файл, + // не потеряв авторское оформление. + lines []line +} + +type line struct { + raw string // комментарий или пустая строка; используется, если key == "" + key string + value string +} + +// File — разобранный ini-файл. +type File struct { + preamble []string // комментарии и пустые строки до первой секции + sections []*Section +} + +// New создаёт пустой файл. +func New() *File { return &File{} } + +// Preamble возвращает строки, стоящие до первой секции. +func (f *File) Preamble() []string { return f.preamble } + +// SetPreamble задаёт строки, которые будут записаны до первой секции. +// Строки комментариев должны начинаться с ';' или '#'. +func (f *File) SetPreamble(lines []string) { f.preamble = lines } + +// Sections возвращает имена секций в порядке их появления в файле. +func (f *File) Sections() []string { + names := make([]string, len(f.sections)) + for i, s := range f.sections { + names[i] = s.name + } + return names +} + +// Section возвращает секцию по имени или nil, если её нет. +func (f *File) Section(name string) *Section { + for _, s := range f.sections { + if s.name == name { + return s + } + } + return nil +} + +// AddSection возвращает существующую секцию или создаёт новую в конце файла. +func (f *File) AddSection(name string) *Section { + if s := f.Section(name); s != nil { + return s + } + s := &Section{name: name} + f.sections = append(f.sections, s) + return s +} + +// RemoveSection удаляет секцию; возвращает true, если она была. +func (f *File) RemoveSection(name string) bool { + for i, s := range f.sections { + if s.name == name { + f.sections = append(f.sections[:i], f.sections[i+1:]...) + return true + } + } + return false +} + +// Name возвращает имя секции. +func (s *Section) Name() string { return s.name } + +// Get возвращает значение ключа; для отсутствующего ключа — пустую строку. +func (s *Section) Get(key string) string { + for _, l := range s.lines { + if l.key == key { + return l.value + } + } + return "" +} + +// Has сообщает, есть ли такой ключ в секции. +func (s *Section) Has(key string) bool { + for _, l := range s.lines { + if l.key == key { + return true + } + } + return false +} + +// Keys возвращает имена ключей в порядке их появления в секции. +func (s *Section) Keys() []string { + var keys []string + for _, l := range s.lines { + if l.key != "" { + keys = append(keys, l.key) + } + } + return keys +} + +// Set задаёт значение ключа. Существующий ключ обновляется на своём месте, +// новый добавляется в конец секции. +func (s *Section) Set(key, value string) { + for i := range s.lines { + if s.lines[i].key == key { + s.lines[i].value = value + return + } + } + s.lines = append(s.lines, line{key: key, value: value}) +} + +// Unset удаляет ключ; возвращает true, если он был. +func (s *Section) Unset(key string) bool { + for i, l := range s.lines { + if l.key == key { + s.lines = append(s.lines[:i], s.lines[i+1:]...) + return true + } + } + return false +} + +func parseError(n int, format string, args ...any) error { + return fmt.Errorf("строка %d: %s", n, fmt.Sprintf(format, args...)) +} + +// Parse разбирает ini-файл из r. +func Parse(r io.Reader) (*File, error) { + f := New() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var cur *Section + for n := 1; sc.Scan(); n++ { + raw := strings.TrimRight(sc.Text(), " \t\r") + trimmed := strings.TrimSpace(raw) + + switch { + case trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#"): + if cur == nil { + f.preamble = append(f.preamble, raw) + } else { + cur.lines = append(cur.lines, line{raw: raw}) + } + + case strings.HasPrefix(trimmed, "["): + if !strings.HasSuffix(trimmed, "]") { + return nil, parseError(n, "незакрытое имя секции %q", trimmed) + } + name := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + if name == "" { + return nil, parseError(n, "пустое имя секции") + } + if f.Section(name) != nil { + return nil, parseError(n, "секция %q встречается повторно", name) + } + cur = f.AddSection(name) + + default: + i := strings.IndexByte(trimmed, '=') + if i < 0 { + return nil, parseError(n, "строка не является парой ключ=значение: %q", trimmed) + } + key := strings.TrimSpace(trimmed[:i]) + if key == "" { + return nil, parseError(n, "пустое имя ключа") + } + if cur == nil { + return nil, parseError(n, "ключ %q вне секции", key) + } + if cur.Has(key) { + return nil, parseError(n, "ключ %q в секции [%s] встречается повторно", key, cur.name) + } + cur.lines = append(cur.lines, line{key: key, value: strings.TrimSpace(trimmed[i+1:])}) + } + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("чтение ini: %w", err) + } + return f, nil +} + +// validValue проверяет, что значение можно записать без потери смысла: +// экранирования в формате нет, поэтому переводы строки запрещены. +func validValue(key, value string) error { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("значение ключа %q содержит перевод строки", key) + } + return nil +} + +// WriteTo записывает файл. Секции и ключи выводятся в исходном порядке, +// значения внутри секции выравниваются по самому длинному имени ключа. +func (f *File) WriteTo(w io.Writer) (int64, error) { + var buf bytes.Buffer + + for _, l := range f.preamble { + buf.WriteString(l) + buf.WriteByte('\n') + } + + for _, s := range f.sections { + // Отделяем секцию пустой строкой, но не плодим их: в исходном файле + // пустая строка перед секцией уже могла быть сохранена как часть + // предыдущей секции или шапки. + separate(&buf) + fmt.Fprintf(&buf, "[%s]\n", s.name) + + width := 0 + for _, l := range s.lines { + if l.key != "" && len(l.key) > width { + width = len(l.key) + } + } + for _, l := range s.lines { + if l.key == "" { + buf.WriteString(l.raw) + buf.WriteByte('\n') + continue + } + if err := validValue(l.key, l.value); err != nil { + return 0, err + } + fmt.Fprintf(&buf, "%-*s = %s\n", width, l.key, l.value) + } + } + + n, err := w.Write(buf.Bytes()) + return int64(n), err +} + +// separate дописывает пустую строку-разделитель, если её ещё нет. +func separate(buf *bytes.Buffer) { + b := buf.Bytes() + if len(b) == 0 || bytes.HasSuffix(b, []byte("\n\n")) { + return + } + buf.WriteByte('\n') +} + +// Bytes возвращает содержимое файла в виде среза байтов. +func (f *File) Bytes() ([]byte, error) { + var buf bytes.Buffer + if _, err := f.WriteTo(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/ini/ini_test.go b/internal/ini/ini_test.go new file mode 100644 index 0000000..80a58c5 --- /dev/null +++ b/internal/ini/ini_test.go @@ -0,0 +1,210 @@ +package ini + +import ( + "strings" + "testing" +) + +const sample = `; хранилище токенов +; вторая строка шапки + +[0004000ABCDEF1] +; заметка про этот токен +type = hotp +seed = 765D7268D012090C28172A729BC9CC1AD82B382D +counter = 42 +label = Иванов И.И. + +[0004000ABCDEF2] +type = totp +period = 30 +` + +func mustParse(t *testing.T, s string) *File { + t.Helper() + f, err := Parse(strings.NewReader(s)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return f +} + +func TestParseBasics(t *testing.T) { + f := mustParse(t, sample) + + if got := f.Sections(); len(got) != 2 || got[0] != "0004000ABCDEF1" || got[1] != "0004000ABCDEF2" { + t.Fatalf("Sections() = %v", got) + } + s := f.Section("0004000ABCDEF1") + if s == nil { + t.Fatal("секция не найдена") + } + if got := s.Get("counter"); got != "42" { + t.Errorf("counter = %q", got) + } + if got := s.Get("label"); got != "Иванов И.И." { + t.Errorf("label = %q", got) + } + if got := s.Get("нет-такого"); got != "" { + t.Errorf("отсутствующий ключ = %q", got) + } + if !s.Has("seed") || s.Has("нет-такого") { + t.Error("Has работает неверно") + } + if got := s.Keys(); strings.Join(got, ",") != "type,seed,counter,label" { + t.Errorf("Keys() = %v", got) + } + if f.Section("нет-такой") != nil { + t.Error("несуществующая секция должна давать nil") + } +} + +func TestRoundTripIsStable(t *testing.T) { + // Первая запись приводит выравнивание к каноническому виду, дальше файл + // не должен меняться — иначе каждая перезапись плодила бы отступы + // или пустые строки. + first, err := mustParse(t, sample).Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + second, err := mustParse(t, string(first)).Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if string(first) != string(second) { + t.Errorf("повторная запись изменила файл:\n--- 1 ---\n%s\n--- 2 ---\n%s", first, second) + } + + // Содержимое при этом сохраняется полностью. + got := string(first) + for _, want := range []string{ + "; хранилище токенов", "; вторая строка шапки", "; заметка про этот токен", + "[0004000ABCDEF1]", "[0004000ABCDEF2]", + "seed = 765D7268D012090C28172A729BC9CC1AD82B382D", + "label = Иванов И.И.", "period = 30", + } { + if !strings.Contains(got, want) { + t.Errorf("после записи потеряно %q:\n%s", want, got) + } + } + if strings.Contains(got, "\n\n\n") { + t.Errorf("лишние пустые строки:\n%q", got) + } +} + +func TestSetKeepsPositionAndComments(t *testing.T) { + f := mustParse(t, sample) + s := f.Section("0004000ABCDEF1") + s.Set("counter", "43") // существующий ключ — на своём месте + s.Set("last_used", "now") // новый — в конец секции + + out, err := f.Bytes() + if err != nil { + t.Fatal(err) + } + got := string(out) + if !strings.Contains(got, "counter = 43") { + t.Errorf("counter не обновлён:\n%s", got) + } + if !strings.Contains(got, "; заметка про этот токен") { + t.Errorf("комментарий внутри секции потерян:\n%s", got) + } + if !strings.Contains(got, "; хранилище токенов") { + t.Errorf("шапка потеряна:\n%s", got) + } + if strings.Index(got, "counter") > strings.Index(got, "label") { + t.Errorf("порядок ключей нарушен:\n%s", got) + } + if !strings.Contains(got, "last_used = now") { + t.Errorf("новый ключ не добавлен:\n%s", got) + } + // Ширина выравнивания пересчитывается по самому длинному ключу секции. + if !strings.Contains(got, "type = hotp") { + t.Errorf("выравнивание нарушено:\n%s", got) + } +} + +func TestAddRemoveSection(t *testing.T) { + f := New() + f.SetPreamble([]string{"; шапка"}) + s := f.AddSection("AAA") + s.Set("k", "v") + if f.AddSection("AAA") != s { + t.Error("AddSection должен возвращать существующую секцию") + } + f.AddSection("BBB").Set("x", "y") + + out, _ := f.Bytes() + want := "; шапка\n\n[AAA]\nk = v\n\n[BBB]\nx = y\n" + if string(out) != want { + t.Errorf("получено:\n%q\nожидалось:\n%q", out, want) + } + + if !f.RemoveSection("AAA") || f.RemoveSection("AAA") { + t.Error("RemoveSection работает неверно") + } + if got := f.Sections(); len(got) != 1 || got[0] != "BBB" { + t.Errorf("после удаления Sections() = %v", got) + } +} + +func TestUnset(t *testing.T) { + f := mustParse(t, sample) + s := f.Section("0004000ABCDEF1") + if !s.Unset("counter") || s.Unset("counter") { + t.Error("Unset работает неверно") + } + out, _ := f.Bytes() + if strings.Contains(string(out), "counter") { + t.Errorf("ключ не удалён:\n%s", out) + } +} + +func TestValueEdgeCases(t *testing.T) { + f := mustParse(t, "[s]\nempty =\nurl = http://x/?a=1&b=2\npadded = с пробелами \n") + s := f.Section("s") + if got := s.Get("empty"); got != "" { + t.Errorf("пустое значение = %q", got) + } + if got := s.Get("url"); got != "http://x/?a=1&b=2" { + t.Errorf("значение со знаком = внутри: %q", got) + } + if got := s.Get("padded"); got != "с пробелами" { + t.Errorf("обрамляющие пробелы не срезаны: %q", got) + } +} + +func TestWriteRejectsNewline(t *testing.T) { + f := New() + f.AddSection("s").Set("k", "две\nстроки") + if _, err := f.Bytes(); err == nil { + t.Error("значение с переводом строки должно отвергаться при записи") + } +} + +func TestParseErrors(t *testing.T) { + cases := map[string]string{ + "ключ вне секции": "k = v\n", + "незакрытая секция": "[abc\n", + "пустое имя секции": "[]\n", + "строка без знака =": "[s]\nмусор\n", + "пустое имя ключа": "[s]\n = v\n", + "повтор секции": "[s]\nk = 1\n[s]\nk = 2\n", + "повтор ключа в секции": "[s]\nk = 1\nk = 2\n", + } + for name, in := range cases { + if _, err := Parse(strings.NewReader(in)); err == nil { + t.Errorf("%s: ожидалась ошибка", name) + } + } +} + +func TestParseEmptyAndCRLF(t *testing.T) { + if f, err := Parse(strings.NewReader("")); err != nil || len(f.Sections()) != 0 { + t.Errorf("пустой файл: %v, %v", f, err) + } + f := mustParse(t, "[s]\r\nk = v\r\n") + if got := f.Section("s").Get("k"); got != "v" { + t.Errorf("CRLF: k = %q", got) + } +} diff --git a/internal/initfile/safenet.go b/internal/initfile/safenet.go new file mode 100644 index 0000000..eaf9402 --- /dev/null +++ b/internal/initfile/safenet.go @@ -0,0 +1,210 @@ +// Package initfile читает и пишет файлы инициализации токенов в родном +// XML-формате SafeNet/Aladdin для eToken PASS. +// +// Структура формата: Tokens → Token[@serial] → Applications → Application → +// {Seed, MovingFactor}. Seed записывается шестнадцатеричным текстом, счётчик — +// в MovingFactor. Полей для разрядности, режима и периода в формате нет: +// он описывает только шестиразрядный HOTP, а алгоритм импортирующая сторона +// выводит из длины seed (64 hex-символа — SHA256, иначе SHA1). +package initfile + +import ( + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "strconv" + "strings" + "time" + + "hotpcalc/internal/otp" + "hotpcalc/internal/store" +) + +// Значения по умолчанию для полей, которые eToken PASS заполняет на заводе. +const ( + DefaultConnectorID = "{ab1397d2-ddb6-4705-b66e-9f83f322deb9}" + DefaultProductName = "eToken PASS" + DefaultCaseModel = "5" + DefaultModel = "101" +) + +// Options задаёт заводские поля выгружаемого файла. +type Options struct { + ConnectorID string + ProductName string + CaseModel string + Model string + ProductionDate time.Time +} + +func (o Options) withDefaults() Options { + if o.ConnectorID == "" { + o.ConnectorID = DefaultConnectorID + } + if o.ProductName == "" { + o.ProductName = DefaultProductName + } + if o.CaseModel == "" { + o.CaseModel = DefaultCaseModel + } + if o.Model == "" { + o.Model = DefaultModel + } + if o.ProductionDate.IsZero() { + o.ProductionDate = time.Now() + } + return o +} + +type xmlTokens struct { + XMLName xml.Name `xml:"Tokens"` + Tokens []xmlToken `xml:"Token"` +} + +type xmlToken struct { + Serial string `xml:"serial,attr"` + CaseModel string `xml:"CaseModel,omitempty"` + Model string `xml:"Model,omitempty"` + ProductionDate string `xml:"ProductionDate,omitempty"` + ProductName string `xml:"ProductName,omitempty"` + Applications xmlApplications `xml:"Applications"` +} + +type xmlApplications struct { + Applications []xmlApplication `xml:"Application"` +} + +type xmlApplication struct { + ConnectorID string `xml:"ConnectorID,attr"` + Seed string `xml:"Seed"` + MovingFactor string `xml:"MovingFactor"` +} + +// WriteSafeNet выгружает токены в XML-файл инициализации. +func WriteSafeNet(w io.Writer, tokens []store.Token, o Options) error { + if len(tokens) == 0 { + return fmt.Errorf("нечего выгружать: список токенов пуст") + } + o = o.withDefaults() + + doc := xmlTokens{Tokens: make([]xmlToken, 0, len(tokens))} + for _, t := range tokens { + if err := store.ValidateSerial(t.Serial); err != nil { + return err + } + doc.Tokens = append(doc.Tokens, xmlToken{ + Serial: t.Serial, + CaseModel: o.CaseModel, + Model: o.Model, + ProductionDate: o.ProductionDate.Format("01/02/2006"), + ProductName: o.ProductName, + Applications: xmlApplications{Applications: []xmlApplication{{ + ConnectorID: o.ConnectorID, + Seed: strings.ToUpper(hex.EncodeToString(t.Secret)), + MovingFactor: strconv.FormatUint(t.Counter, 10), + }}}, + }) + } + + if _, err := io.WriteString(w, xml.Header); err != nil { + return err + } + enc := xml.NewEncoder(w) + enc.Indent("", " ") + if err := enc.Encode(doc); err != nil { + return fmt.Errorf("не удалось сформировать XML: %w", err) + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + return nil +} + +// ReadSafeNet читает файл инициализации. Отсутствующие в формате параметры +// восстанавливаются по тем же правилам, что применяет импортирующая сторона: +// тип hotp, шесть разрядов, алгоритм по длине seed. +func ReadSafeNet(r io.Reader) ([]store.Token, error) { + var doc xmlTokens + dec := xml.NewDecoder(r) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("не удалось разобрать XML: %w", err) + } + if doc.XMLName.Local != "Tokens" { + return nil, fmt.Errorf("корневой элемент %q, ожидался Tokens", doc.XMLName.Local) + } + if len(doc.Tokens) == 0 { + return nil, fmt.Errorf("в файле нет ни одного элемента Token") + } + + out := make([]store.Token, 0, len(doc.Tokens)) + for _, xt := range doc.Tokens { + if xt.Serial == "" { + return nil, fmt.Errorf("найден Token без атрибута serial") + } + if len(xt.Applications.Applications) == 0 { + return nil, fmt.Errorf("токен %s: нет элемента Application", xt.Serial) + } + app := xt.Applications.Applications[0] + + seed := strings.TrimSpace(app.Seed) + if seed == "" { + return nil, fmt.Errorf("токен %s: нет элемента Seed", xt.Serial) + } + secret, err := hex.DecodeString(seed) + if err != nil { + return nil, fmt.Errorf("токен %s: Seed не является корректным hex: %w", xt.Serial, err) + } + + var counter uint64 + if mf := strings.TrimSpace(app.MovingFactor); mf != "" { + if counter, err = strconv.ParseUint(mf, 10, 64); err != nil { + return nil, fmt.Errorf("токен %s: некорректный MovingFactor %q", xt.Serial, mf) + } + } + + alg := otp.SHA1 + if len(seed) == 64 { + alg = otp.SHA256 + } + // ProductName у заводских файлов — название модели, а не владелец; + // в метку оно попадает, только если несёт что-то своё. + label := strings.TrimSpace(xt.ProductName) + if label == DefaultProductName { + label = "" + } + t := store.Token{ + Serial: xt.Serial, Type: store.TypeHOTP, Secret: secret, + Digits: 6, Alg: alg, Counter: counter, Label: label, + } + if err := t.Validate(); err != nil { + return nil, err + } + out = append(out, t) + } + return out, nil +} + +// Warnings перечисляет параметры токена, которые формат SafeNet передать +// не может: сервер импортирует такой токен с настройками по умолчанию, +// и коды не сойдутся. +func Warnings(t store.Token) []string { + var w []string + if t.IsTOTP() { + w = append(w, "формат описывает только HOTP — режим TOTP и период будут потеряны") + } + if t.Digits != 6 { + w = append(w, fmt.Sprintf("формат не хранит разрядность — сервер импортирует токен как 6-разрядный, а не %d-разрядный", t.Digits)) + } + + // Импортирующая сторона выводит алгоритм из длины seed. + guessed := otp.SHA1 + if len(t.Secret) == 32 { + guessed = otp.SHA256 + } + if t.Alg != guessed { + w = append(w, fmt.Sprintf("формат не хранит алгоритм — при seed в %d байт сервер выберет %s вместо %s", + len(t.Secret), guessed, t.Alg)) + } + return w +} diff --git a/internal/initfile/safenet_test.go b/internal/initfile/safenet_test.go new file mode 100644 index 0000000..6ad9cf7 --- /dev/null +++ b/internal/initfile/safenet_test.go @@ -0,0 +1,190 @@ +package initfile + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" + "time" + + "hotpcalc/internal/otp" + "hotpcalc/internal/store" +) + +const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D" + +func testToken(t *testing.T) store.Token { + t.Helper() + secret, err := hex.DecodeString(testSeedHex) + if err != nil { + t.Fatal(err) + } + return store.Token{ + Serial: "0004000ABCDEF1", Type: store.TypeHOTP, Secret: secret, + Digits: 6, Alg: otp.SHA1, Counter: 1, + } +} + +// Эталон формата: структура сверена с parseSafeNetXML из privacyIDEA. +const golden = ` + + + 5 + 101 + 09/04/2026 + eToken PASS + + + 765D7268D012090C28172A729BC9CC1AD82B382D + 1 + + + + +` + +func TestWriteSafeNetMatchesGolden(t *testing.T) { + var buf bytes.Buffer + opts := Options{ProductionDate: time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)} + if err := WriteSafeNet(&buf, []store.Token{testToken(t)}, opts); err != nil { + t.Fatalf("WriteSafeNet: %v", err) + } + if buf.String() != golden { + t.Errorf("вывод не совпал с эталоном:\n--- получено ---\n%s\n--- ожидалось ---\n%s", + buf.String(), golden) + } +} + +func TestRoundTrip(t *testing.T) { + want := []store.Token{testToken(t)} + want = append(want, store.Token{ + Serial: "0004000ABCDEF2", Type: store.TypeHOTP, Secret: want[0].Secret, + Digits: 6, Alg: otp.SHA1, Counter: 123456, + }) + + var buf bytes.Buffer + if err := WriteSafeNet(&buf, want, Options{}); err != nil { + t.Fatal(err) + } + got, err := ReadSafeNet(&buf) + if err != nil { + t.Fatalf("ReadSafeNet: %v", err) + } + if len(got) != len(want) { + t.Fatalf("прочитано %d токенов, ожидалось %d", len(got), len(want)) + } + for i := range want { + if got[i].Serial != want[i].Serial || got[i].Counter != want[i].Counter || + !bytes.Equal(got[i].Secret, want[i].Secret) || got[i].Alg != want[i].Alg || + got[i].Digits != want[i].Digits || got[i].Type != want[i].Type { + t.Errorf("токен %d: получено %+v, ожидалось %+v", i, got[i], want[i]) + } + } +} + +func TestReadSafeNetSample(t *testing.T) { + // Образец в том виде, в каком формат описан у privacyIDEA/LinOTP. + const sample = ` + + + 5 + 101 + 02/19/2009 + Safeword Alpine + + + 3132333435363738393031323334353637383930 + 1 + + + +` + got, err := ReadSafeNet(strings.NewReader(sample)) + if err != nil { + t.Fatalf("ReadSafeNet: %v", err) + } + if len(got) != 1 { + t.Fatalf("прочитано %d токенов", len(got)) + } + tok := got[0] + if tok.Serial != "000400081DFFA5" || tok.Counter != 1 || tok.Label != "Safeword Alpine" { + t.Errorf("получено %+v", tok) + } + // Seed из RFC 4226: счётчик 0 даёт 755224. + if code := tok.Config().HOTP(0); code != "755224" { + t.Errorf("HOTP(0) = %s, ожидалось 755224 — seed прочитан неверно", code) + } +} + +func TestReadSafeNetAlgorithmFromSeedLength(t *testing.T) { + long := strings.Repeat("AB", 32) // 64 hex-символа + x := `` + + `` + long + `0` + + `` + got, err := ReadSafeNet(strings.NewReader(x)) + if err != nil { + t.Fatal(err) + } + if got[0].Alg != otp.SHA256 { + t.Errorf("для 64-символьного seed ожидался SHA256, получено %s", got[0].Alg) + } +} + +func TestReadSafeNetErrors(t *testing.T) { + cases := map[string]string{ + "не XML": "просто текст", + "чужой корень": ``, + "нет токенов": ``, + "нет serial": `AA`, + "нет Application": ``, + "нет Seed": `0`, + "Seed не hex": `ZZZZ`, + "MovingFactor не чис": `` + testSeedHex + `abc`, + } + for name, in := range cases { + if _, err := ReadSafeNet(strings.NewReader(in)); err == nil { + t.Errorf("%s: ожидалась ошибка", name) + } + } +} + +func TestWriteSafeNetRejectsEmpty(t *testing.T) { + var buf bytes.Buffer + if err := WriteSafeNet(&buf, nil, Options{}); err == nil { + t.Error("пустой список должен отвергаться") + } +} + +func TestWarnings(t *testing.T) { + ok := testToken(t) + if w := Warnings(ok); len(w) != 0 { + t.Errorf("для обычного HOTP-токена предупреждений быть не должно: %v", w) + } + + totp := ok + totp.Type = store.TypeTOTP + if w := Warnings(totp); len(w) != 1 || !strings.Contains(w[0], "только HOTP") { + t.Errorf("TOTP: %v", w) + } + + eight := ok + eight.Digits = 8 + if w := Warnings(eight); len(w) != 1 || !strings.Contains(w[0], "разрядность") { + t.Errorf("8 разрядов: %v", w) + } + + // SHA256 при 20-байтном seed сервер прочитает как SHA1. + sha := ok + sha.Alg = otp.SHA256 + if w := Warnings(sha); len(w) != 1 || !strings.Contains(w[0], "алгоритм") { + t.Errorf("SHA256 с коротким seed: %v", w) + } + + // SHA256 при 32-байтном seed передаётся корректно. + good := ok + good.Alg = otp.SHA256 + good.Secret = bytes.Repeat([]byte{1}, 32) + if w := Warnings(good); len(w) != 0 { + t.Errorf("SHA256 с 32-байтным seed не должен давать предупреждений: %v", w) + } +} diff --git a/internal/otp/otp.go b/internal/otp/otp.go new file mode 100644 index 0000000..6781c2a --- /dev/null +++ b/internal/otp/otp.go @@ -0,0 +1,135 @@ +// Package otp реализует алгоритмы одноразовых паролей HOTP (RFC 4226) +// и TOTP (RFC 6238) в том виде, в каком их использует SafeNet eToken PASS. +package otp + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/binary" + "fmt" + "hash" + "strings" + "time" +) + +// Algorithm — хеш, на котором строится HMAC. eToken PASS использует SHA1. +type Algorithm int + +const ( + SHA1 Algorithm = iota + SHA256 + SHA512 +) + +func (a Algorithm) String() string { + switch a { + case SHA256: + return "SHA256" + case SHA512: + return "SHA512" + default: + return "SHA1" + } +} + +func (a Algorithm) new() func() hash.Hash { + switch a { + case SHA256: + return sha256.New + case SHA512: + return sha512.New + default: + return sha1.New + } +} + +// ParseAlgorithm разбирает название алгоритма без учёта регистра. +func ParseAlgorithm(s string) (Algorithm, error) { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "SHA1", "SHA-1", "": + return SHA1, nil + case "SHA256", "SHA-256": + return SHA256, nil + case "SHA512", "SHA-512": + return SHA512, nil + } + return SHA1, fmt.Errorf("неизвестный алгоритм %q (допустимо: sha1, sha256, sha512)", s) +} + +// DefaultStep — шаг времени TOTP по умолчанию (RFC 6238). +const DefaultStep = 30 * time.Second + +// Config описывает параметры конкретного токена. +type Config struct { + Secret []byte // вектор инициализации (seed) + Digits int // разрядность кода: 6 или 8 + Alg Algorithm // хеш для HMAC + Step time.Duration // шаг времени, только для TOTP +} + +// Validate проверяет параметры на осмысленность. +func (c Config) Validate() error { + if len(c.Secret) == 0 { + return fmt.Errorf("пустой seed") + } + if c.Digits < 6 || c.Digits > 10 { + return fmt.Errorf("разрядность %d вне допустимого диапазона 6..10", c.Digits) + } + if c.Step <= 0 { + return fmt.Errorf("шаг времени должен быть положительным") + } + return nil +} + +var pow10 = [...]uint32{1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000} + +// HOTP считает одноразовый пароль для указанного значения счётчика (RFC 4226). +func (c Config) HOTP(counter uint64) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + + mac := hmac.New(c.Alg.new(), c.Secret) + mac.Write(buf[:]) + sum := mac.Sum(nil) + + // Динамическое усечение: младшие 4 бита последнего байта задают смещение. + offset := sum[len(sum)-1] & 0x0F + code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF + + digits := c.Digits + if digits < 1 || digits >= len(pow10) { + digits = 6 + } + return fmt.Sprintf("%0*d", digits, code%pow10[digits]) +} + +// TimeStep возвращает номер временного шага T для момента t (RFC 6238). +func (c Config) TimeStep(t time.Time) uint64 { + step := c.Step + if step <= 0 { + step = DefaultStep + } + return uint64(t.Unix() / int64(step/time.Second)) +} + +// StepStart возвращает момент начала временного шага с номером step. +func (c Config) StepStart(step uint64) time.Time { + d := c.Step + if d <= 0 { + d = DefaultStep + } + return time.Unix(int64(step)*int64(d/time.Second), 0) +} + +// TOTP считает одноразовый пароль, действующий в момент t. +func (c Config) TOTP(t time.Time) string { + return c.HOTP(c.TimeStep(t)) +} + +// equalCode сравнивает коды за постоянное время. +func equalCode(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/otp/otp_test.go b/internal/otp/otp_test.go new file mode 100644 index 0000000..cd37484 --- /dev/null +++ b/internal/otp/otp_test.go @@ -0,0 +1,108 @@ +package otp + +import ( + "testing" + "time" +) + +// RFC 4226, Appendix D: ключ ASCII "12345678901234567890". +var rfc4226Key = []byte("12345678901234567890") + +func TestHOTP_RFC4226(t *testing.T) { + want := []string{ + "755224", "287082", "359152", "969429", "338314", + "254676", "287922", "162583", "399871", "520489", + } + c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep} + for i, w := range want { + if got := c.HOTP(uint64(i)); got != w { + t.Errorf("HOTP(%d) = %s, ожидалось %s", i, got, w) + } + } +} + +// RFC 6238, Appendix B. Ключи для SHA256/SHA512 — продолжение той же +// последовательности "12345678901234567890..." до размера блока хеша. +func seedRepeat(n int) []byte { + base := "12345678901234567890" + out := make([]byte, 0, n) + for len(out) < n { + out = append(out, base...) + } + return out[:n] +} + +func TestTOTP_RFC6238(t *testing.T) { + cases := []struct { + unix int64 + alg Algorithm + key []byte + want string + }{ + {59, SHA1, seedRepeat(20), "94287082"}, + {59, SHA256, seedRepeat(32), "46119246"}, + {59, SHA512, seedRepeat(64), "90693936"}, + {1111111109, SHA1, seedRepeat(20), "07081804"}, + {1111111111, SHA1, seedRepeat(20), "14050471"}, + {1234567890, SHA1, seedRepeat(20), "89005924"}, + {2000000000, SHA1, seedRepeat(20), "69279037"}, + {20000000000, SHA1, seedRepeat(20), "65353130"}, + {1111111109, SHA256, seedRepeat(32), "68084774"}, + {20000000000, SHA256, seedRepeat(32), "77737706"}, + {1111111109, SHA512, seedRepeat(64), "25091201"}, + {20000000000, SHA512, seedRepeat(64), "47863826"}, + } + for _, tc := range cases { + c := Config{Secret: tc.key, Digits: 8, Alg: tc.alg, Step: DefaultStep} + got := c.TOTP(time.Unix(tc.unix, 0).UTC()) + if got != tc.want { + t.Errorf("TOTP(%d, %s) = %s, ожидалось %s", tc.unix, tc.alg, got, tc.want) + } + } +} + +func TestHOTP_LeadingZeros(t *testing.T) { + c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep} + for i := uint64(0); i < 5000; i++ { + if got := c.HOTP(i); len(got) != 6 { + t.Fatalf("HOTP(%d) = %q: длина %d вместо 6", i, got, len(got)) + } + } +} + +func TestTimeStepAndStepStart(t *testing.T) { + c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep} + if got := c.TimeStep(time.Unix(59, 0)); got != 1 { + t.Errorf("TimeStep(59) = %d, ожидалось 1", got) + } + if got := c.StepStart(1).Unix(); got != 30 { + t.Errorf("StepStart(1) = %d, ожидалось 30", got) + } + c60 := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: 60 * time.Second} + if got := c60.TimeStep(time.Unix(119, 0)); got != 1 { + t.Errorf("TimeStep(119, шаг 60с) = %d, ожидалось 1", got) + } +} + +func TestValidate(t *testing.T) { + if err := (Config{Digits: 6, Step: DefaultStep}).Validate(); err == nil { + t.Error("пустой seed должен отвергаться") + } + if err := (Config{Secret: rfc4226Key, Digits: 4, Step: DefaultStep}).Validate(); err == nil { + t.Error("разрядность 4 должна отвергаться") + } + if err := (Config{Secret: rfc4226Key, Digits: 6}).Validate(); err == nil { + t.Error("нулевой шаг должен отвергаться") + } +} + +func TestParseAlgorithm(t *testing.T) { + for _, s := range []string{"sha1", "SHA-1", ""} { + if a, err := ParseAlgorithm(s); err != nil || a != SHA1 { + t.Errorf("ParseAlgorithm(%q) = %v, %v", s, a, err) + } + } + if _, err := ParseAlgorithm("md5"); err == nil { + t.Error("md5 должен отвергаться") + } +} diff --git a/internal/otp/resync.go b/internal/otp/resync.go new file mode 100644 index 0000000..8dd40e2 --- /dev/null +++ b/internal/otp/resync.go @@ -0,0 +1,87 @@ +package otp + +import ( + "errors" + "fmt" + "math" + "time" +) + +// Match — найденная позиция токена. Counter соответствует ПЕРВОМУ из двух +// введённых кодов; второй код приходится на Counter+1. +type Match struct { + Counter uint64 + Time time.Time // начало окна первого кода; заполняется только для TOTP +} + +// ErrNotFound возвращается, когда пара кодов не найдена в заданном диапазоне. +type ErrNotFound struct { + From, To uint64 + Mode string +} + +func (e *ErrNotFound) Error() string { + return fmt.Sprintf("пара кодов не найдена в диапазоне %s %d..%d", e.Mode, e.From, e.To) +} + +// FindCounter ищет значение счётчика HOTP, при котором два введённых кода +// идут подряд. Перебираются счётчики [start, start+span). +func FindCounter(c Config, otp1, otp2 string, start, span uint64) ([]Match, error) { + if err := c.Validate(); err != nil { + return nil, err + } + if span < 2 { + return nil, fmt.Errorf("диапазон перебора должен быть не меньше 2") + } + end := start + span - 1 // включительно; защита от переполнения ниже + if end < start { + end = math.MaxUint64 + } + + var matches []Match + prev := c.HOTP(start) + for i := start; i < end; i++ { + cur := c.HOTP(i + 1) + if equalCode(prev, otp1) && equalCode(cur, otp2) { + matches = append(matches, Match{Counter: i}) + } + prev = cur + } + if len(matches) == 0 { + return nil, &ErrNotFound{From: start, To: end, Mode: "счётчика"} + } + return matches, nil +} + +// FindTimeStep ищет временной шаг TOTP, при котором два введённых кода идут +// подряд. Перебор идёт вокруг момента now в пределах ±window. +func FindTimeStep(c Config, otp1, otp2 string, now time.Time, window time.Duration) ([]Match, error) { + if err := c.Validate(); err != nil { + return nil, err + } + if window < 0 { + return nil, fmt.Errorf("окно поиска не может быть отрицательным") + } + + center := c.TimeStep(now) + half := uint64(window / c.Step) + + start := uint64(0) + if center > half { + start = center - half + } + span := half*2 + 2 // +2, чтобы центр и обе границы попали в пары + + matches, err := FindCounter(c, otp1, otp2, start, span) + if err != nil { + var nf *ErrNotFound + if errors.As(err, &nf) { + nf.Mode = "временного шага" + } + return nil, err + } + for i := range matches { + matches[i].Time = c.StepStart(matches[i].Counter) + } + return matches, nil +} diff --git a/internal/otp/resync_test.go b/internal/otp/resync_test.go new file mode 100644 index 0000000..080b5df --- /dev/null +++ b/internal/otp/resync_test.go @@ -0,0 +1,139 @@ +package otp + +import ( + "errors" + "testing" + "time" +) + +func exampleConfig(t *testing.T) Config { + t.Helper() + s, err := ParseSeed(exampleSeed, FormatAuto) + if err != nil { + t.Fatalf("ParseSeed: %v", err) + } + return Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep} +} + +func TestFindCounter_RoundTrip(t *testing.T) { + c := exampleConfig(t) + const want = uint64(41) + + m, err := FindCounter(c, c.HOTP(want), c.HOTP(want+1), 0, 100000) + if err != nil { + t.Fatalf("FindCounter: %v", err) + } + if len(m) != 1 { + t.Fatalf("найдено %d совпадений, ожидалось 1: %+v", len(m), m) + } + if m[0].Counter != want { + t.Errorf("счётчик = %d, ожидалось %d", m[0].Counter, want) + } +} + +func TestFindCounter_RFCVectors(t *testing.T) { + // Пара 287082/359152 из RFC 4226 — это счётчики 1 и 2. + c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep} + m, err := FindCounter(c, "287082", "359152", 0, 1000) + if err != nil { + t.Fatalf("FindCounter: %v", err) + } + if m[0].Counter != 1 { + t.Errorf("счётчик = %d, ожидалось 1", m[0].Counter) + } +} + +func TestFindCounter_NotFound(t *testing.T) { + c := exampleConfig(t) + _, err := FindCounter(c, "000000", "000000", 0, 1000) + var nf *ErrNotFound + if !errors.As(err, &nf) { + t.Fatalf("ожидалась ErrNotFound, получено %v", err) + } +} + +func TestFindCounter_OutOfRange(t *testing.T) { + c := exampleConfig(t) + // Позиция 500 лежит вне диапазона [0, 100). + if _, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 0, 100); err == nil { + t.Error("ожидалась ошибка: позиция вне диапазона перебора") + } + // С правильным смещением находится. + m, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 450, 100) + if err != nil { + t.Fatalf("FindCounter со смещением: %v", err) + } + if m[0].Counter != 500 { + t.Errorf("счётчик = %d, ожидалось 500", m[0].Counter) + } +} + +func TestFindCounter_SpanTooSmall(t *testing.T) { + c := exampleConfig(t) + if _, err := FindCounter(c, "123456", "654321", 0, 1); err == nil { + t.Error("span < 2 должен отвергаться") + } +} + +func TestFindCounter_ReversedOrderNotMatched(t *testing.T) { + c := exampleConfig(t) + // Коды, поданные в обратном порядке, не должны находиться. + if _, err := FindCounter(c, c.HOTP(11), c.HOTP(10), 0, 10000); err == nil { + t.Error("перепутанный порядок кодов не должен давать совпадение") + } +} + +func TestFindTimeStep_RoundTrip(t *testing.T) { + c := exampleConfig(t) + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // Токен «спешит» на 2 минуты: коды сняты на 4 шага вперёд. + step := c.TimeStep(now) + 4 + m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Hour) + if err != nil { + t.Fatalf("FindTimeStep: %v", err) + } + if len(m) != 1 { + t.Fatalf("найдено %d совпадений, ожидалось 1", len(m)) + } + if m[0].Counter != step { + t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step) + } + if want := c.StepStart(step); !m[0].Time.Equal(want) { + t.Errorf("время = %v, ожидалось %v", m[0].Time, want) + } +} + +func TestFindTimeStep_Step60(t *testing.T) { + s, _ := ParseSeed(exampleSeed, FormatAuto) + c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: 60 * time.Second} + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + step := c.TimeStep(now) - 10 + m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, 24*time.Hour) + if err != nil { + t.Fatalf("FindTimeStep: %v", err) + } + if m[0].Counter != step { + t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step) + } +} + +func TestFindTimeStep_OutsideWindow(t *testing.T) { + c := exampleConfig(t) + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + step := c.TimeStep(now) + 10000 + if _, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Minute); err == nil { + t.Error("ожидалась ошибка: позиция вне окна поиска") + } +} + +func BenchmarkFindCounter(b *testing.B) { + s, _ := ParseSeed(exampleSeed, FormatAuto) + c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep} + o1, o2 := c.HOTP(999998), c.HOTP(999999) + for b.Loop() { + if _, err := FindCounter(c, o1, o2, 0, 1000000); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/otp/seed.go b/internal/otp/seed.go new file mode 100644 index 0000000..1f8d81f --- /dev/null +++ b/internal/otp/seed.go @@ -0,0 +1,186 @@ +package otp + +import ( + "encoding/base32" + "encoding/hex" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +// SeedFormat — способ кодирования вектора инициализации. +type SeedFormat string + +const ( + FormatAuto SeedFormat = "auto" + FormatHex SeedFormat = "hex" + FormatBase32 SeedFormat = "base32" +) + +// Seed — разобранный вектор инициализации вместе с параметрами, +// которые удалось извлечь из otpauth:// URI (если он был на входе). +type Seed struct { + Secret []byte + Format SeedFormat // фактически распознанный формат + + // Параметры из otpauth:// URI; nil, если не заданы. + Digits *int + Alg *Algorithm + Step *time.Duration + Counter *uint64 + Label string + TOTP bool // true, если URI описывает totp + FromURI bool +} + +// cleanSeed убирает разделители, которыми seed обычно печатают на бумаге. +func cleanSeed(s string) string { + return strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '\n', '\r', '-', ':', '_', '.': + return -1 + } + return r + }, s) +} + +func isHexString(s string) bool { + if s == "" || len(s)%2 != 0 { + return false + } + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +func decodeBase32(s string) ([]byte, error) { + s = strings.ToUpper(strings.TrimRight(s, "=")) + if s == "" { + return nil, fmt.Errorf("пустая строка") + } + if pad := len(s) % 8; pad != 0 { + s += strings.Repeat("=", 8-pad) + } + return base32.StdEncoding.DecodeString(s) +} + +// ParseSeed разбирает вектор инициализации в hex, Base32 или otpauth:// URI. +func ParseSeed(s string, format SeedFormat) (Seed, error) { + raw := strings.TrimSpace(s) + if raw == "" { + return Seed{}, fmt.Errorf("seed не задан") + } + + if strings.HasPrefix(strings.ToLower(raw), "otpauth://") { + return parseOtpauth(raw) + } + + clean := cleanSeed(raw) + if clean == "" { + return Seed{}, fmt.Errorf("seed не содержит значащих символов") + } + + switch format { + case FormatHex: + b, err := hex.DecodeString(clean) + if err != nil { + return Seed{}, fmt.Errorf("seed не является корректным hex: %w", err) + } + return Seed{Secret: b, Format: FormatHex}, nil + + case FormatBase32: + b, err := decodeBase32(clean) + if err != nil { + return Seed{}, fmt.Errorf("seed не является корректным Base32: %w", err) + } + return Seed{Secret: b, Format: FormatBase32}, nil + + case FormatAuto, "": + if isHexString(clean) { + b, err := hex.DecodeString(clean) + if err == nil { + return Seed{Secret: b, Format: FormatHex}, nil + } + } + b, err := decodeBase32(clean) + if err != nil { + return Seed{}, fmt.Errorf("seed не распознан ни как hex, ни как Base32: %w", err) + } + return Seed{Secret: b, Format: FormatBase32}, nil + } + + return Seed{}, fmt.Errorf("неизвестный формат seed %q (допустимо: auto, hex, base32)", format) +} + +func parseOtpauth(raw string) (Seed, error) { + u, err := url.Parse(raw) + if err != nil { + return Seed{}, fmt.Errorf("не удалось разобрать otpauth URI: %w", err) + } + q := u.Query() + + secret := q.Get("secret") + if secret == "" { + return Seed{}, fmt.Errorf("в otpauth URI нет параметра secret") + } + b, err := decodeBase32(cleanSeed(secret)) + if err != nil { + return Seed{}, fmt.Errorf("secret из otpauth URI не является корректным Base32: %w", err) + } + + sd := Seed{ + Secret: b, + Format: FormatBase32, + Label: strings.TrimPrefix(u.Path, "/"), + TOTP: strings.EqualFold(u.Host, "totp"), + FromURI: true, + } + + if v := q.Get("digits"); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return Seed{}, fmt.Errorf("некорректный digits в otpauth URI: %w", err) + } + sd.Digits = &n + } + if v := q.Get("algorithm"); v != "" { + a, err := ParseAlgorithm(v) + if err != nil { + return Seed{}, err + } + sd.Alg = &a + } + if v := q.Get("period"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return Seed{}, fmt.Errorf("некорректный period в otpauth URI: %q", v) + } + d := time.Duration(n) * time.Second + sd.Step = &d + } + if v := q.Get("counter"); v != "" { + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return Seed{}, fmt.Errorf("некорректный counter в otpauth URI: %w", err) + } + sd.Counter = &n + } + return sd, nil +} + +// Fingerprint возвращает укороченное представление seed для вывода на экран, +// чтобы не печатать секрет целиком. +func Fingerprint(secret []byte) string { + h := strings.ToUpper(hex.EncodeToString(secret)) + if len(h) <= 12 { + return h + } + return h[:8] + "…" + h[len(h)-4:] +} diff --git a/internal/otp/seed_test.go b/internal/otp/seed_test.go new file mode 100644 index 0000000..57d9683 --- /dev/null +++ b/internal/otp/seed_test.go @@ -0,0 +1,117 @@ +package otp + +import ( + "encoding/hex" + "strings" + "testing" + "time" +) + +// Seed из примера задачи: eToken PASS, 20 байт в hex. +const exampleSeed = "765D7268D012090C28172A729BC9CC1AD82B382D" + +func TestParseSeed_Hex(t *testing.T) { + for _, in := range []string{ + exampleSeed, + strings.ToLower(exampleSeed), + "765D 7268 D012 090C 2817 2A72 9BC9 CC1A D82B 382D", + "76-5D-72-68-D0-12-09-0C-28-17-2A-72-9B-C9-CC-1A-D8-2B-38-2D", + } { + s, err := ParseSeed(in, FormatAuto) + if err != nil { + t.Fatalf("ParseSeed(%q): %v", in, err) + } + if s.Format != FormatHex { + t.Errorf("ParseSeed(%q): формат %s, ожидался hex", in, s.Format) + } + if got := strings.ToUpper(hex.EncodeToString(s.Secret)); got != exampleSeed { + t.Errorf("ParseSeed(%q) = %s", in, got) + } + } +} + +func TestParseSeed_Base32(t *testing.T) { + // Base32 от ASCII-ключа "12345678901234567890" из RFC 4226. + const b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + s, err := ParseSeed(b32, FormatAuto) + if err != nil { + t.Fatalf("ParseSeed: %v", err) + } + if s.Format != FormatBase32 { + t.Errorf("формат %s, ожидался base32", s.Format) + } + if string(s.Secret) != string(rfc4226Key) { + t.Errorf("secret = %q, ожидалось %q", s.Secret, rfc4226Key) + } + // Без padding и в нижнем регистре тоже должно работать. + if s2, err := ParseSeed("gezdgnbvgy3tqojq", FormatAuto); err != nil || len(s2.Secret) != 10 { + t.Errorf("Base32 без padding: %v, %d байт", err, len(s2.Secret)) + } +} + +func TestParseSeed_ForcedFormat(t *testing.T) { + // "12345678" — валидный hex и валидный набор символов не для Base32. + if _, err := ParseSeed(exampleSeed, FormatBase32); err == nil { + t.Error("hex-строка с цифрами 0/1 не должна разбираться как Base32") + } + if s, err := ParseSeed(exampleSeed, FormatHex); err != nil || len(s.Secret) != 20 { + t.Errorf("принудительный hex: %v", err) + } +} + +func TestParseSeed_Errors(t *testing.T) { + for _, in := range []string{"", " ", "zzzz!!!", "765D7268D"} { + if _, err := ParseSeed(in, FormatAuto); err == nil { + t.Errorf("ParseSeed(%q) должен возвращать ошибку", in) + } + } +} + +func TestParseSeed_OtpauthURI(t *testing.T) { + uri := "otpauth://totp/ACME:user@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + "&issuer=ACME&digits=8&algorithm=SHA256&period=60" + s, err := ParseSeed(uri, FormatAuto) + if err != nil { + t.Fatalf("ParseSeed: %v", err) + } + if !s.FromURI || !s.TOTP { + t.Error("URI должен распознаваться как TOTP") + } + if s.Digits == nil || *s.Digits != 8 { + t.Error("digits из URI не подхвачен") + } + if s.Alg == nil || *s.Alg != SHA256 { + t.Error("algorithm из URI не подхвачен") + } + if s.Step == nil || *s.Step != 60*time.Second { + t.Error("period из URI не подхвачен") + } + if s.Label != "ACME:user@example.com" { + t.Errorf("label = %q", s.Label) + } + + hotpURI := "otpauth://hotp/tok?secret=GEZDGNBVGY3TQOJQ&counter=42" + h, err := ParseSeed(hotpURI, FormatAuto) + if err != nil { + t.Fatalf("ParseSeed(hotp): %v", err) + } + if h.TOTP { + t.Error("hotp URI не должен помечаться как TOTP") + } + if h.Counter == nil || *h.Counter != 42 { + t.Error("counter из URI не подхвачен") + } + if _, err := ParseSeed("otpauth://totp/x?issuer=ACME", FormatAuto); err == nil { + t.Error("URI без secret должен отвергаться") + } +} + +func TestFingerprint(t *testing.T) { + s, _ := ParseSeed(exampleSeed, FormatAuto) + if got := Fingerprint(s.Secret); got != "765D7268…382D" { + t.Errorf("Fingerprint = %q", got) + } + if got := Fingerprint([]byte{0x01, 0x02}); got != "0102" { + t.Errorf("короткий Fingerprint = %q", got) + } +} diff --git a/internal/store/generate.go b/internal/store/generate.go new file mode 100644 index 0000000..68ca193 --- /dev/null +++ b/internal/store/generate.go @@ -0,0 +1,62 @@ +package store + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" +) + +const ( + // SerialLength — длина серийного номера eToken PASS в символах. + SerialLength = 14 + // DefaultSerialPrefix — префикс серийных номеров eToken PASS. + DefaultSerialPrefix = "000400" + // SeedBytes — длина вектора инициализации в байтах. + SeedBytes = 20 +) + +// NewSeed генерирует криптографически стойкий вектор инициализации. +func NewSeed(n int) ([]byte, error) { + if n <= 0 { + n = SeedBytes + } + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("не удалось получить случайные данные: %w", err) + } + return b, nil +} + +// NewSerial генерирует серийный номер: заданный префикс и случайный +// шестнадцатеричный хвост до общей длины SerialLength. +func NewSerial(prefix string) (string, error) { + prefix = strings.ToUpper(strings.TrimSpace(prefix)) + if err := ValidateSerial(prefix + "0"); err != nil { + return "", fmt.Errorf("некорректный префикс серийного номера: %w", err) + } + + suffix := SerialLength - len(prefix) + if suffix < 4 { + suffix = 4 + } + b := make([]byte, (suffix+1)/2) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("не удалось получить случайные данные: %w", err) + } + return prefix + strings.ToUpper(hex.EncodeToString(b))[:suffix], nil +} + +// NewUniqueSerial подбирает серийный номер, которого ещё нет в хранилище. +func (s *Store) NewUniqueSerial(prefix string) (string, error) { + for i := 0; i < 100; i++ { + serial, err := NewSerial(prefix) + if err != nil { + return "", err + } + if s.file.Section(serial) == nil { + return serial, nil + } + } + return "", fmt.Errorf("не удалось подобрать свободный серийный номер с префиксом %q", prefix) +} diff --git a/internal/store/lock.go b/internal/store/lock.go new file mode 100644 index 0000000..edd6881 --- /dev/null +++ b/internal/store/lock.go @@ -0,0 +1,82 @@ +package store + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + // lockTimeout — сколько ждём освобождения хранилища другим процессом. + lockTimeout = 5 * time.Second + // lockStaleAfter — после этого возраста замок считается брошенным + // (процесс убит, не успев его снять). + lockStaleAfter = 60 * time.Second +) + +// Lock — межпроцессная блокировка хранилища на основе файла, создаваемого +// с O_EXCL. В отличие от flock работает одинаково на Unix и Windows. +type Lock struct { + path string + warn func(string) +} + +// acquireLock захватывает замок для файла хранилища path. +// warn получает предупреждения (обычно печатается в stderr); может быть nil. +func acquireLock(path string, warn func(string)) (*Lock, error) { + if warn == nil { + warn = func(string) {} + } + l := &Lock{path: path + ".lock", warn: warn} + + deadline := time.Now().Add(lockTimeout) + delay := 10 * time.Millisecond + brokeStale := false + + for { + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + fmt.Fprintf(f, "pid %d, %s\n", os.Getpid(), time.Now().Format(time.RFC3339)) + f.Close() + return l, nil + } + if !os.IsExist(err) { + return nil, fmt.Errorf("не удалось создать файл блокировки %s: %w", l.path, err) + } + + // Замок занят: возможно, его владелец давно умер. + if !brokeStale { + if fi, statErr := os.Stat(l.path); statErr == nil && time.Since(fi.ModTime()) > lockStaleAfter { + l.warn(fmt.Sprintf("снимаю брошенный файл блокировки %s (возраст %s)", + l.path, time.Since(fi.ModTime()).Round(time.Second))) + os.Remove(l.path) + brokeStale = true + continue + } + } + + if time.Now().After(deadline) { + return nil, fmt.Errorf("хранилище занято другим процессом: файл блокировки %s.\n"+ + "Если процесс уже завершён, удалите этот файл вручную", l.path) + } + time.Sleep(delay) + if delay < 200*time.Millisecond { + delay *= 2 + } + } +} + +// release снимает блокировку. +func (l *Lock) release() error { + if l == nil { + return nil + } + if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("не удалось снять блокировку %s: %w", l.path, err) + } + return nil +} + +// IsLockPath сообщает, является ли путь служебным файлом блокировки. +func IsLockPath(p string) bool { return strings.HasSuffix(p, ".lock") } diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..620831c --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,350 @@ +// Package store хранит токены и их состояние в ini-файле. +package store + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "hotpcalc/internal/ini" + "hotpcalc/internal/otp" +) + +// Типы токенов. +const ( + TypeHOTP = "hotp" + TypeTOTP = "totp" +) + +// EnvStore — переменная окружения, задающая путь к хранилищу. +const EnvStore = "HOTPCALC_STORE" + +// FileName — имя файла хранилища по умолчанию. +const FileName = "tokens.ini" + +// Token — токен и его текущее состояние. +type Token struct { + Serial string + Type string + Secret []byte + Digits int + Alg otp.Algorithm + Period time.Duration // только TOTP + Counter uint64 // HOTP: счётчик СЛЕДУЮЩЕГО значения + LastStep uint64 // TOTP: последний выданный временной шаг + Label string + Created time.Time + LastUsed time.Time +} + +// IsTOTP сообщает, является ли токен временным. +func (t Token) IsTOTP() bool { return t.Type == TypeTOTP } + +// Config строит параметры расчёта для пакета otp. +func (t Token) Config() otp.Config { + step := t.Period + if step <= 0 { + step = otp.DefaultStep + } + return otp.Config{Secret: t.Secret, Digits: t.Digits, Alg: t.Alg, Step: step} +} + +// Validate проверяет токен перед записью в хранилище. +func (t Token) Validate() error { + if err := ValidateSerial(t.Serial); err != nil { + return err + } + if t.Type != TypeHOTP && t.Type != TypeTOTP { + return fmt.Errorf("токен %s: неизвестный тип %q (допустимо: hotp, totp)", t.Serial, t.Type) + } + if strings.ContainsAny(t.Label, "\r\n") { + return fmt.Errorf("токен %s: метка содержит перевод строки", t.Serial) + } + return t.Config().Validate() +} + +// ValidateSerial проверяет серийный номер: он становится именем секции ini. +func ValidateSerial(s string) error { + if s == "" { + return fmt.Errorf("пустой серийный номер") + } + if len(s) > 64 { + return fmt.Errorf("серийный номер длиннее 64 символов: %q", s) + } + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', + r == '-', r == '_', r == '.': + default: + return fmt.Errorf("недопустимый символ %q в серийном номере %q "+ + "(разрешены буквы, цифры, -, _ и точка)", r, s) + } + } + return nil +} + +// DefaultPath возвращает путь к хранилищу по умолчанию: переменная окружения +// HOTPCALC_STORE, иначе tokens.ini рядом с исполняемым файлом. +func DefaultPath() string { + if p := os.Getenv(EnvStore); p != "" { + return p + } + if exe, err := os.Executable(); err == nil { + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Join(filepath.Dir(exe), FileName) + } + return FileName +} + +// Store — открытое хранилище. Пока оно открыто, файл заблокирован +// для других процессов, поэтому закрывать его нужно всегда. +type Store struct { + path string + file *ini.File + lock *Lock +} + +var preamble = []string{ + "; hotpcalc — хранилище токенов", + "; Файл содержит секреты в открытом виде: защищайте его как пароль.", + "; Одна секция — один токен, имя секции — серийный номер.", +} + +// Open открывает хранилище, захватывая блокировку. Отсутствующий файл — +// не ошибка: он будет создан при первом Commit. +func Open(path string, warn func(string)) (*Store, error) { + if path == "" { + return nil, fmt.Errorf("не задан путь к хранилищу") + } + lock, err := acquireLock(path, warn) + if err != nil { + return nil, err + } + + s := &Store{path: path, lock: lock} + data, err := os.ReadFile(path) + switch { + case os.IsNotExist(err): + s.file = ini.New() + s.file.SetPreamble(preamble) + case err != nil: + lock.release() + return nil, fmt.Errorf("не удалось прочитать хранилище %s: %w", path, err) + default: + s.file, err = ini.Parse(strings.NewReader(string(data))) + if err != nil { + lock.release() + return nil, fmt.Errorf("хранилище %s повреждено: %w", path, err) + } + } + return s, nil +} + +// Close снимает блокировку. Несохранённые изменения теряются. +func (s *Store) Close() error { + if s == nil { + return nil + } + return s.lock.release() +} + +// Path возвращает путь к файлу хранилища. +func (s *Store) Path() string { return s.path } + +// Serials возвращает серийные номера в порядке их появления в файле. +func (s *Store) Serials() []string { return s.file.Sections() } + +// Get читает токен по серийному номеру. +func (s *Store) Get(serial string) (Token, bool, error) { + sec := s.file.Section(serial) + if sec == nil { + return Token{}, false, nil + } + t, err := tokenFromSection(serial, sec) + if err != nil { + return Token{}, true, err + } + return t, true, nil +} + +// List возвращает все токены хранилища. +func (s *Store) List() ([]Token, error) { + var out []Token + for _, serial := range s.file.Sections() { + t, _, err := s.Get(serial) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, nil +} + +// Put добавляет или обновляет токен. +func (s *Store) Put(t Token) error { + if err := t.Validate(); err != nil { + return err + } + sec := s.file.AddSection(t.Serial) + + sec.Set("type", t.Type) + sec.Set("seed", strings.ToUpper(hex.EncodeToString(t.Secret))) + sec.Set("digits", strconv.Itoa(t.Digits)) + sec.Set("algorithm", strings.ToLower(t.Alg.String())) + + if t.IsTOTP() { + sec.Unset("counter") + sec.Set("period", strconv.Itoa(int(t.Config().Step/time.Second))) + sec.Set("last_step", strconv.FormatUint(t.LastStep, 10)) + } else { + sec.Unset("period") + sec.Unset("last_step") + sec.Set("counter", strconv.FormatUint(t.Counter, 10)) + } + + setOrUnset(sec, "label", t.Label) + setOrUnset(sec, "created", formatTime(t.Created)) + setOrUnset(sec, "last_used", formatTime(t.LastUsed)) + return nil +} + +// Remove удаляет токен; возвращает true, если он был. +func (s *Store) Remove(serial string) bool { return s.file.RemoveSection(serial) } + +// Commit атомарно записывает хранилище на диск. +func (s *Store) Commit() error { + data, err := s.file.Bytes() + if err != nil { + return err + } + + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("не удалось создать каталог %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp-*") + if err != nil { + return fmt.Errorf("не удалось создать временный файл в %s: %w", dir, err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // не сработает после успешного Rename + + if err := tmp.Chmod(0o600); err != nil && !os.IsPermission(err) { + tmp.Close() + return fmt.Errorf("не удалось выставить права на %s: %w", tmpName, err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("не удалось записать %s: %w", tmpName, err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("не удалось сбросить на диск %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("не удалось закрыть %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("не удалось заменить %s: %w", s.path, err) + } + return nil +} + +func setOrUnset(sec *ini.Section, key, value string) { + if value == "" { + sec.Unset(key) + return + } + sec.Set(key, value) +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func tokenFromSection(serial string, sec *ini.Section) (Token, error) { + fail := func(format string, args ...any) (Token, error) { + return Token{}, fmt.Errorf("токен %s: %s", serial, fmt.Sprintf(format, args...)) + } + + t := Token{Serial: serial, Type: sec.Get("type")} + if t.Type == "" { + t.Type = TypeHOTP + } + if t.Type != TypeHOTP && t.Type != TypeTOTP { + return fail("неизвестный тип %q", t.Type) + } + + seed := sec.Get("seed") + if seed == "" { + return fail("не задан seed") + } + parsed, err := otp.ParseSeed(seed, otp.FormatAuto) + if err != nil { + return fail("%v", err) + } + t.Secret = parsed.Secret + + t.Digits = 6 + if v := sec.Get("digits"); v != "" { + if t.Digits, err = strconv.Atoi(v); err != nil { + return fail("некорректное значение digits %q", v) + } + } + if t.Alg, err = otp.ParseAlgorithm(sec.Get("algorithm")); err != nil { + return fail("%v", err) + } + + if t.IsTOTP() { + t.Period = otp.DefaultStep + if v := sec.Get("period"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return fail("некорректное значение period %q", v) + } + t.Period = time.Duration(n) * time.Second + } + if t.LastStep, err = parseUint(sec.Get("last_step")); err != nil { + return fail("некорректное значение last_step %q", sec.Get("last_step")) + } + } else if t.Counter, err = parseUint(sec.Get("counter")); err != nil { + return fail("некорректное значение counter %q", sec.Get("counter")) + } + + t.Label = sec.Get("label") + if t.Created, err = parseTime(sec.Get("created")); err != nil { + return fail("некорректное значение created %q", sec.Get("created")) + } + if t.LastUsed, err = parseTime(sec.Get("last_used")); err != nil { + return fail("некорректное значение last_used %q", sec.Get("last_used")) + } + + if err := t.Config().Validate(); err != nil { + return fail("%v", err) + } + return t, nil +} + +func parseUint(s string) (uint64, error) { + if s == "" { + return 0, nil + } + return strconv.ParseUint(s, 10, 64) +} + +func parseTime(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339, s) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..a9ceb57 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,321 @@ +package store + +import ( + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "hotpcalc/internal/otp" +) + +const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D" + +func testSeed(t *testing.T) []byte { + t.Helper() + b, err := hex.DecodeString(testSeedHex) + if err != nil { + t.Fatal(err) + } + return b +} + +func openTemp(t *testing.T) (*Store, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "tokens.ini") + s, err := Open(path, nil) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s, path +} + +func sampleToken(t *testing.T) Token { + return Token{ + Serial: "0004000ABCDEF1", Type: TypeHOTP, Secret: testSeed(t), + Digits: 6, Alg: otp.SHA1, Counter: 42, Label: "Иванов И.И.", + Created: time.Now().Truncate(time.Second), + } +} + +func TestPutGetCommitReopen(t *testing.T) { + s, path := openTemp(t) + want := sampleToken(t) + if err := s.Put(want); err != nil { + t.Fatalf("Put: %v", err) + } + if err := s.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + s.Close() + + s2, err := Open(path, nil) + if err != nil { + t.Fatalf("повторное открытие: %v", err) + } + defer s2.Close() + + got, ok, err := s2.Get(want.Serial) + if err != nil || !ok { + t.Fatalf("Get: %v, найден=%v", err, ok) + } + if got.Type != want.Type || got.Digits != want.Digits || got.Counter != want.Counter || + got.Label != want.Label || got.Alg != want.Alg { + t.Errorf("получено %+v, ожидалось %+v", got, want) + } + if hex.EncodeToString(got.Secret) != hex.EncodeToString(want.Secret) { + t.Errorf("seed не совпал: %X", got.Secret) + } + if !got.Created.Equal(want.Created) { + t.Errorf("created = %v, ожидалось %v", got.Created, want.Created) + } + + if _, ok, _ := s2.Get("НЕТ-ТАКОГО"); ok { + t.Error("несуществующий токен не должен находиться") + } +} + +func TestTOTPFieldsRoundTrip(t *testing.T) { + s, path := openTemp(t) + want := Token{ + Serial: "TOTP01", Type: TypeTOTP, Secret: testSeed(t), Digits: 8, + Alg: otp.SHA256, Period: 60 * time.Second, LastStep: 58907525, + } + if err := s.Put(want); err != nil { + t.Fatal(err) + } + if err := s.Commit(); err != nil { + t.Fatal(err) + } + s.Close() + + s2, _ := Open(path, nil) + defer s2.Close() + got, _, err := s2.Get("TOTP01") + if err != nil { + t.Fatal(err) + } + if got.Period != 60*time.Second || got.LastStep != want.LastStep || + got.Digits != 8 || got.Alg != otp.SHA256 { + t.Errorf("получено %+v", got) + } + + // Для TOTP counter не пишется, для HOTP не пишутся period/last_step. + data, _ := os.ReadFile(path) + if strings.Contains(string(data), "counter") { + t.Errorf("в TOTP-секции не должно быть counter:\n%s", data) + } +} + +func TestSwitchTypeClearsForeignKeys(t *testing.T) { + s, path := openTemp(t) + tok := sampleToken(t) + if err := s.Put(tok); err != nil { + t.Fatal(err) + } + tok.Type = TypeTOTP + tok.Period = 30 * time.Second + if err := s.Put(tok); err != nil { + t.Fatal(err) + } + if err := s.Commit(); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if strings.Contains(string(data), "counter") { + t.Errorf("counter должен исчезнуть при смене типа на totp:\n%s", data) + } +} + +func TestListAndRemove(t *testing.T) { + s, _ := openTemp(t) + a := sampleToken(t) + b := sampleToken(t) + b.Serial = "0004000ABCDEF2" + if err := s.Put(a); err != nil { + t.Fatal(err) + } + if err := s.Put(b); err != nil { + t.Fatal(err) + } + + list, err := s.List() + if err != nil { + t.Fatal(err) + } + if len(list) != 2 || list[0].Serial != a.Serial || list[1].Serial != b.Serial { + t.Errorf("List вернул %+v", list) + } + if !s.Remove(a.Serial) || s.Remove(a.Serial) { + t.Error("Remove работает неверно") + } + if got := s.Serials(); len(got) != 1 || got[0] != b.Serial { + t.Errorf("после удаления Serials() = %v", got) + } +} + +func TestFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("на Windows права доступа задаются иначе") + } + s, path := openTemp(t) + if err := s.Put(sampleToken(t)); err != nil { + t.Fatal(err) + } + if err := s.Commit(); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("права на хранилище = %04o, ожидалось 0600", perm) + } +} + +func TestValidation(t *testing.T) { + s, _ := openTemp(t) + bad := []struct { + name string + alter func(*Token) + }{ + {"пустой серийник", func(t *Token) { t.Serial = "" }}, + {"пробел в серийнике", func(t *Token) { t.Serial = "AB CD" }}, + {"скобка в серийнике", func(t *Token) { t.Serial = "A]B" }}, + {"неизвестный тип", func(t *Token) { t.Type = "ocra" }}, + {"пустой seed", func(t *Token) { t.Secret = nil }}, + {"разрядность 4", func(t *Token) { t.Digits = 4 }}, + {"перевод строки в метке", func(t *Token) { t.Label = "две\nстроки" }}, + } + for _, tc := range bad { + tok := sampleToken(t) + tc.alter(&tok) + if err := s.Put(tok); err == nil { + t.Errorf("%s: ожидалась ошибка", tc.name) + } + } +} + +func TestOpenBrokenStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.ini") + os.WriteFile(path, []byte("это не ini\n"), 0o600) + if _, err := Open(path, nil); err == nil { + t.Error("битый файл должен давать ошибку") + } + // Замок при неудачном открытии не должен оставаться. + if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) { + t.Error("файл блокировки остался после неудачного Open") + } +} + +func TestBrokenTokenSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.ini") + os.WriteFile(path, []byte("[AAA]\nseed = не-hex-и-не-base32!\n"), 0o600) + s, err := Open(path, nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, _, err := s.Get("AAA"); err == nil { + t.Error("некорректный seed должен давать ошибку при чтении токена") + } +} + +func TestLockIsExclusive(t *testing.T) { + s, path := openTemp(t) + _ = s + start := time.Now() + if _, err := Open(path, nil); err == nil { + t.Fatal("второе открытие должно завершиться ошибкой") + } + if d := time.Since(start); d < lockTimeout { + t.Errorf("ожидание длилось %s, ожидалось не меньше %s", d, lockTimeout) + } + // После Close хранилище снова открывается. + s.Close() + s2, err := Open(path, nil) + if err != nil { + t.Fatalf("после Close открыть не удалось: %v", err) + } + s2.Close() +} + +func TestStaleLockIsBroken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tokens.ini") + lockPath := path + ".lock" + os.WriteFile(lockPath, []byte("pid 1\n"), 0o600) + old := time.Now().Add(-2 * lockStaleAfter) + os.Chtimes(lockPath, old, old) + + var warned string + s, err := Open(path, func(m string) { warned = m }) + if err != nil { + t.Fatalf("брошенный замок должен сниматься: %v", err) + } + defer s.Close() + if !strings.Contains(warned, "брошенный") { + t.Errorf("предупреждение не выдано: %q", warned) + } +} + +func TestNewSeedAndSerial(t *testing.T) { + a, err := NewSeed(0) + if err != nil || len(a) != SeedBytes { + t.Fatalf("NewSeed: %v, %d байт", err, len(a)) + } + b, _ := NewSeed(0) + if hex.EncodeToString(a) == hex.EncodeToString(b) { + t.Error("два вызова NewSeed дали одинаковый результат") + } + + serial, err := NewSerial(DefaultSerialPrefix) + if err != nil { + t.Fatal(err) + } + if len(serial) != SerialLength || !strings.HasPrefix(serial, DefaultSerialPrefix) { + t.Errorf("NewSerial = %q", serial) + } + if err := ValidateSerial(serial); err != nil { + t.Errorf("сгенерированный серийник не проходит проверку: %v", err) + } + if _, err := NewSerial("плохой префикс"); err == nil { + t.Error("некорректный префикс должен отвергаться") + } +} + +func TestNewUniqueSerial(t *testing.T) { + s, _ := openTemp(t) + seen := map[string]bool{} + for i := 0; i < 20; i++ { + serial, err := s.NewUniqueSerial(DefaultSerialPrefix) + if err != nil { + t.Fatal(err) + } + if seen[serial] { + t.Fatalf("серийник %s выдан дважды", serial) + } + seen[serial] = true + tok := sampleToken(t) + tok.Serial = serial + if err := s.Put(tok); err != nil { + t.Fatal(err) + } + } +} + +func TestDefaultPathHonoursEnv(t *testing.T) { + t.Setenv(EnvStore, "/tmp/особый/путь.ini") + if got := DefaultPath(); got != "/tmp/особый/путь.ini" { + t.Errorf("DefaultPath = %q", got) + } + t.Setenv(EnvStore, "") + if got := DefaultPath(); filepath.Base(got) != FileName { + t.Errorf("DefaultPath = %q, ожидалось имя %s", got, FileName) + } +}