Initial commit: hotpcalc CLI
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
# сборочные артефакты
|
||||
/dist/
|
||||
/hotpcalc
|
||||
/hotpcalc.exe
|
||||
|
||||
# хранилища и выгрузки с реальными секретами (seed) — никогда не коммитить
|
||||
*.ini
|
||||
*.ini.lock
|
||||
*.xml
|
||||
|
||||
# служебное
|
||||
.DS_Store
|
||||
@@ -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)
|
||||
@@ -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, переданный флагом, попадает в историю командной строки и в список процессов —
|
||||
в чувствительных случаях вводите его в диалоге.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 должен отвергаться")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user