Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# сборочные артефакты
/dist/
/hotpcalc
/hotpcalc.exe
# хранилища и выгрузки с реальными секретами (seed) — никогда не коммитить
*.ini
*.ini.lock
*.xml
# служебное
.DS_Store
+39
View File
@@ -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)
+353
View File
@@ -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 <seed> <код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
<?xml version="1.0" encoding="UTF-8"?>
<Tokens>
<Token serial="0004003D473F7D">
<CaseModel>5</CaseModel>
<Model>101</Model>
<ProductionDate>09/04/2026</ProductionDate>
<ProductName>eToken PASS</ProductName>
<Applications>
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
<Seed>281CCDCD4E26ACFD57A09450358FF257FEA484F1</Seed>
<MovingFactor>0</MovingFactor>
</Application>
</Applications>
</Token>
</Tokens>
```
Заводские поля меняются флагами `-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, переданный флагом, попадает в историю командной строки и в список процессов —
в чувствительных случаях вводите его в диалоге.
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"bufio"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
// Тесты запускают саму команду как подпроцесс: тестовый бинарник, увидев
// HOTPCALC_TEST_EXEC, работает как hotpcalc. Это даёт настоящую проверку
// разбора аргументов, вывода, кодов возврата и межпроцессной блокировки.
func TestMain(m *testing.M) {
if os.Getenv("HOTPCALC_TEST_EXEC") == "1" {
os.Args = append([]string{"hotpcalc"}, os.Args[1:]...)
main()
return
}
os.Exit(m.Run())
}
type runResult struct {
stdout, stderr string
code int
}
func runCLI(t *testing.T, args ...string) runResult {
t.Helper()
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1", "HOTPCALC_STORE=")
var out, errBuf strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errBuf
err := cmd.Run()
res := runResult{stdout: out.String(), stderr: errBuf.String()}
if err != nil {
var ee *exec.ExitError
if !asExitError(err, &ee) {
t.Fatalf("запуск %v: %v", args, err)
}
res.code = ee.ExitCode()
}
return res
}
func asExitError(err error, target **exec.ExitError) bool {
e, ok := err.(*exec.ExitError)
if ok {
*target = e
}
return ok
}
func mustRun(t *testing.T, args ...string) runResult {
t.Helper()
r := runCLI(t, args...)
if r.code != 0 {
t.Fatalf("%v завершилась с кодом %d\nstdout: %s\nstderr: %s", args, r.code, r.stdout, r.stderr)
}
return r
}
func storePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "tokens.ini")
}
// newToken выпускает токен и возвращает его серийник и seed.
func newToken(t *testing.T, store string, extra ...string) (serial, seed string) {
t.Helper()
args := append([]string{"new", "-store", store, "-json"}, extra...)
r := mustRun(t, args...)
var tokens []struct {
Serial string `json:"serial"`
Seed string `json:"seed"`
}
if err := json.Unmarshal([]byte(r.stdout), &tokens); err != nil {
t.Fatalf("разбор JSON: %v\n%s", err, r.stdout)
}
if len(tokens) != 1 {
t.Fatalf("ожидался один токен, получено %d", len(tokens))
}
return tokens[0].Serial, tokens[0].Seed
}
func TestNewNextMatchesCalculator(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st)
if len(seed) != 40 {
t.Errorf("seed = %q, ожидалось 40 hex-символов", seed)
}
// Три «нажатия кнопки».
var pressed []string
for i := 0; i < 3; i++ {
r := mustRun(t, "next", serial, "-store", st)
pressed = append(pressed, strings.TrimSpace(r.stdout))
}
// Независимая сверка: калькулятор от того же seed со счётчика 0.
r := mustRun(t, "-seed", seed, "-counter", "0", "-n", "3", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
if err := json.Unmarshal([]byte(r.stdout), &calc); err != nil {
t.Fatalf("разбор JSON калькулятора: %v\n%s", err, r.stdout)
}
for i, c := range calc.Positions[0].Codes {
if pressed[i] != c.Code {
t.Errorf("нажатие %d: токен выдал %s, калькулятор — %s", i+1, pressed[i], c.Code)
}
}
// Счётчик доехал до 3 и служебная строка ушла в stderr, а не в stdout.
r = mustRun(t, "show", serial, "-store", st, "-json")
var shown struct {
Counter uint64 `json:"counter"`
}
json.Unmarshal([]byte(r.stdout), &shown)
if shown.Counter != 3 {
t.Errorf("счётчик = %d, ожидалось 3", shown.Counter)
}
}
func TestShowDoesNotAdvance(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
mustRun(t, "next", serial, "-store", st)
before := counterOf(t, st, serial)
mustRun(t, "show", serial, "-store", st, "-peek", "5")
if after := counterOf(t, st, serial); after != before {
t.Errorf("show сдвинул счётчик: %d → %d", before, after)
}
}
func counterOf(t *testing.T, store, serial string) uint64 {
t.Helper()
r := mustRun(t, "show", serial, "-store", store, "-json")
var v struct {
Counter uint64 `json:"counter"`
}
if err := json.Unmarshal([]byte(r.stdout), &v); err != nil {
t.Fatalf("разбор JSON: %v\n%s", err, r.stdout)
}
return v.Counter
}
func TestNextOutputIsScriptable(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
r := mustRun(t, "next", serial, "-store", st)
code := strings.TrimSpace(r.stdout)
if len(code) != 6 {
t.Errorf("stdout должен содержать только код, получено %q", r.stdout)
}
if _, err := strconv.Atoi(code); err != nil {
t.Errorf("код %q не число", code)
}
if !strings.Contains(r.stderr, "счётчик 0 → 1") {
t.Errorf("служебная строка не попала в stderr: %q", r.stderr)
}
}
func TestSyncSetsCounter(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st)
// Коды счётчиков 10 и 11, полученные независимо через калькулятор.
r := mustRun(t, "-seed", seed, "-counter", "10", "-n", "2", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(r.stdout), &calc)
c1 := calc.Positions[0].Codes[0].Code
c2 := calc.Positions[0].Codes[1].Code
mustRun(t, "sync", serial, c1, c2, "-store", st)
if got := counterOf(t, st, serial); got != 12 {
t.Errorf("после sync счётчик = %d, ожидалось 12", got)
}
// Следующее значение продолжает ту же последовательность.
r = mustRun(t, "next", serial, "-store", st)
expect := mustRun(t, "-seed", seed, "-counter", "12", "-n", "1", "-json")
var one struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(expect.stdout), &one)
if strings.TrimSpace(r.stdout) != one.Positions[0].Codes[0].Code {
t.Errorf("после sync выдан %q, ожидалось %q",
strings.TrimSpace(r.stdout), one.Positions[0].Codes[0].Code)
}
}
func TestExportImportRoundTrip(t *testing.T) {
st := storePath(t)
xml := filepath.Join(filepath.Dir(st), "init.xml")
serial, seed := newToken(t, st, "-xml", xml)
data, err := os.ReadFile(xml)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "<Seed>"+seed+"</Seed>") {
t.Errorf("seed не найден в XML:\n%s", data)
}
other := filepath.Join(filepath.Dir(st), "other.ini")
mustRun(t, "import", xml, "-store", other)
r := mustRun(t, "list", "-store", other, "-json", "-show-seed")
var tokens []struct {
Serial string `json:"serial"`
Seed string `json:"seed"`
}
json.Unmarshal([]byte(r.stdout), &tokens)
if len(tokens) != 1 || tokens[0].Serial != serial || tokens[0].Seed != seed {
t.Errorf("импорт вернул %+v, ожидался %s / %s", tokens, serial, seed)
}
// Повторный импорт без -force ничего не меняет.
r = mustRun(t, "import", xml, "-store", other)
if !strings.Contains(r.stderr, "пропущены") {
t.Errorf("повторный импорт должен предупреждать о пропуске: %q", r.stderr)
}
}
func TestExportWarnsAboutUnsupportedParameters(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st, "-digits", "8")
r := mustRun(t, "export", serial, "-store", st)
if !strings.Contains(r.stderr, "разрядность") {
t.Errorf("ожидалось предупреждение о разрядности, получено: %q", r.stderr)
}
if !strings.Contains(r.stdout, "<Tokens>") {
t.Errorf("XML не выведен в stdout:\n%s", r.stdout)
}
}
func TestTOTPTokenLifecycle(t *testing.T) {
st := storePath(t)
serial, seed := newToken(t, st, "-type", "totp", "-period", "60s")
r := mustRun(t, "next", serial, "-store", st)
code := strings.TrimSpace(r.stdout)
if !strings.Contains(r.stderr, "шаг ") {
t.Errorf("для TOTP ожидалась строка про шаг: %q", r.stderr)
}
// Токен записал выданный временной шаг.
var shown struct {
LastStep uint64 `json:"last_step"`
Period int `json:"period_seconds"`
}
json.Unmarshal([]byte(mustRun(t, "show", serial, "-store", st, "-json").stdout), &shown)
if shown.LastStep == 0 || shown.Period != 60 {
t.Fatalf("состояние TOTP записано неверно: %+v", shown)
}
// Тот же шаг, посчитанный калькулятором от того же seed, даёт тот же код.
r = mustRun(t, "-mode", "totp", "-step", "60s", "-seed", seed,
"-counter", strconv.FormatUint(shown.LastStep, 10), "-n", "1", "-json")
var calc struct {
Positions []struct {
Codes []struct {
Code string `json:"code"`
} `json:"codes"`
} `json:"positions"`
}
json.Unmarshal([]byte(r.stdout), &calc)
if got := calc.Positions[0].Codes[0].Code; got != code {
t.Errorf("токен выдал %s, калькулятор на шаге %d — %s", code, shown.LastStep, got)
}
}
func TestConcurrentNextGivesDistinctCounters(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
const n = 8
codes := make([]string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
r := runCLI(t, "next", serial, "-store", st)
if r.code == 0 {
codes[i] = strings.TrimSpace(r.stdout)
}
}(i)
}
wg.Wait()
seen := map[string]bool{}
got := 0
for _, c := range codes {
if c == "" {
continue
}
if seen[c] {
t.Errorf("код %s выдан дважды — блокировка не сработала", c)
}
seen[c] = true
got++
}
if got != n {
t.Fatalf("успешных нажатий %d из %d — блокировка не пропустила все", got, n)
}
if c := counterOf(t, st, serial); c != n {
t.Errorf("счётчик = %d, ожидалось %d", c, n)
}
}
func TestUnknownTokenAndBadArgs(t *testing.T) {
st := storePath(t)
newToken(t, st)
if r := runCLI(t, "next", "НЕТ-ТАКОГО", "-store", st); r.code == 0 {
t.Error("несуществующий токен должен давать ненулевой код возврата")
}
if r := runCLI(t, "next", "-store", st); r.code == 0 {
t.Error("вызов без серийника должен давать ошибку")
}
if r := runCLI(t, "set", "AAA", "-store", st); r.code == 0 {
t.Error("set без изменений должен давать ошибку")
}
}
func TestCalculatorStillWorks(t *testing.T) {
// Регрессия: прежние вызовы не должны сломаться подкомандами.
const seed = "765D7268D012090C28172A729BC9CC1AD82B382D"
r := mustRun(t, seed, "941595", "057938", "-n", "2")
if !strings.Contains(r.stdout, "счётчик 41") {
t.Errorf("калькулятор сломан:\n%s", r.stdout)
}
r = mustRun(t, "calc", "-seed", seed, "-counter", "41", "-n", "1")
if !strings.Contains(r.stdout, "941595") {
t.Errorf("явный вызов calc сломан:\n%s", r.stdout)
}
}
func TestListEmptyStore(t *testing.T) {
st := storePath(t)
r := mustRun(t, "list", "-store", st)
if !strings.Contains(r.stdout, "пусто") {
t.Errorf("ожидалось сообщение о пустом хранилище:\n%s", r.stdout)
}
r = mustRun(t, "list", "-store", st, "-json")
if strings.TrimSpace(r.stdout) != "[]" {
t.Errorf("для пустого хранилища JSON должен быть [], получено %q", r.stdout)
}
}
func TestTokenInteractive(t *testing.T) {
st := storePath(t)
serial, _ := newToken(t, st)
cmd := exec.Command(os.Args[0], "token", serial, "-store", st)
cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1")
cmd.Stdin = strings.NewReader("\n\nq\n")
out, err := cmd.Output()
if err != nil {
t.Fatalf("token: %v", err)
}
var codes int
sc := bufio.NewScanner(strings.NewReader(string(out)))
for sc.Scan() {
if strings.Contains(sc.Text(), "(счётчик ") {
codes++
}
}
if codes != 2 {
t.Errorf("выдано %d значений, ожидалось 2:\n%s", codes, out)
}
if c := counterOf(t, st, serial); c != 2 {
t.Errorf("счётчик = %d, ожидалось 2", c)
}
}
+889
View File
@@ -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)
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"strings"
"hotpcalc/internal/store"
)
// command — подкоманда командной строки.
type command struct {
name string
brief string
args string // краткое описание позиционных аргументов для справки
run func(args []string) error
}
func commands() []*command {
return []*command{
{"new", "выпустить новый токен и выгрузить XML", "", cmdNew},
{"add", "добавить в хранилище известный токен по его seed", "<seed>", cmdAdd},
{"import", "загрузить токены из XML-файла инициализации", "<файл.xml>", cmdImport},
{"list", "список токенов в хранилище", "", cmdList},
{"show", "параметры и состояние токена, счётчик не двигается", "<серийник>", cmdShow},
{"next", "выдать следующее значение — «нажатие кнопки»", "<серийник>", cmdNext},
{"token", "интерактивный режим: Enter = нажатие кнопки", "<серийник>", cmdToken},
{"sync", "найти позицию по двум кодам и записать её в хранилище", "<серийник> <код1> <код2>", cmdSync},
{"set", "изменить параметры токена", "<серийник>", cmdSet},
{"export", "выгрузить XML-файл инициализации", "[серийник...]", cmdExport},
{"remove", "удалить токен из хранилища", "<серийник>...", cmdRemove},
{"calc", "явный вызов калькулятора", "[seed] [код1] [код2]", runCalc},
}
}
func lookupCommand(name string) *command {
for _, c := range commands() {
if c.name == name {
return c
}
}
return nil
}
// newFlagSet готовит набор флагов подкоманды с общей справкой.
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet("hotpcalc "+name, flag.ContinueOnError)
cmd := lookupCommand(name)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "hotpcalc %s %s\n\n %s\n\nФлаги:\n", name, cmd.args, cmd.brief)
fs.PrintDefaults()
}
return fs
}
// storeFlag добавляет флаг пути к хранилищу.
func storeFlag(fs *flag.FlagSet, dst *string) {
fs.StringVar(dst, "store", store.DefaultPath(),
"файл хранилища токенов (переменная "+store.EnvStore+")")
}
// parseFlags разбирает аргументы подкоманды с перестановкой флагов и
// позиционных аргументов.
func parseFlags(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(permute(fs, args)); err != nil {
if errors.Is(err, flag.ErrHelp) {
return errHelp
}
return err
}
return nil
}
// errHelp сигнализирует, что была напечатана справка и работа окончена.
var errHelp = errors.New("справка напечатана")
// warn печатает предупреждение в stderr.
func warn(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Внимание: "+format+"\n", args...)
}
// openStore открывает хранилище с блокировкой.
func openStore(path string) (*store.Store, error) {
return store.Open(path, func(m string) { warn("%s", m) })
}
// getToken читает токен, требуя, чтобы он существовал.
func getToken(s *store.Store, serial string) (store.Token, error) {
t, ok, err := s.Get(serial)
if err != nil {
return store.Token{}, err
}
if !ok {
hint := "хранилище пусто"
if serials := s.Serials(); len(serials) > 0 {
hint = "известные токены: " + strings.Join(serials, ", ")
}
return store.Token{}, fmt.Errorf("токен %s не найден в %s (%s)", serial, s.Path(), hint)
}
return t, nil
}
// requireArgs проверяет число позиционных аргументов.
func requireArgs(fs *flag.FlagSet, min, max int, what string) error {
n := fs.NArg()
if n < min {
fs.Usage()
return fmt.Errorf("не хватает аргументов: нужен %s", what)
}
if max >= 0 && n > max {
return fmt.Errorf("лишние аргументы: %s", strings.Join(fs.Args()[max:], " "))
}
return nil
}
+494
View File
@@ -0,0 +1,494 @@
// Команда hotpcalc — калькулятор одноразовых паролей для токенов
// SafeNet eToken PASS (HOTP по RFC 4226 и TOTP по RFC 6238).
//
// По вектору инициализации (seed) и двум последовательным значениям с токена
// программа определяет его текущую позицию и рассчитывает следующие N значений.
package main
import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"hotpcalc/internal/otp"
)
// version подставляется при сборке через -ldflags "-X main.version=...".
var version = "dev"
const usage = `hotpcalc — калькулятор HOTP/TOTP для токенов SafeNet eToken PASS
Использование:
hotpcalc [флаги] [seed] [код1] [код2]
По вектору инициализации и двум ПОСЛЕДОВАТЕЛЬНЫМ значениям с токена программа
находит его текущую позицию и печатает следующие N значений.
Если обязательные данные не заданы, они запрашиваются в диалоге.
Примеры:
hotpcalc 765D7268D012090C28172A729BC9CC1AD82B382D 755224 287082
hotpcalc -n 20 -seed 765D... -otp1 755224 -otp2 287082
hotpcalc -mode totp -step 60s -seed GEZDGNBVGY3TQOJQ -otp1 123456 -otp2 654321
hotpcalc -seed 765D... -counter 100 -n 5 # без поиска, прямо со счётчика
Команды (без команды работает калькулятор):
new выпустить новый токен: seed, серийник, запись в ini и выгрузка XML
add добавить в ini уже известный токен по его seed
import загрузить токены из XML-файла инициализации
list список токенов в хранилище
show параметры и состояние токена (счётчик не двигается)
next выдать следующее значение — «нажатие кнопки»
token интерактивный режим: Enter = нажатие кнопки
sync найти позицию по двум кодам и записать её в хранилище
set изменить параметры токена
export выгрузить XML-файл инициализации
remove удалить токен из хранилища
calc явный вызов калькулятора
hotpcalc <команда> -h — справка по команде
Флаги калькулятора:
`
type options struct {
mode string
seed string
format string
otp1 string
otp2 string
n int
digits int
alg string
step time.Duration
start uint64
span uint64
window string
counter int64
at string
all bool
asJSON bool
showVers bool
args []string // исходные аргументы, нужны changedFlags
}
func main() {
err := run()
switch {
case err == nil:
case errors.Is(err, errHelp):
os.Exit(2)
default:
fmt.Fprintln(os.Stderr, "Ошибка: "+err.Error())
os.Exit(1)
}
}
func run() error {
// Подкоманды. Первый позиционный аргумент сверяется со списком команд;
// если он не команда, работает режим калькулятора, поэтому все прежние
// вызовы вида "hotpcalc <seed> <код1> <код2>" продолжают работать.
args := os.Args[1:]
if len(args) > 0 {
if cmd := lookupCommand(args[0]); cmd != nil {
return cmd.run(args[1:])
}
}
return runCalc(args)
}
func runCalc(args []string) error {
var o options
fs := flag.NewFlagSet("hotpcalc", flag.ContinueOnError)
fs.Usage = func() {
fmt.Fprint(os.Stderr, usage)
fs.PrintDefaults()
}
fs.StringVar(&o.mode, "mode", "hotp", "режим: hotp или totp")
fs.StringVar(&o.seed, "seed", "", "вектор инициализации (hex, Base32 или otpauth:// URI)")
fs.StringVar(&o.format, "format", "auto", "формат seed: auto, hex, base32")
fs.StringVar(&o.otp1, "otp1", "", "первое значение с токена")
fs.StringVar(&o.otp2, "otp2", "", "второе (следующее за первым) значение с токена")
fs.IntVar(&o.n, "n", 10, "сколько следующих значений рассчитать")
fs.IntVar(&o.digits, "digits", 0, "разрядность кода: 6 или 8 (по умолчанию — по длине введённых кодов)")
fs.StringVar(&o.alg, "alg", "sha1", "алгоритм HMAC: sha1, sha256, sha512")
fs.DurationVar(&o.step, "step", otp.DefaultStep, "шаг времени для TOTP (например 30s или 60s)")
fs.Uint64Var(&o.start, "start", 0, "HOTP: начало диапазона перебора счётчика")
fs.Uint64Var(&o.span, "span", 1000000, "HOTP: размер диапазона перебора счётчика")
fs.StringVar(&o.window, "window", "90d", "TOTP: окно перебора вокруг текущего времени (30d, 12h, 90m)")
fs.Int64Var(&o.counter, "counter", -1, "считать прямо с этого счётчика/шага, без поиска по кодам")
fs.StringVar(&o.at, "at", "", "TOTP: считать текущим моментом указанное время (RFC 3339)")
fs.BoolVar(&o.all, "all", false, "вывести результат для всех найденных совпадений, а не только для первого")
fs.BoolVar(&o.asJSON, "json", false, "машиночитаемый вывод в формате JSON")
fs.BoolVar(&o.showVers, "version", false, "показать версию и выйти")
if err := fs.Parse(permute(fs, args)); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if o.showVers {
fmt.Println("hotpcalc " + version)
return nil
}
// Позиционные аргументы как сокращение: hotpcalc <seed> <код1> <код2>.
// Заполняются только те поля, которые не заданы флагами, — чтобы вызов
// "hotpcalc -seed <seed> <код1> <код2>" тоже работал.
if err := assignPositional(&o, fs.Args()); err != nil {
return err
}
o.args = args
return execute(&o)
}
func execute(o *options) error {
totpMode, err := parseMode(o.mode)
if err != nil {
return err
}
if o.n < 1 {
return fmt.Errorf("количество значений (-n) должно быть больше нуля")
}
in := bufio.NewReader(os.Stdin)
if o.seed == "" {
o.seed, err = prompt(in, "seed", "Введите seed (hex или Base32): ")
if err != nil {
return err
}
}
seed, err := otp.ParseSeed(o.seed, otp.SeedFormat(o.format))
if err != nil {
return err
}
// Параметры из otpauth:// URI используются как значения по умолчанию,
// но явно заданные флаги имеют приоритет.
setFlags := changedFlags(o.args)
if seed.FromURI {
if !setFlags["mode"] {
totpMode = seed.TOTP
}
if seed.Digits != nil && !setFlags["digits"] {
o.digits = *seed.Digits
}
if seed.Alg != nil && !setFlags["alg"] {
o.alg = seed.Alg.String()
}
if seed.Step != nil && !setFlags["step"] {
o.step = *seed.Step
}
if seed.Counter != nil && !setFlags["counter"] && o.counter < 0 {
o.counter = int64(*seed.Counter)
}
}
direct := o.counter >= 0
if !direct {
if o.otp1 == "" {
if o.otp1, err = prompt(in, "первый код с токена (-otp1)", "Введите первый код с токена: "); err != nil {
return err
}
}
if o.otp2 == "" {
if o.otp2, err = prompt(in, "второй код с токена (-otp2)", "Введите второй (следующий) код: "); err != nil {
return err
}
}
if err := validateCode(o.otp1, "первый код"); err != nil {
return err
}
if err := validateCode(o.otp2, "второй код"); err != nil {
return err
}
if len(o.otp1) != len(o.otp2) {
return fmt.Errorf("коды имеют разную длину (%d и %d) — проверьте ввод", len(o.otp1), len(o.otp2))
}
}
alg, err := otp.ParseAlgorithm(o.alg)
if err != nil {
return err
}
digits := o.digits
if digits == 0 {
if direct {
digits = 6
} else {
digits = len(o.otp1)
}
}
if o.step <= 0 {
return fmt.Errorf("шаг времени (-step) должен быть положительным")
}
cfg := otp.Config{Secret: seed.Secret, Digits: digits, Alg: alg, Step: o.step}
if err := cfg.Validate(); err != nil {
return err
}
now := time.Now()
if o.at != "" {
if !totpMode {
return fmt.Errorf("флаг -at применим только в режиме TOTP")
}
if now, err = time.Parse(time.RFC3339, o.at); err != nil {
return fmt.Errorf("не удалось разобрать -at: %w", err)
}
}
res := result{
Mode: modeName(totpMode),
Algorithm: alg.String(),
Digits: digits,
SeedBytes: len(seed.Secret),
SeedShown: otp.Fingerprint(seed.Secret),
SeedForm: string(seed.Format),
Label: seed.Label,
}
if totpMode {
res.StepSeconds = int(o.step / time.Second)
}
var matches []otp.Match
if direct {
matches = []otp.Match{{Counter: uint64(o.counter)}}
if totpMode {
matches[0].Time = cfg.StepStart(matches[0].Counter)
}
res.Direct = true
} else {
if totpMode {
window, err := parseWindow(o.window)
if err != nil {
return err
}
matches, err = otp.FindTimeStep(cfg, o.otp1, o.otp2, now, window)
if err != nil {
return withHints(err, totpMode)
}
} else {
matches, err = otp.FindCounter(cfg, o.otp1, o.otp2, o.start, o.span)
if err != nil {
return withHints(err, totpMode)
}
}
res.OTP1, res.OTP2 = o.otp1, o.otp2
}
res.Ambiguous = len(matches) > 1
if !o.all && len(matches) > 1 {
matches = matches[:1]
}
for _, m := range matches {
p := position{Counter: m.Counter}
// Первое рассчитываемое значение идёт сразу за введённой парой;
// в режиме -counter — начиная с самого указанного счётчика.
first := m.Counter
if !direct {
first = m.Counter + 2
}
if totpMode {
p.ValidFrom = cfg.StepStart(m.Counter)
p.ValidTo = p.ValidFrom.Add(o.step)
if !direct {
// Расхождение часов: насколько шаг второго введённого кода
// отстоит от текущего временного шага этой машины.
driftSteps := int64(m.Counter+1) - int64(cfg.TimeStep(now))
p.DriftSteps = driftSteps
p.DriftSeconds = driftSteps * int64(o.step/time.Second)
}
}
for i := 0; i < o.n; i++ {
c := first + uint64(i)
e := entry{Index: i + 1, Counter: c, Code: cfg.HOTP(c)}
if totpMode {
e.ValidFrom = cfg.StepStart(c)
e.ValidTo = e.ValidFrom.Add(o.step)
e.Current = !e.ValidFrom.After(now) && e.ValidTo.After(now)
}
p.Codes = append(p.Codes, e)
}
res.Positions = append(res.Positions, p)
}
if o.asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
printText(os.Stdout, res, totpMode)
return nil
}
func parseMode(s string) (totp bool, err error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "hotp", "":
return false, nil
case "totp":
return true, nil
}
return false, fmt.Errorf("неизвестный режим %q (допустимо: hotp, totp)", s)
}
func modeName(totp bool) string {
if totp {
return "totp"
}
return "hotp"
}
// assignPositional раскладывает позиционные аргументы по незаполненным полям
// в порядке seed, код1, код2.
func assignPositional(o *options, args []string) error {
slots := []*string{&o.seed, &o.otp1, &o.otp2}
i := 0
for _, v := range args {
for i < len(slots) && *slots[i] != "" {
i++
}
if i == len(slots) {
return fmt.Errorf("лишний аргумент %q: seed и оба кода уже заданы флагами", v)
}
*slots[i] = v
i++
}
return nil
}
// permute переставляет аргументы так, чтобы флаги шли перед позиционными
// аргументами. Пакет flag прекращает разбор на первом позиционном аргументе,
// а команду удобнее вызывать в виде "hotpcalc <seed> <код1> <код2> -n 20".
func permute(fs *flag.FlagSet, args []string) []string {
flags := make([]string, 0, len(args))
rest := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
rest = append(rest, args[i+1:]...)
break
}
if len(a) < 2 || a[0] != '-' {
rest = append(rest, a)
continue
}
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if strings.ContainsRune(name, '=') {
continue // значение указано как -flag=value
}
f := fs.Lookup(name)
if f == nil {
continue // неизвестный флаг: пусть о нём сообщит сам flag.Parse
}
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue // булев флаг значения не требует
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
return append(flags, rest...)
}
// changedFlags возвращает множество флагов, явно заданных в командной строке.
func changedFlags(args []string) map[string]bool {
set := map[string]bool{}
for _, a := range args {
if strings.HasPrefix(a, "-") {
name := strings.TrimLeft(a, "-")
if i := strings.IndexByte(name, '='); i >= 0 {
name = name[:i]
}
set[name] = true
}
}
return set
}
func validateCode(s, what string) error {
if s == "" {
return fmt.Errorf("%s не задан", what)
}
if len(s) < 6 || len(s) > 10 {
return fmt.Errorf("%s: длина %d вне диапазона 6..10 разрядов", what, len(s))
}
for _, r := range s {
if r < '0' || r > '9' {
return fmt.Errorf("%s должен состоять только из цифр, получено %q", what, s)
}
}
return nil
}
// parseWindow разбирает длительность, дополнительно понимая суффиксы d и w.
func parseWindow(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("окно поиска (-window) не задано")
}
unit := s[len(s)-1]
if unit == 'd' || unit == 'w' {
n, err := strconv.ParseFloat(s[:len(s)-1], 64)
if err != nil {
return 0, fmt.Errorf("не удалось разобрать -window %q: %w", s, err)
}
mult := 24 * time.Hour
if unit == 'w' {
mult = 7 * 24 * time.Hour
}
return time.Duration(n * float64(mult)), nil
}
d, err := time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("не удалось разобрать -window %q: %w", s, err)
}
return d, nil
}
// withHints дополняет ошибку поиска подсказками, что обычно идёт не так.
func withHints(err error, totpMode bool) error {
var nf *otp.ErrNotFound
if !errors.As(err, &nf) {
return err
}
hint := "расширьте диапазон перебора флагами -start/-span"
if totpMode {
hint = "увеличьте окно поиска флагом -window (например -window 365d)"
}
return fmt.Errorf("%w.\nПроверьте: seed, разрядность (-digits), режим (-mode hotp|totp), "+
"порядок кодов (второй должен идти сразу за первым).\nИначе %s", err, hint)
}
// prompt запрашивает недостающее значение у пользователя. Приглашение идёт в
// stderr, чтобы не смешиваться с результатом при перенаправлении вывода.
func prompt(in *bufio.Reader, what, text string) (string, error) {
fmt.Fprint(os.Stderr, text)
line, err := in.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
if err != nil {
fmt.Fprintln(os.Stderr)
}
return "", fmt.Errorf("не задан обязательный параметр: %s. "+
"Укажите его флагом или введите в диалоге", what)
}
return line, nil
}
+185
View File
@@ -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)
}
}
}
+222
View File
@@ -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))
}
+3
View File
@@ -0,0 +1,3 @@
module hotpcalc
go 1.26
+269
View File
@@ -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
}
+210
View File
@@ -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)
}
}
+210
View File
@@ -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
}
+190
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<Tokens>
<Token serial="0004000ABCDEF1">
<CaseModel>5</CaseModel>
<Model>101</Model>
<ProductionDate>09/04/2026</ProductionDate>
<ProductName>eToken PASS</ProductName>
<Applications>
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
<Seed>765D7268D012090C28172A729BC9CC1AD82B382D</Seed>
<MovingFactor>1</MovingFactor>
</Application>
</Applications>
</Token>
</Tokens>
`
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 = `<?xml version="1.0" encoding="utf-8"?>
<Tokens>
<Token serial="000400081DFFA5">
<CaseModel>5</CaseModel>
<Model>101</Model>
<ProductionDate>02/19/2009</ProductionDate>
<ProductName>Safeword Alpine</ProductName>
<Applications>
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
<Seed>3132333435363738393031323334353637383930</Seed>
<MovingFactor>1</MovingFactor>
</Application>
</Applications>
</Token>
</Tokens>`
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 := `<Tokens><Token serial="X"><Applications><Application>` +
`<Seed>` + long + `</Seed><MovingFactor>0</MovingFactor>` +
`</Application></Applications></Token></Tokens>`
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": "просто текст",
"чужой корень": `<KeyContainer/>`,
"нет токенов": `<Tokens></Tokens>`,
"нет serial": `<Tokens><Token><Applications><Application><Seed>AA</Seed></Application></Applications></Token></Tokens>`,
"нет Application": `<Tokens><Token serial="X"><Applications></Applications></Token></Tokens>`,
"нет Seed": `<Tokens><Token serial="X"><Applications><Application><MovingFactor>0</MovingFactor></Application></Applications></Token></Tokens>`,
"Seed не hex": `<Tokens><Token serial="X"><Applications><Application><Seed>ZZZZ</Seed></Application></Applications></Token></Tokens>`,
"MovingFactor не чис": `<Tokens><Token serial="X"><Applications><Application><Seed>` + testSeedHex + `</Seed><MovingFactor>abc</MovingFactor></Application></Applications></Token></Tokens>`,
}
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)
}
}
+135
View File
@@ -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
}
+108
View File
@@ -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 должен отвергаться")
}
}
+87
View File
@@ -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
}
+139
View File
@@ -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)
}
}
}
+186
View File
@@ -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:]
}
+117
View File
@@ -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)
}
}
+62
View File
@@ -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)
}
+82
View File
@@ -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") }
+350
View File
@@ -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)
}
+321
View File
@@ -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)
}
}