Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
// Package ini реализует минимальный разбор и запись ini-файлов без внешних
// зависимостей. Порядок секций, порядок ключей и строки комментариев
// сохраняются, чтобы файл, отредактированный пользователем вручную,
// не портился при перезаписи.
package ini
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
// Section — одна секция ini-файла.
type Section struct {
name string
// lines хранит строки секции в исходном порядке: комментарии, пустые
// строки и пары ключ-значение. Это позволяет перезаписать файл,
// не потеряв авторское оформление.
lines []line
}
type line struct {
raw string // комментарий или пустая строка; используется, если key == ""
key string
value string
}
// File — разобранный ini-файл.
type File struct {
preamble []string // комментарии и пустые строки до первой секции
sections []*Section
}
// New создаёт пустой файл.
func New() *File { return &File{} }
// Preamble возвращает строки, стоящие до первой секции.
func (f *File) Preamble() []string { return f.preamble }
// SetPreamble задаёт строки, которые будут записаны до первой секции.
// Строки комментариев должны начинаться с ';' или '#'.
func (f *File) SetPreamble(lines []string) { f.preamble = lines }
// Sections возвращает имена секций в порядке их появления в файле.
func (f *File) Sections() []string {
names := make([]string, len(f.sections))
for i, s := range f.sections {
names[i] = s.name
}
return names
}
// Section возвращает секцию по имени или nil, если её нет.
func (f *File) Section(name string) *Section {
for _, s := range f.sections {
if s.name == name {
return s
}
}
return nil
}
// AddSection возвращает существующую секцию или создаёт новую в конце файла.
func (f *File) AddSection(name string) *Section {
if s := f.Section(name); s != nil {
return s
}
s := &Section{name: name}
f.sections = append(f.sections, s)
return s
}
// RemoveSection удаляет секцию; возвращает true, если она была.
func (f *File) RemoveSection(name string) bool {
for i, s := range f.sections {
if s.name == name {
f.sections = append(f.sections[:i], f.sections[i+1:]...)
return true
}
}
return false
}
// Name возвращает имя секции.
func (s *Section) Name() string { return s.name }
// Get возвращает значение ключа; для отсутствующего ключа — пустую строку.
func (s *Section) Get(key string) string {
for _, l := range s.lines {
if l.key == key {
return l.value
}
}
return ""
}
// Has сообщает, есть ли такой ключ в секции.
func (s *Section) Has(key string) bool {
for _, l := range s.lines {
if l.key == key {
return true
}
}
return false
}
// Keys возвращает имена ключей в порядке их появления в секции.
func (s *Section) Keys() []string {
var keys []string
for _, l := range s.lines {
if l.key != "" {
keys = append(keys, l.key)
}
}
return keys
}
// Set задаёт значение ключа. Существующий ключ обновляется на своём месте,
// новый добавляется в конец секции.
func (s *Section) Set(key, value string) {
for i := range s.lines {
if s.lines[i].key == key {
s.lines[i].value = value
return
}
}
s.lines = append(s.lines, line{key: key, value: value})
}
// Unset удаляет ключ; возвращает true, если он был.
func (s *Section) Unset(key string) bool {
for i, l := range s.lines {
if l.key == key {
s.lines = append(s.lines[:i], s.lines[i+1:]...)
return true
}
}
return false
}
func parseError(n int, format string, args ...any) error {
return fmt.Errorf("строка %d: %s", n, fmt.Sprintf(format, args...))
}
// Parse разбирает ini-файл из r.
func Parse(r io.Reader) (*File, error) {
f := New()
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var cur *Section
for n := 1; sc.Scan(); n++ {
raw := strings.TrimRight(sc.Text(), " \t\r")
trimmed := strings.TrimSpace(raw)
switch {
case trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#"):
if cur == nil {
f.preamble = append(f.preamble, raw)
} else {
cur.lines = append(cur.lines, line{raw: raw})
}
case strings.HasPrefix(trimmed, "["):
if !strings.HasSuffix(trimmed, "]") {
return nil, parseError(n, "незакрытое имя секции %q", trimmed)
}
name := strings.TrimSpace(trimmed[1 : len(trimmed)-1])
if name == "" {
return nil, parseError(n, "пустое имя секции")
}
if f.Section(name) != nil {
return nil, parseError(n, "секция %q встречается повторно", name)
}
cur = f.AddSection(name)
default:
i := strings.IndexByte(trimmed, '=')
if i < 0 {
return nil, parseError(n, "строка не является парой ключ=значение: %q", trimmed)
}
key := strings.TrimSpace(trimmed[:i])
if key == "" {
return nil, parseError(n, "пустое имя ключа")
}
if cur == nil {
return nil, parseError(n, "ключ %q вне секции", key)
}
if cur.Has(key) {
return nil, parseError(n, "ключ %q в секции [%s] встречается повторно", key, cur.name)
}
cur.lines = append(cur.lines, line{key: key, value: strings.TrimSpace(trimmed[i+1:])})
}
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("чтение ini: %w", err)
}
return f, nil
}
// validValue проверяет, что значение можно записать без потери смысла:
// экранирования в формате нет, поэтому переводы строки запрещены.
func validValue(key, value string) error {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("значение ключа %q содержит перевод строки", key)
}
return nil
}
// WriteTo записывает файл. Секции и ключи выводятся в исходном порядке,
// значения внутри секции выравниваются по самому длинному имени ключа.
func (f *File) WriteTo(w io.Writer) (int64, error) {
var buf bytes.Buffer
for _, l := range f.preamble {
buf.WriteString(l)
buf.WriteByte('\n')
}
for _, s := range f.sections {
// Отделяем секцию пустой строкой, но не плодим их: в исходном файле
// пустая строка перед секцией уже могла быть сохранена как часть
// предыдущей секции или шапки.
separate(&buf)
fmt.Fprintf(&buf, "[%s]\n", s.name)
width := 0
for _, l := range s.lines {
if l.key != "" && len(l.key) > width {
width = len(l.key)
}
}
for _, l := range s.lines {
if l.key == "" {
buf.WriteString(l.raw)
buf.WriteByte('\n')
continue
}
if err := validValue(l.key, l.value); err != nil {
return 0, err
}
fmt.Fprintf(&buf, "%-*s = %s\n", width, l.key, l.value)
}
}
n, err := w.Write(buf.Bytes())
return int64(n), err
}
// separate дописывает пустую строку-разделитель, если её ещё нет.
func separate(buf *bytes.Buffer) {
b := buf.Bytes()
if len(b) == 0 || bytes.HasSuffix(b, []byte("\n\n")) {
return
}
buf.WriteByte('\n')
}
// Bytes возвращает содержимое файла в виде среза байтов.
func (f *File) Bytes() ([]byte, error) {
var buf bytes.Buffer
if _, err := f.WriteTo(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+210
View File
@@ -0,0 +1,210 @@
package ini
import (
"strings"
"testing"
)
const sample = `; хранилище токенов
; вторая строка шапки
[0004000ABCDEF1]
; заметка про этот токен
type = hotp
seed = 765D7268D012090C28172A729BC9CC1AD82B382D
counter = 42
label = Иванов И.И.
[0004000ABCDEF2]
type = totp
period = 30
`
func mustParse(t *testing.T, s string) *File {
t.Helper()
f, err := Parse(strings.NewReader(s))
if err != nil {
t.Fatalf("Parse: %v", err)
}
return f
}
func TestParseBasics(t *testing.T) {
f := mustParse(t, sample)
if got := f.Sections(); len(got) != 2 || got[0] != "0004000ABCDEF1" || got[1] != "0004000ABCDEF2" {
t.Fatalf("Sections() = %v", got)
}
s := f.Section("0004000ABCDEF1")
if s == nil {
t.Fatal("секция не найдена")
}
if got := s.Get("counter"); got != "42" {
t.Errorf("counter = %q", got)
}
if got := s.Get("label"); got != "Иванов И.И." {
t.Errorf("label = %q", got)
}
if got := s.Get("нет-такого"); got != "" {
t.Errorf("отсутствующий ключ = %q", got)
}
if !s.Has("seed") || s.Has("нет-такого") {
t.Error("Has работает неверно")
}
if got := s.Keys(); strings.Join(got, ",") != "type,seed,counter,label" {
t.Errorf("Keys() = %v", got)
}
if f.Section("нет-такой") != nil {
t.Error("несуществующая секция должна давать nil")
}
}
func TestRoundTripIsStable(t *testing.T) {
// Первая запись приводит выравнивание к каноническому виду, дальше файл
// не должен меняться — иначе каждая перезапись плодила бы отступы
// или пустые строки.
first, err := mustParse(t, sample).Bytes()
if err != nil {
t.Fatalf("Bytes: %v", err)
}
second, err := mustParse(t, string(first)).Bytes()
if err != nil {
t.Fatalf("Bytes: %v", err)
}
if string(first) != string(second) {
t.Errorf("повторная запись изменила файл:\n--- 1 ---\n%s\n--- 2 ---\n%s", first, second)
}
// Содержимое при этом сохраняется полностью.
got := string(first)
for _, want := range []string{
"; хранилище токенов", "; вторая строка шапки", "; заметка про этот токен",
"[0004000ABCDEF1]", "[0004000ABCDEF2]",
"seed = 765D7268D012090C28172A729BC9CC1AD82B382D",
"label = Иванов И.И.", "period = 30",
} {
if !strings.Contains(got, want) {
t.Errorf("после записи потеряно %q:\n%s", want, got)
}
}
if strings.Contains(got, "\n\n\n") {
t.Errorf("лишние пустые строки:\n%q", got)
}
}
func TestSetKeepsPositionAndComments(t *testing.T) {
f := mustParse(t, sample)
s := f.Section("0004000ABCDEF1")
s.Set("counter", "43") // существующий ключ — на своём месте
s.Set("last_used", "now") // новый — в конец секции
out, err := f.Bytes()
if err != nil {
t.Fatal(err)
}
got := string(out)
if !strings.Contains(got, "counter = 43") {
t.Errorf("counter не обновлён:\n%s", got)
}
if !strings.Contains(got, "; заметка про этот токен") {
t.Errorf("комментарий внутри секции потерян:\n%s", got)
}
if !strings.Contains(got, "; хранилище токенов") {
t.Errorf("шапка потеряна:\n%s", got)
}
if strings.Index(got, "counter") > strings.Index(got, "label") {
t.Errorf("порядок ключей нарушен:\n%s", got)
}
if !strings.Contains(got, "last_used = now") {
t.Errorf("новый ключ не добавлен:\n%s", got)
}
// Ширина выравнивания пересчитывается по самому длинному ключу секции.
if !strings.Contains(got, "type = hotp") {
t.Errorf("выравнивание нарушено:\n%s", got)
}
}
func TestAddRemoveSection(t *testing.T) {
f := New()
f.SetPreamble([]string{"; шапка"})
s := f.AddSection("AAA")
s.Set("k", "v")
if f.AddSection("AAA") != s {
t.Error("AddSection должен возвращать существующую секцию")
}
f.AddSection("BBB").Set("x", "y")
out, _ := f.Bytes()
want := "; шапка\n\n[AAA]\nk = v\n\n[BBB]\nx = y\n"
if string(out) != want {
t.Errorf("получено:\n%q\nожидалось:\n%q", out, want)
}
if !f.RemoveSection("AAA") || f.RemoveSection("AAA") {
t.Error("RemoveSection работает неверно")
}
if got := f.Sections(); len(got) != 1 || got[0] != "BBB" {
t.Errorf("после удаления Sections() = %v", got)
}
}
func TestUnset(t *testing.T) {
f := mustParse(t, sample)
s := f.Section("0004000ABCDEF1")
if !s.Unset("counter") || s.Unset("counter") {
t.Error("Unset работает неверно")
}
out, _ := f.Bytes()
if strings.Contains(string(out), "counter") {
t.Errorf("ключ не удалён:\n%s", out)
}
}
func TestValueEdgeCases(t *testing.T) {
f := mustParse(t, "[s]\nempty =\nurl = http://x/?a=1&b=2\npadded = с пробелами \n")
s := f.Section("s")
if got := s.Get("empty"); got != "" {
t.Errorf("пустое значение = %q", got)
}
if got := s.Get("url"); got != "http://x/?a=1&b=2" {
t.Errorf("значение со знаком = внутри: %q", got)
}
if got := s.Get("padded"); got != "с пробелами" {
t.Errorf("обрамляющие пробелы не срезаны: %q", got)
}
}
func TestWriteRejectsNewline(t *testing.T) {
f := New()
f.AddSection("s").Set("k", "две\nстроки")
if _, err := f.Bytes(); err == nil {
t.Error("значение с переводом строки должно отвергаться при записи")
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"ключ вне секции": "k = v\n",
"незакрытая секция": "[abc\n",
"пустое имя секции": "[]\n",
"строка без знака =": "[s]\nмусор\n",
"пустое имя ключа": "[s]\n = v\n",
"повтор секции": "[s]\nk = 1\n[s]\nk = 2\n",
"повтор ключа в секции": "[s]\nk = 1\nk = 2\n",
}
for name, in := range cases {
if _, err := Parse(strings.NewReader(in)); err == nil {
t.Errorf("%s: ожидалась ошибка", name)
}
}
}
func TestParseEmptyAndCRLF(t *testing.T) {
if f, err := Parse(strings.NewReader("")); err != nil || len(f.Sections()) != 0 {
t.Errorf("пустой файл: %v, %v", f, err)
}
f := mustParse(t, "[s]\r\nk = v\r\n")
if got := f.Section("s").Get("k"); got != "v" {
t.Errorf("CRLF: k = %q", got)
}
}
+210
View File
@@ -0,0 +1,210 @@
// Package initfile читает и пишет файлы инициализации токенов в родном
// XML-формате SafeNet/Aladdin для eToken PASS.
//
// Структура формата: Tokens → Token[@serial] → Applications → Application →
// {Seed, MovingFactor}. Seed записывается шестнадцатеричным текстом, счётчик —
// в MovingFactor. Полей для разрядности, режима и периода в формате нет:
// он описывает только шестиразрядный HOTP, а алгоритм импортирующая сторона
// выводит из длины seed (64 hex-символа — SHA256, иначе SHA1).
package initfile
import (
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"strconv"
"strings"
"time"
"hotpcalc/internal/otp"
"hotpcalc/internal/store"
)
// Значения по умолчанию для полей, которые eToken PASS заполняет на заводе.
const (
DefaultConnectorID = "{ab1397d2-ddb6-4705-b66e-9f83f322deb9}"
DefaultProductName = "eToken PASS"
DefaultCaseModel = "5"
DefaultModel = "101"
)
// Options задаёт заводские поля выгружаемого файла.
type Options struct {
ConnectorID string
ProductName string
CaseModel string
Model string
ProductionDate time.Time
}
func (o Options) withDefaults() Options {
if o.ConnectorID == "" {
o.ConnectorID = DefaultConnectorID
}
if o.ProductName == "" {
o.ProductName = DefaultProductName
}
if o.CaseModel == "" {
o.CaseModel = DefaultCaseModel
}
if o.Model == "" {
o.Model = DefaultModel
}
if o.ProductionDate.IsZero() {
o.ProductionDate = time.Now()
}
return o
}
type xmlTokens struct {
XMLName xml.Name `xml:"Tokens"`
Tokens []xmlToken `xml:"Token"`
}
type xmlToken struct {
Serial string `xml:"serial,attr"`
CaseModel string `xml:"CaseModel,omitempty"`
Model string `xml:"Model,omitempty"`
ProductionDate string `xml:"ProductionDate,omitempty"`
ProductName string `xml:"ProductName,omitempty"`
Applications xmlApplications `xml:"Applications"`
}
type xmlApplications struct {
Applications []xmlApplication `xml:"Application"`
}
type xmlApplication struct {
ConnectorID string `xml:"ConnectorID,attr"`
Seed string `xml:"Seed"`
MovingFactor string `xml:"MovingFactor"`
}
// WriteSafeNet выгружает токены в XML-файл инициализации.
func WriteSafeNet(w io.Writer, tokens []store.Token, o Options) error {
if len(tokens) == 0 {
return fmt.Errorf("нечего выгружать: список токенов пуст")
}
o = o.withDefaults()
doc := xmlTokens{Tokens: make([]xmlToken, 0, len(tokens))}
for _, t := range tokens {
if err := store.ValidateSerial(t.Serial); err != nil {
return err
}
doc.Tokens = append(doc.Tokens, xmlToken{
Serial: t.Serial,
CaseModel: o.CaseModel,
Model: o.Model,
ProductionDate: o.ProductionDate.Format("01/02/2006"),
ProductName: o.ProductName,
Applications: xmlApplications{Applications: []xmlApplication{{
ConnectorID: o.ConnectorID,
Seed: strings.ToUpper(hex.EncodeToString(t.Secret)),
MovingFactor: strconv.FormatUint(t.Counter, 10),
}}},
})
}
if _, err := io.WriteString(w, xml.Header); err != nil {
return err
}
enc := xml.NewEncoder(w)
enc.Indent("", " ")
if err := enc.Encode(doc); err != nil {
return fmt.Errorf("не удалось сформировать XML: %w", err)
}
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
return nil
}
// ReadSafeNet читает файл инициализации. Отсутствующие в формате параметры
// восстанавливаются по тем же правилам, что применяет импортирующая сторона:
// тип hotp, шесть разрядов, алгоритм по длине seed.
func ReadSafeNet(r io.Reader) ([]store.Token, error) {
var doc xmlTokens
dec := xml.NewDecoder(r)
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("не удалось разобрать XML: %w", err)
}
if doc.XMLName.Local != "Tokens" {
return nil, fmt.Errorf("корневой элемент %q, ожидался Tokens", doc.XMLName.Local)
}
if len(doc.Tokens) == 0 {
return nil, fmt.Errorf("в файле нет ни одного элемента Token")
}
out := make([]store.Token, 0, len(doc.Tokens))
for _, xt := range doc.Tokens {
if xt.Serial == "" {
return nil, fmt.Errorf("найден Token без атрибута serial")
}
if len(xt.Applications.Applications) == 0 {
return nil, fmt.Errorf("токен %s: нет элемента Application", xt.Serial)
}
app := xt.Applications.Applications[0]
seed := strings.TrimSpace(app.Seed)
if seed == "" {
return nil, fmt.Errorf("токен %s: нет элемента Seed", xt.Serial)
}
secret, err := hex.DecodeString(seed)
if err != nil {
return nil, fmt.Errorf("токен %s: Seed не является корректным hex: %w", xt.Serial, err)
}
var counter uint64
if mf := strings.TrimSpace(app.MovingFactor); mf != "" {
if counter, err = strconv.ParseUint(mf, 10, 64); err != nil {
return nil, fmt.Errorf("токен %s: некорректный MovingFactor %q", xt.Serial, mf)
}
}
alg := otp.SHA1
if len(seed) == 64 {
alg = otp.SHA256
}
// ProductName у заводских файлов — название модели, а не владелец;
// в метку оно попадает, только если несёт что-то своё.
label := strings.TrimSpace(xt.ProductName)
if label == DefaultProductName {
label = ""
}
t := store.Token{
Serial: xt.Serial, Type: store.TypeHOTP, Secret: secret,
Digits: 6, Alg: alg, Counter: counter, Label: label,
}
if err := t.Validate(); err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// Warnings перечисляет параметры токена, которые формат SafeNet передать
// не может: сервер импортирует такой токен с настройками по умолчанию,
// и коды не сойдутся.
func Warnings(t store.Token) []string {
var w []string
if t.IsTOTP() {
w = append(w, "формат описывает только HOTP — режим TOTP и период будут потеряны")
}
if t.Digits != 6 {
w = append(w, fmt.Sprintf("формат не хранит разрядность — сервер импортирует токен как 6-разрядный, а не %d-разрядный", t.Digits))
}
// Импортирующая сторона выводит алгоритм из длины seed.
guessed := otp.SHA1
if len(t.Secret) == 32 {
guessed = otp.SHA256
}
if t.Alg != guessed {
w = append(w, fmt.Sprintf("формат не хранит алгоритм — при seed в %d байт сервер выберет %s вместо %s",
len(t.Secret), guessed, t.Alg))
}
return w
}
+190
View File
@@ -0,0 +1,190 @@
package initfile
import (
"bytes"
"encoding/hex"
"strings"
"testing"
"time"
"hotpcalc/internal/otp"
"hotpcalc/internal/store"
)
const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D"
func testToken(t *testing.T) store.Token {
t.Helper()
secret, err := hex.DecodeString(testSeedHex)
if err != nil {
t.Fatal(err)
}
return store.Token{
Serial: "0004000ABCDEF1", Type: store.TypeHOTP, Secret: secret,
Digits: 6, Alg: otp.SHA1, Counter: 1,
}
}
// Эталон формата: структура сверена с parseSafeNetXML из privacyIDEA.
const golden = `<?xml version="1.0" encoding="UTF-8"?>
<Tokens>
<Token serial="0004000ABCDEF1">
<CaseModel>5</CaseModel>
<Model>101</Model>
<ProductionDate>09/04/2026</ProductionDate>
<ProductName>eToken PASS</ProductName>
<Applications>
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
<Seed>765D7268D012090C28172A729BC9CC1AD82B382D</Seed>
<MovingFactor>1</MovingFactor>
</Application>
</Applications>
</Token>
</Tokens>
`
func TestWriteSafeNetMatchesGolden(t *testing.T) {
var buf bytes.Buffer
opts := Options{ProductionDate: time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)}
if err := WriteSafeNet(&buf, []store.Token{testToken(t)}, opts); err != nil {
t.Fatalf("WriteSafeNet: %v", err)
}
if buf.String() != golden {
t.Errorf("вывод не совпал с эталоном:\n--- получено ---\n%s\n--- ожидалось ---\n%s",
buf.String(), golden)
}
}
func TestRoundTrip(t *testing.T) {
want := []store.Token{testToken(t)}
want = append(want, store.Token{
Serial: "0004000ABCDEF2", Type: store.TypeHOTP, Secret: want[0].Secret,
Digits: 6, Alg: otp.SHA1, Counter: 123456,
})
var buf bytes.Buffer
if err := WriteSafeNet(&buf, want, Options{}); err != nil {
t.Fatal(err)
}
got, err := ReadSafeNet(&buf)
if err != nil {
t.Fatalf("ReadSafeNet: %v", err)
}
if len(got) != len(want) {
t.Fatalf("прочитано %d токенов, ожидалось %d", len(got), len(want))
}
for i := range want {
if got[i].Serial != want[i].Serial || got[i].Counter != want[i].Counter ||
!bytes.Equal(got[i].Secret, want[i].Secret) || got[i].Alg != want[i].Alg ||
got[i].Digits != want[i].Digits || got[i].Type != want[i].Type {
t.Errorf("токен %d: получено %+v, ожидалось %+v", i, got[i], want[i])
}
}
}
func TestReadSafeNetSample(t *testing.T) {
// Образец в том виде, в каком формат описан у privacyIDEA/LinOTP.
const sample = `<?xml version="1.0" encoding="utf-8"?>
<Tokens>
<Token serial="000400081DFFA5">
<CaseModel>5</CaseModel>
<Model>101</Model>
<ProductionDate>02/19/2009</ProductionDate>
<ProductName>Safeword Alpine</ProductName>
<Applications>
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
<Seed>3132333435363738393031323334353637383930</Seed>
<MovingFactor>1</MovingFactor>
</Application>
</Applications>
</Token>
</Tokens>`
got, err := ReadSafeNet(strings.NewReader(sample))
if err != nil {
t.Fatalf("ReadSafeNet: %v", err)
}
if len(got) != 1 {
t.Fatalf("прочитано %d токенов", len(got))
}
tok := got[0]
if tok.Serial != "000400081DFFA5" || tok.Counter != 1 || tok.Label != "Safeword Alpine" {
t.Errorf("получено %+v", tok)
}
// Seed из RFC 4226: счётчик 0 даёт 755224.
if code := tok.Config().HOTP(0); code != "755224" {
t.Errorf("HOTP(0) = %s, ожидалось 755224 — seed прочитан неверно", code)
}
}
func TestReadSafeNetAlgorithmFromSeedLength(t *testing.T) {
long := strings.Repeat("AB", 32) // 64 hex-символа
x := `<Tokens><Token serial="X"><Applications><Application>` +
`<Seed>` + long + `</Seed><MovingFactor>0</MovingFactor>` +
`</Application></Applications></Token></Tokens>`
got, err := ReadSafeNet(strings.NewReader(x))
if err != nil {
t.Fatal(err)
}
if got[0].Alg != otp.SHA256 {
t.Errorf("для 64-символьного seed ожидался SHA256, получено %s", got[0].Alg)
}
}
func TestReadSafeNetErrors(t *testing.T) {
cases := map[string]string{
"не XML": "просто текст",
"чужой корень": `<KeyContainer/>`,
"нет токенов": `<Tokens></Tokens>`,
"нет serial": `<Tokens><Token><Applications><Application><Seed>AA</Seed></Application></Applications></Token></Tokens>`,
"нет Application": `<Tokens><Token serial="X"><Applications></Applications></Token></Tokens>`,
"нет Seed": `<Tokens><Token serial="X"><Applications><Application><MovingFactor>0</MovingFactor></Application></Applications></Token></Tokens>`,
"Seed не hex": `<Tokens><Token serial="X"><Applications><Application><Seed>ZZZZ</Seed></Application></Applications></Token></Tokens>`,
"MovingFactor не чис": `<Tokens><Token serial="X"><Applications><Application><Seed>` + testSeedHex + `</Seed><MovingFactor>abc</MovingFactor></Application></Applications></Token></Tokens>`,
}
for name, in := range cases {
if _, err := ReadSafeNet(strings.NewReader(in)); err == nil {
t.Errorf("%s: ожидалась ошибка", name)
}
}
}
func TestWriteSafeNetRejectsEmpty(t *testing.T) {
var buf bytes.Buffer
if err := WriteSafeNet(&buf, nil, Options{}); err == nil {
t.Error("пустой список должен отвергаться")
}
}
func TestWarnings(t *testing.T) {
ok := testToken(t)
if w := Warnings(ok); len(w) != 0 {
t.Errorf("для обычного HOTP-токена предупреждений быть не должно: %v", w)
}
totp := ok
totp.Type = store.TypeTOTP
if w := Warnings(totp); len(w) != 1 || !strings.Contains(w[0], "только HOTP") {
t.Errorf("TOTP: %v", w)
}
eight := ok
eight.Digits = 8
if w := Warnings(eight); len(w) != 1 || !strings.Contains(w[0], "разрядность") {
t.Errorf("8 разрядов: %v", w)
}
// SHA256 при 20-байтном seed сервер прочитает как SHA1.
sha := ok
sha.Alg = otp.SHA256
if w := Warnings(sha); len(w) != 1 || !strings.Contains(w[0], "алгоритм") {
t.Errorf("SHA256 с коротким seed: %v", w)
}
// SHA256 при 32-байтном seed передаётся корректно.
good := ok
good.Alg = otp.SHA256
good.Secret = bytes.Repeat([]byte{1}, 32)
if w := Warnings(good); len(w) != 0 {
t.Errorf("SHA256 с 32-байтным seed не должен давать предупреждений: %v", w)
}
}
+135
View File
@@ -0,0 +1,135 @@
// Package otp реализует алгоритмы одноразовых паролей HOTP (RFC 4226)
// и TOTP (RFC 6238) в том виде, в каком их использует SafeNet eToken PASS.
package otp
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/binary"
"fmt"
"hash"
"strings"
"time"
)
// Algorithm — хеш, на котором строится HMAC. eToken PASS использует SHA1.
type Algorithm int
const (
SHA1 Algorithm = iota
SHA256
SHA512
)
func (a Algorithm) String() string {
switch a {
case SHA256:
return "SHA256"
case SHA512:
return "SHA512"
default:
return "SHA1"
}
}
func (a Algorithm) new() func() hash.Hash {
switch a {
case SHA256:
return sha256.New
case SHA512:
return sha512.New
default:
return sha1.New
}
}
// ParseAlgorithm разбирает название алгоритма без учёта регистра.
func ParseAlgorithm(s string) (Algorithm, error) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SHA1", "SHA-1", "":
return SHA1, nil
case "SHA256", "SHA-256":
return SHA256, nil
case "SHA512", "SHA-512":
return SHA512, nil
}
return SHA1, fmt.Errorf("неизвестный алгоритм %q (допустимо: sha1, sha256, sha512)", s)
}
// DefaultStep — шаг времени TOTP по умолчанию (RFC 6238).
const DefaultStep = 30 * time.Second
// Config описывает параметры конкретного токена.
type Config struct {
Secret []byte // вектор инициализации (seed)
Digits int // разрядность кода: 6 или 8
Alg Algorithm // хеш для HMAC
Step time.Duration // шаг времени, только для TOTP
}
// Validate проверяет параметры на осмысленность.
func (c Config) Validate() error {
if len(c.Secret) == 0 {
return fmt.Errorf("пустой seed")
}
if c.Digits < 6 || c.Digits > 10 {
return fmt.Errorf("разрядность %d вне допустимого диапазона 6..10", c.Digits)
}
if c.Step <= 0 {
return fmt.Errorf("шаг времени должен быть положительным")
}
return nil
}
var pow10 = [...]uint32{1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000}
// HOTP считает одноразовый пароль для указанного значения счётчика (RFC 4226).
func (c Config) HOTP(counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(c.Alg.new(), c.Secret)
mac.Write(buf[:])
sum := mac.Sum(nil)
// Динамическое усечение: младшие 4 бита последнего байта задают смещение.
offset := sum[len(sum)-1] & 0x0F
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
digits := c.Digits
if digits < 1 || digits >= len(pow10) {
digits = 6
}
return fmt.Sprintf("%0*d", digits, code%pow10[digits])
}
// TimeStep возвращает номер временного шага T для момента t (RFC 6238).
func (c Config) TimeStep(t time.Time) uint64 {
step := c.Step
if step <= 0 {
step = DefaultStep
}
return uint64(t.Unix() / int64(step/time.Second))
}
// StepStart возвращает момент начала временного шага с номером step.
func (c Config) StepStart(step uint64) time.Time {
d := c.Step
if d <= 0 {
d = DefaultStep
}
return time.Unix(int64(step)*int64(d/time.Second), 0)
}
// TOTP считает одноразовый пароль, действующий в момент t.
func (c Config) TOTP(t time.Time) string {
return c.HOTP(c.TimeStep(t))
}
// equalCode сравнивает коды за постоянное время.
func equalCode(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+108
View File
@@ -0,0 +1,108 @@
package otp
import (
"testing"
"time"
)
// RFC 4226, Appendix D: ключ ASCII "12345678901234567890".
var rfc4226Key = []byte("12345678901234567890")
func TestHOTP_RFC4226(t *testing.T) {
want := []string{
"755224", "287082", "359152", "969429", "338314",
"254676", "287922", "162583", "399871", "520489",
}
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
for i, w := range want {
if got := c.HOTP(uint64(i)); got != w {
t.Errorf("HOTP(%d) = %s, ожидалось %s", i, got, w)
}
}
}
// RFC 6238, Appendix B. Ключи для SHA256/SHA512 — продолжение той же
// последовательности "12345678901234567890..." до размера блока хеша.
func seedRepeat(n int) []byte {
base := "12345678901234567890"
out := make([]byte, 0, n)
for len(out) < n {
out = append(out, base...)
}
return out[:n]
}
func TestTOTP_RFC6238(t *testing.T) {
cases := []struct {
unix int64
alg Algorithm
key []byte
want string
}{
{59, SHA1, seedRepeat(20), "94287082"},
{59, SHA256, seedRepeat(32), "46119246"},
{59, SHA512, seedRepeat(64), "90693936"},
{1111111109, SHA1, seedRepeat(20), "07081804"},
{1111111111, SHA1, seedRepeat(20), "14050471"},
{1234567890, SHA1, seedRepeat(20), "89005924"},
{2000000000, SHA1, seedRepeat(20), "69279037"},
{20000000000, SHA1, seedRepeat(20), "65353130"},
{1111111109, SHA256, seedRepeat(32), "68084774"},
{20000000000, SHA256, seedRepeat(32), "77737706"},
{1111111109, SHA512, seedRepeat(64), "25091201"},
{20000000000, SHA512, seedRepeat(64), "47863826"},
}
for _, tc := range cases {
c := Config{Secret: tc.key, Digits: 8, Alg: tc.alg, Step: DefaultStep}
got := c.TOTP(time.Unix(tc.unix, 0).UTC())
if got != tc.want {
t.Errorf("TOTP(%d, %s) = %s, ожидалось %s", tc.unix, tc.alg, got, tc.want)
}
}
}
func TestHOTP_LeadingZeros(t *testing.T) {
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
for i := uint64(0); i < 5000; i++ {
if got := c.HOTP(i); len(got) != 6 {
t.Fatalf("HOTP(%d) = %q: длина %d вместо 6", i, got, len(got))
}
}
}
func TestTimeStepAndStepStart(t *testing.T) {
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
if got := c.TimeStep(time.Unix(59, 0)); got != 1 {
t.Errorf("TimeStep(59) = %d, ожидалось 1", got)
}
if got := c.StepStart(1).Unix(); got != 30 {
t.Errorf("StepStart(1) = %d, ожидалось 30", got)
}
c60 := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: 60 * time.Second}
if got := c60.TimeStep(time.Unix(119, 0)); got != 1 {
t.Errorf("TimeStep(119, шаг 60с) = %d, ожидалось 1", got)
}
}
func TestValidate(t *testing.T) {
if err := (Config{Digits: 6, Step: DefaultStep}).Validate(); err == nil {
t.Error("пустой seed должен отвергаться")
}
if err := (Config{Secret: rfc4226Key, Digits: 4, Step: DefaultStep}).Validate(); err == nil {
t.Error("разрядность 4 должна отвергаться")
}
if err := (Config{Secret: rfc4226Key, Digits: 6}).Validate(); err == nil {
t.Error("нулевой шаг должен отвергаться")
}
}
func TestParseAlgorithm(t *testing.T) {
for _, s := range []string{"sha1", "SHA-1", ""} {
if a, err := ParseAlgorithm(s); err != nil || a != SHA1 {
t.Errorf("ParseAlgorithm(%q) = %v, %v", s, a, err)
}
}
if _, err := ParseAlgorithm("md5"); err == nil {
t.Error("md5 должен отвергаться")
}
}
+87
View File
@@ -0,0 +1,87 @@
package otp
import (
"errors"
"fmt"
"math"
"time"
)
// Match — найденная позиция токена. Counter соответствует ПЕРВОМУ из двух
// введённых кодов; второй код приходится на Counter+1.
type Match struct {
Counter uint64
Time time.Time // начало окна первого кода; заполняется только для TOTP
}
// ErrNotFound возвращается, когда пара кодов не найдена в заданном диапазоне.
type ErrNotFound struct {
From, To uint64
Mode string
}
func (e *ErrNotFound) Error() string {
return fmt.Sprintf("пара кодов не найдена в диапазоне %s %d..%d", e.Mode, e.From, e.To)
}
// FindCounter ищет значение счётчика HOTP, при котором два введённых кода
// идут подряд. Перебираются счётчики [start, start+span).
func FindCounter(c Config, otp1, otp2 string, start, span uint64) ([]Match, error) {
if err := c.Validate(); err != nil {
return nil, err
}
if span < 2 {
return nil, fmt.Errorf("диапазон перебора должен быть не меньше 2")
}
end := start + span - 1 // включительно; защита от переполнения ниже
if end < start {
end = math.MaxUint64
}
var matches []Match
prev := c.HOTP(start)
for i := start; i < end; i++ {
cur := c.HOTP(i + 1)
if equalCode(prev, otp1) && equalCode(cur, otp2) {
matches = append(matches, Match{Counter: i})
}
prev = cur
}
if len(matches) == 0 {
return nil, &ErrNotFound{From: start, To: end, Mode: "счётчика"}
}
return matches, nil
}
// FindTimeStep ищет временной шаг TOTP, при котором два введённых кода идут
// подряд. Перебор идёт вокруг момента now в пределах ±window.
func FindTimeStep(c Config, otp1, otp2 string, now time.Time, window time.Duration) ([]Match, error) {
if err := c.Validate(); err != nil {
return nil, err
}
if window < 0 {
return nil, fmt.Errorf("окно поиска не может быть отрицательным")
}
center := c.TimeStep(now)
half := uint64(window / c.Step)
start := uint64(0)
if center > half {
start = center - half
}
span := half*2 + 2 // +2, чтобы центр и обе границы попали в пары
matches, err := FindCounter(c, otp1, otp2, start, span)
if err != nil {
var nf *ErrNotFound
if errors.As(err, &nf) {
nf.Mode = "временного шага"
}
return nil, err
}
for i := range matches {
matches[i].Time = c.StepStart(matches[i].Counter)
}
return matches, nil
}
+139
View File
@@ -0,0 +1,139 @@
package otp
import (
"errors"
"testing"
"time"
)
func exampleConfig(t *testing.T) Config {
t.Helper()
s, err := ParseSeed(exampleSeed, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
return Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep}
}
func TestFindCounter_RoundTrip(t *testing.T) {
c := exampleConfig(t)
const want = uint64(41)
m, err := FindCounter(c, c.HOTP(want), c.HOTP(want+1), 0, 100000)
if err != nil {
t.Fatalf("FindCounter: %v", err)
}
if len(m) != 1 {
t.Fatalf("найдено %d совпадений, ожидалось 1: %+v", len(m), m)
}
if m[0].Counter != want {
t.Errorf("счётчик = %d, ожидалось %d", m[0].Counter, want)
}
}
func TestFindCounter_RFCVectors(t *testing.T) {
// Пара 287082/359152 из RFC 4226 — это счётчики 1 и 2.
c := Config{Secret: rfc4226Key, Digits: 6, Alg: SHA1, Step: DefaultStep}
m, err := FindCounter(c, "287082", "359152", 0, 1000)
if err != nil {
t.Fatalf("FindCounter: %v", err)
}
if m[0].Counter != 1 {
t.Errorf("счётчик = %d, ожидалось 1", m[0].Counter)
}
}
func TestFindCounter_NotFound(t *testing.T) {
c := exampleConfig(t)
_, err := FindCounter(c, "000000", "000000", 0, 1000)
var nf *ErrNotFound
if !errors.As(err, &nf) {
t.Fatalf("ожидалась ErrNotFound, получено %v", err)
}
}
func TestFindCounter_OutOfRange(t *testing.T) {
c := exampleConfig(t)
// Позиция 500 лежит вне диапазона [0, 100).
if _, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 0, 100); err == nil {
t.Error("ожидалась ошибка: позиция вне диапазона перебора")
}
// С правильным смещением находится.
m, err := FindCounter(c, c.HOTP(500), c.HOTP(501), 450, 100)
if err != nil {
t.Fatalf("FindCounter со смещением: %v", err)
}
if m[0].Counter != 500 {
t.Errorf("счётчик = %d, ожидалось 500", m[0].Counter)
}
}
func TestFindCounter_SpanTooSmall(t *testing.T) {
c := exampleConfig(t)
if _, err := FindCounter(c, "123456", "654321", 0, 1); err == nil {
t.Error("span < 2 должен отвергаться")
}
}
func TestFindCounter_ReversedOrderNotMatched(t *testing.T) {
c := exampleConfig(t)
// Коды, поданные в обратном порядке, не должны находиться.
if _, err := FindCounter(c, c.HOTP(11), c.HOTP(10), 0, 10000); err == nil {
t.Error("перепутанный порядок кодов не должен давать совпадение")
}
}
func TestFindTimeStep_RoundTrip(t *testing.T) {
c := exampleConfig(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Токен «спешит» на 2 минуты: коды сняты на 4 шага вперёд.
step := c.TimeStep(now) + 4
m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Hour)
if err != nil {
t.Fatalf("FindTimeStep: %v", err)
}
if len(m) != 1 {
t.Fatalf("найдено %d совпадений, ожидалось 1", len(m))
}
if m[0].Counter != step {
t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step)
}
if want := c.StepStart(step); !m[0].Time.Equal(want) {
t.Errorf("время = %v, ожидалось %v", m[0].Time, want)
}
}
func TestFindTimeStep_Step60(t *testing.T) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: 60 * time.Second}
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
step := c.TimeStep(now) - 10
m, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, 24*time.Hour)
if err != nil {
t.Fatalf("FindTimeStep: %v", err)
}
if m[0].Counter != step {
t.Errorf("шаг = %d, ожидалось %d", m[0].Counter, step)
}
}
func TestFindTimeStep_OutsideWindow(t *testing.T) {
c := exampleConfig(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
step := c.TimeStep(now) + 10000
if _, err := FindTimeStep(c, c.HOTP(step), c.HOTP(step+1), now, time.Minute); err == nil {
t.Error("ожидалась ошибка: позиция вне окна поиска")
}
}
func BenchmarkFindCounter(b *testing.B) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
c := Config{Secret: s.Secret, Digits: 6, Alg: SHA1, Step: DefaultStep}
o1, o2 := c.HOTP(999998), c.HOTP(999999)
for b.Loop() {
if _, err := FindCounter(c, o1, o2, 0, 1000000); err != nil {
b.Fatal(err)
}
}
}
+186
View File
@@ -0,0 +1,186 @@
package otp
import (
"encoding/base32"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
// SeedFormat — способ кодирования вектора инициализации.
type SeedFormat string
const (
FormatAuto SeedFormat = "auto"
FormatHex SeedFormat = "hex"
FormatBase32 SeedFormat = "base32"
)
// Seed — разобранный вектор инициализации вместе с параметрами,
// которые удалось извлечь из otpauth:// URI (если он был на входе).
type Seed struct {
Secret []byte
Format SeedFormat // фактически распознанный формат
// Параметры из otpauth:// URI; nil, если не заданы.
Digits *int
Alg *Algorithm
Step *time.Duration
Counter *uint64
Label string
TOTP bool // true, если URI описывает totp
FromURI bool
}
// cleanSeed убирает разделители, которыми seed обычно печатают на бумаге.
func cleanSeed(s string) string {
return strings.Map(func(r rune) rune {
switch r {
case ' ', '\t', '\n', '\r', '-', ':', '_', '.':
return -1
}
return r
}, s)
}
func isHexString(s string) bool {
if s == "" || len(s)%2 != 0 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
func decodeBase32(s string) ([]byte, error) {
s = strings.ToUpper(strings.TrimRight(s, "="))
if s == "" {
return nil, fmt.Errorf("пустая строка")
}
if pad := len(s) % 8; pad != 0 {
s += strings.Repeat("=", 8-pad)
}
return base32.StdEncoding.DecodeString(s)
}
// ParseSeed разбирает вектор инициализации в hex, Base32 или otpauth:// URI.
func ParseSeed(s string, format SeedFormat) (Seed, error) {
raw := strings.TrimSpace(s)
if raw == "" {
return Seed{}, fmt.Errorf("seed не задан")
}
if strings.HasPrefix(strings.ToLower(raw), "otpauth://") {
return parseOtpauth(raw)
}
clean := cleanSeed(raw)
if clean == "" {
return Seed{}, fmt.Errorf("seed не содержит значащих символов")
}
switch format {
case FormatHex:
b, err := hex.DecodeString(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не является корректным hex: %w", err)
}
return Seed{Secret: b, Format: FormatHex}, nil
case FormatBase32:
b, err := decodeBase32(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не является корректным Base32: %w", err)
}
return Seed{Secret: b, Format: FormatBase32}, nil
case FormatAuto, "":
if isHexString(clean) {
b, err := hex.DecodeString(clean)
if err == nil {
return Seed{Secret: b, Format: FormatHex}, nil
}
}
b, err := decodeBase32(clean)
if err != nil {
return Seed{}, fmt.Errorf("seed не распознан ни как hex, ни как Base32: %w", err)
}
return Seed{Secret: b, Format: FormatBase32}, nil
}
return Seed{}, fmt.Errorf("неизвестный формат seed %q (допустимо: auto, hex, base32)", format)
}
func parseOtpauth(raw string) (Seed, error) {
u, err := url.Parse(raw)
if err != nil {
return Seed{}, fmt.Errorf("не удалось разобрать otpauth URI: %w", err)
}
q := u.Query()
secret := q.Get("secret")
if secret == "" {
return Seed{}, fmt.Errorf("в otpauth URI нет параметра secret")
}
b, err := decodeBase32(cleanSeed(secret))
if err != nil {
return Seed{}, fmt.Errorf("secret из otpauth URI не является корректным Base32: %w", err)
}
sd := Seed{
Secret: b,
Format: FormatBase32,
Label: strings.TrimPrefix(u.Path, "/"),
TOTP: strings.EqualFold(u.Host, "totp"),
FromURI: true,
}
if v := q.Get("digits"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return Seed{}, fmt.Errorf("некорректный digits в otpauth URI: %w", err)
}
sd.Digits = &n
}
if v := q.Get("algorithm"); v != "" {
a, err := ParseAlgorithm(v)
if err != nil {
return Seed{}, err
}
sd.Alg = &a
}
if v := q.Get("period"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return Seed{}, fmt.Errorf("некорректный period в otpauth URI: %q", v)
}
d := time.Duration(n) * time.Second
sd.Step = &d
}
if v := q.Get("counter"); v != "" {
n, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return Seed{}, fmt.Errorf("некорректный counter в otpauth URI: %w", err)
}
sd.Counter = &n
}
return sd, nil
}
// Fingerprint возвращает укороченное представление seed для вывода на экран,
// чтобы не печатать секрет целиком.
func Fingerprint(secret []byte) string {
h := strings.ToUpper(hex.EncodeToString(secret))
if len(h) <= 12 {
return h
}
return h[:8] + "…" + h[len(h)-4:]
}
+117
View File
@@ -0,0 +1,117 @@
package otp
import (
"encoding/hex"
"strings"
"testing"
"time"
)
// Seed из примера задачи: eToken PASS, 20 байт в hex.
const exampleSeed = "765D7268D012090C28172A729BC9CC1AD82B382D"
func TestParseSeed_Hex(t *testing.T) {
for _, in := range []string{
exampleSeed,
strings.ToLower(exampleSeed),
"765D 7268 D012 090C 2817 2A72 9BC9 CC1A D82B 382D",
"76-5D-72-68-D0-12-09-0C-28-17-2A-72-9B-C9-CC-1A-D8-2B-38-2D",
} {
s, err := ParseSeed(in, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed(%q): %v", in, err)
}
if s.Format != FormatHex {
t.Errorf("ParseSeed(%q): формат %s, ожидался hex", in, s.Format)
}
if got := strings.ToUpper(hex.EncodeToString(s.Secret)); got != exampleSeed {
t.Errorf("ParseSeed(%q) = %s", in, got)
}
}
}
func TestParseSeed_Base32(t *testing.T) {
// Base32 от ASCII-ключа "12345678901234567890" из RFC 4226.
const b32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
s, err := ParseSeed(b32, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
if s.Format != FormatBase32 {
t.Errorf("формат %s, ожидался base32", s.Format)
}
if string(s.Secret) != string(rfc4226Key) {
t.Errorf("secret = %q, ожидалось %q", s.Secret, rfc4226Key)
}
// Без padding и в нижнем регистре тоже должно работать.
if s2, err := ParseSeed("gezdgnbvgy3tqojq", FormatAuto); err != nil || len(s2.Secret) != 10 {
t.Errorf("Base32 без padding: %v, %d байт", err, len(s2.Secret))
}
}
func TestParseSeed_ForcedFormat(t *testing.T) {
// "12345678" — валидный hex и валидный набор символов не для Base32.
if _, err := ParseSeed(exampleSeed, FormatBase32); err == nil {
t.Error("hex-строка с цифрами 0/1 не должна разбираться как Base32")
}
if s, err := ParseSeed(exampleSeed, FormatHex); err != nil || len(s.Secret) != 20 {
t.Errorf("принудительный hex: %v", err)
}
}
func TestParseSeed_Errors(t *testing.T) {
for _, in := range []string{"", " ", "zzzz!!!", "765D7268D"} {
if _, err := ParseSeed(in, FormatAuto); err == nil {
t.Errorf("ParseSeed(%q) должен возвращать ошибку", in)
}
}
}
func TestParseSeed_OtpauthURI(t *testing.T) {
uri := "otpauth://totp/ACME:user@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" +
"&issuer=ACME&digits=8&algorithm=SHA256&period=60"
s, err := ParseSeed(uri, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed: %v", err)
}
if !s.FromURI || !s.TOTP {
t.Error("URI должен распознаваться как TOTP")
}
if s.Digits == nil || *s.Digits != 8 {
t.Error("digits из URI не подхвачен")
}
if s.Alg == nil || *s.Alg != SHA256 {
t.Error("algorithm из URI не подхвачен")
}
if s.Step == nil || *s.Step != 60*time.Second {
t.Error("period из URI не подхвачен")
}
if s.Label != "ACME:user@example.com" {
t.Errorf("label = %q", s.Label)
}
hotpURI := "otpauth://hotp/tok?secret=GEZDGNBVGY3TQOJQ&counter=42"
h, err := ParseSeed(hotpURI, FormatAuto)
if err != nil {
t.Fatalf("ParseSeed(hotp): %v", err)
}
if h.TOTP {
t.Error("hotp URI не должен помечаться как TOTP")
}
if h.Counter == nil || *h.Counter != 42 {
t.Error("counter из URI не подхвачен")
}
if _, err := ParseSeed("otpauth://totp/x?issuer=ACME", FormatAuto); err == nil {
t.Error("URI без secret должен отвергаться")
}
}
func TestFingerprint(t *testing.T) {
s, _ := ParseSeed(exampleSeed, FormatAuto)
if got := Fingerprint(s.Secret); got != "765D7268…382D" {
t.Errorf("Fingerprint = %q", got)
}
if got := Fingerprint([]byte{0x01, 0x02}); got != "0102" {
t.Errorf("короткий Fingerprint = %q", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
const (
// SerialLength — длина серийного номера eToken PASS в символах.
SerialLength = 14
// DefaultSerialPrefix — префикс серийных номеров eToken PASS.
DefaultSerialPrefix = "000400"
// SeedBytes — длина вектора инициализации в байтах.
SeedBytes = 20
)
// NewSeed генерирует криптографически стойкий вектор инициализации.
func NewSeed(n int) ([]byte, error) {
if n <= 0 {
n = SeedBytes
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("не удалось получить случайные данные: %w", err)
}
return b, nil
}
// NewSerial генерирует серийный номер: заданный префикс и случайный
// шестнадцатеричный хвост до общей длины SerialLength.
func NewSerial(prefix string) (string, error) {
prefix = strings.ToUpper(strings.TrimSpace(prefix))
if err := ValidateSerial(prefix + "0"); err != nil {
return "", fmt.Errorf("некорректный префикс серийного номера: %w", err)
}
suffix := SerialLength - len(prefix)
if suffix < 4 {
suffix = 4
}
b := make([]byte, (suffix+1)/2)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("не удалось получить случайные данные: %w", err)
}
return prefix + strings.ToUpper(hex.EncodeToString(b))[:suffix], nil
}
// NewUniqueSerial подбирает серийный номер, которого ещё нет в хранилище.
func (s *Store) NewUniqueSerial(prefix string) (string, error) {
for i := 0; i < 100; i++ {
serial, err := NewSerial(prefix)
if err != nil {
return "", err
}
if s.file.Section(serial) == nil {
return serial, nil
}
}
return "", fmt.Errorf("не удалось подобрать свободный серийный номер с префиксом %q", prefix)
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"fmt"
"os"
"strings"
"time"
)
const (
// lockTimeout — сколько ждём освобождения хранилища другим процессом.
lockTimeout = 5 * time.Second
// lockStaleAfter — после этого возраста замок считается брошенным
// (процесс убит, не успев его снять).
lockStaleAfter = 60 * time.Second
)
// Lock — межпроцессная блокировка хранилища на основе файла, создаваемого
// с O_EXCL. В отличие от flock работает одинаково на Unix и Windows.
type Lock struct {
path string
warn func(string)
}
// acquireLock захватывает замок для файла хранилища path.
// warn получает предупреждения (обычно печатается в stderr); может быть nil.
func acquireLock(path string, warn func(string)) (*Lock, error) {
if warn == nil {
warn = func(string) {}
}
l := &Lock{path: path + ".lock", warn: warn}
deadline := time.Now().Add(lockTimeout)
delay := 10 * time.Millisecond
brokeStale := false
for {
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
fmt.Fprintf(f, "pid %d, %s\n", os.Getpid(), time.Now().Format(time.RFC3339))
f.Close()
return l, nil
}
if !os.IsExist(err) {
return nil, fmt.Errorf("не удалось создать файл блокировки %s: %w", l.path, err)
}
// Замок занят: возможно, его владелец давно умер.
if !brokeStale {
if fi, statErr := os.Stat(l.path); statErr == nil && time.Since(fi.ModTime()) > lockStaleAfter {
l.warn(fmt.Sprintf("снимаю брошенный файл блокировки %s (возраст %s)",
l.path, time.Since(fi.ModTime()).Round(time.Second)))
os.Remove(l.path)
brokeStale = true
continue
}
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("хранилище занято другим процессом: файл блокировки %s.\n"+
"Если процесс уже завершён, удалите этот файл вручную", l.path)
}
time.Sleep(delay)
if delay < 200*time.Millisecond {
delay *= 2
}
}
}
// release снимает блокировку.
func (l *Lock) release() error {
if l == nil {
return nil
}
if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("не удалось снять блокировку %s: %w", l.path, err)
}
return nil
}
// IsLockPath сообщает, является ли путь служебным файлом блокировки.
func IsLockPath(p string) bool { return strings.HasSuffix(p, ".lock") }
+350
View File
@@ -0,0 +1,350 @@
// Package store хранит токены и их состояние в ini-файле.
package store
import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"hotpcalc/internal/ini"
"hotpcalc/internal/otp"
)
// Типы токенов.
const (
TypeHOTP = "hotp"
TypeTOTP = "totp"
)
// EnvStore — переменная окружения, задающая путь к хранилищу.
const EnvStore = "HOTPCALC_STORE"
// FileName — имя файла хранилища по умолчанию.
const FileName = "tokens.ini"
// Token — токен и его текущее состояние.
type Token struct {
Serial string
Type string
Secret []byte
Digits int
Alg otp.Algorithm
Period time.Duration // только TOTP
Counter uint64 // HOTP: счётчик СЛЕДУЮЩЕГО значения
LastStep uint64 // TOTP: последний выданный временной шаг
Label string
Created time.Time
LastUsed time.Time
}
// IsTOTP сообщает, является ли токен временным.
func (t Token) IsTOTP() bool { return t.Type == TypeTOTP }
// Config строит параметры расчёта для пакета otp.
func (t Token) Config() otp.Config {
step := t.Period
if step <= 0 {
step = otp.DefaultStep
}
return otp.Config{Secret: t.Secret, Digits: t.Digits, Alg: t.Alg, Step: step}
}
// Validate проверяет токен перед записью в хранилище.
func (t Token) Validate() error {
if err := ValidateSerial(t.Serial); err != nil {
return err
}
if t.Type != TypeHOTP && t.Type != TypeTOTP {
return fmt.Errorf("токен %s: неизвестный тип %q (допустимо: hotp, totp)", t.Serial, t.Type)
}
if strings.ContainsAny(t.Label, "\r\n") {
return fmt.Errorf("токен %s: метка содержит перевод строки", t.Serial)
}
return t.Config().Validate()
}
// ValidateSerial проверяет серийный номер: он становится именем секции ini.
func ValidateSerial(s string) error {
if s == "" {
return fmt.Errorf("пустой серийный номер")
}
if len(s) > 64 {
return fmt.Errorf("серийный номер длиннее 64 символов: %q", s)
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z',
r == '-', r == '_', r == '.':
default:
return fmt.Errorf("недопустимый символ %q в серийном номере %q "+
"(разрешены буквы, цифры, -, _ и точка)", r, s)
}
}
return nil
}
// DefaultPath возвращает путь к хранилищу по умолчанию: переменная окружения
// HOTPCALC_STORE, иначе tokens.ini рядом с исполняемым файлом.
func DefaultPath() string {
if p := os.Getenv(EnvStore); p != "" {
return p
}
if exe, err := os.Executable(); err == nil {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Join(filepath.Dir(exe), FileName)
}
return FileName
}
// Store — открытое хранилище. Пока оно открыто, файл заблокирован
// для других процессов, поэтому закрывать его нужно всегда.
type Store struct {
path string
file *ini.File
lock *Lock
}
var preamble = []string{
"; hotpcalc — хранилище токенов",
"; Файл содержит секреты в открытом виде: защищайте его как пароль.",
"; Одна секция — один токен, имя секции — серийный номер.",
}
// Open открывает хранилище, захватывая блокировку. Отсутствующий файл —
// не ошибка: он будет создан при первом Commit.
func Open(path string, warn func(string)) (*Store, error) {
if path == "" {
return nil, fmt.Errorf("не задан путь к хранилищу")
}
lock, err := acquireLock(path, warn)
if err != nil {
return nil, err
}
s := &Store{path: path, lock: lock}
data, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
s.file = ini.New()
s.file.SetPreamble(preamble)
case err != nil:
lock.release()
return nil, fmt.Errorf("не удалось прочитать хранилище %s: %w", path, err)
default:
s.file, err = ini.Parse(strings.NewReader(string(data)))
if err != nil {
lock.release()
return nil, fmt.Errorf("хранилище %s повреждено: %w", path, err)
}
}
return s, nil
}
// Close снимает блокировку. Несохранённые изменения теряются.
func (s *Store) Close() error {
if s == nil {
return nil
}
return s.lock.release()
}
// Path возвращает путь к файлу хранилища.
func (s *Store) Path() string { return s.path }
// Serials возвращает серийные номера в порядке их появления в файле.
func (s *Store) Serials() []string { return s.file.Sections() }
// Get читает токен по серийному номеру.
func (s *Store) Get(serial string) (Token, bool, error) {
sec := s.file.Section(serial)
if sec == nil {
return Token{}, false, nil
}
t, err := tokenFromSection(serial, sec)
if err != nil {
return Token{}, true, err
}
return t, true, nil
}
// List возвращает все токены хранилища.
func (s *Store) List() ([]Token, error) {
var out []Token
for _, serial := range s.file.Sections() {
t, _, err := s.Get(serial)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// Put добавляет или обновляет токен.
func (s *Store) Put(t Token) error {
if err := t.Validate(); err != nil {
return err
}
sec := s.file.AddSection(t.Serial)
sec.Set("type", t.Type)
sec.Set("seed", strings.ToUpper(hex.EncodeToString(t.Secret)))
sec.Set("digits", strconv.Itoa(t.Digits))
sec.Set("algorithm", strings.ToLower(t.Alg.String()))
if t.IsTOTP() {
sec.Unset("counter")
sec.Set("period", strconv.Itoa(int(t.Config().Step/time.Second)))
sec.Set("last_step", strconv.FormatUint(t.LastStep, 10))
} else {
sec.Unset("period")
sec.Unset("last_step")
sec.Set("counter", strconv.FormatUint(t.Counter, 10))
}
setOrUnset(sec, "label", t.Label)
setOrUnset(sec, "created", formatTime(t.Created))
setOrUnset(sec, "last_used", formatTime(t.LastUsed))
return nil
}
// Remove удаляет токен; возвращает true, если он был.
func (s *Store) Remove(serial string) bool { return s.file.RemoveSection(serial) }
// Commit атомарно записывает хранилище на диск.
func (s *Store) Commit() error {
data, err := s.file.Bytes()
if err != nil {
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("не удалось создать каталог %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp-*")
if err != nil {
return fmt.Errorf("не удалось создать временный файл в %s: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // не сработает после успешного Rename
if err := tmp.Chmod(0o600); err != nil && !os.IsPermission(err) {
tmp.Close()
return fmt.Errorf("не удалось выставить права на %s: %w", tmpName, err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("не удалось записать %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("не удалось сбросить на диск %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("не удалось закрыть %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("не удалось заменить %s: %w", s.path, err)
}
return nil
}
func setOrUnset(sec *ini.Section, key, value string) {
if value == "" {
sec.Unset(key)
return
}
sec.Set(key, value)
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func tokenFromSection(serial string, sec *ini.Section) (Token, error) {
fail := func(format string, args ...any) (Token, error) {
return Token{}, fmt.Errorf("токен %s: %s", serial, fmt.Sprintf(format, args...))
}
t := Token{Serial: serial, Type: sec.Get("type")}
if t.Type == "" {
t.Type = TypeHOTP
}
if t.Type != TypeHOTP && t.Type != TypeTOTP {
return fail("неизвестный тип %q", t.Type)
}
seed := sec.Get("seed")
if seed == "" {
return fail("не задан seed")
}
parsed, err := otp.ParseSeed(seed, otp.FormatAuto)
if err != nil {
return fail("%v", err)
}
t.Secret = parsed.Secret
t.Digits = 6
if v := sec.Get("digits"); v != "" {
if t.Digits, err = strconv.Atoi(v); err != nil {
return fail("некорректное значение digits %q", v)
}
}
if t.Alg, err = otp.ParseAlgorithm(sec.Get("algorithm")); err != nil {
return fail("%v", err)
}
if t.IsTOTP() {
t.Period = otp.DefaultStep
if v := sec.Get("period"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fail("некорректное значение period %q", v)
}
t.Period = time.Duration(n) * time.Second
}
if t.LastStep, err = parseUint(sec.Get("last_step")); err != nil {
return fail("некорректное значение last_step %q", sec.Get("last_step"))
}
} else if t.Counter, err = parseUint(sec.Get("counter")); err != nil {
return fail("некорректное значение counter %q", sec.Get("counter"))
}
t.Label = sec.Get("label")
if t.Created, err = parseTime(sec.Get("created")); err != nil {
return fail("некорректное значение created %q", sec.Get("created"))
}
if t.LastUsed, err = parseTime(sec.Get("last_used")); err != nil {
return fail("некорректное значение last_used %q", sec.Get("last_used"))
}
if err := t.Config().Validate(); err != nil {
return fail("%v", err)
}
return t, nil
}
func parseUint(s string) (uint64, error) {
if s == "" {
return 0, nil
}
return strconv.ParseUint(s, 10, 64)
}
func parseTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, s)
}
+321
View File
@@ -0,0 +1,321 @@
package store
import (
"encoding/hex"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"hotpcalc/internal/otp"
)
const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D"
func testSeed(t *testing.T) []byte {
t.Helper()
b, err := hex.DecodeString(testSeedHex)
if err != nil {
t.Fatal(err)
}
return b
}
func openTemp(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.ini")
s, err := Open(path, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s, path
}
func sampleToken(t *testing.T) Token {
return Token{
Serial: "0004000ABCDEF1", Type: TypeHOTP, Secret: testSeed(t),
Digits: 6, Alg: otp.SHA1, Counter: 42, Label: "Иванов И.И.",
Created: time.Now().Truncate(time.Second),
}
}
func TestPutGetCommitReopen(t *testing.T) {
s, path := openTemp(t)
want := sampleToken(t)
if err := s.Put(want); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}
s.Close()
s2, err := Open(path, nil)
if err != nil {
t.Fatalf("повторное открытие: %v", err)
}
defer s2.Close()
got, ok, err := s2.Get(want.Serial)
if err != nil || !ok {
t.Fatalf("Get: %v, найден=%v", err, ok)
}
if got.Type != want.Type || got.Digits != want.Digits || got.Counter != want.Counter ||
got.Label != want.Label || got.Alg != want.Alg {
t.Errorf("получено %+v, ожидалось %+v", got, want)
}
if hex.EncodeToString(got.Secret) != hex.EncodeToString(want.Secret) {
t.Errorf("seed не совпал: %X", got.Secret)
}
if !got.Created.Equal(want.Created) {
t.Errorf("created = %v, ожидалось %v", got.Created, want.Created)
}
if _, ok, _ := s2.Get("НЕТ-ТАКОГО"); ok {
t.Error("несуществующий токен не должен находиться")
}
}
func TestTOTPFieldsRoundTrip(t *testing.T) {
s, path := openTemp(t)
want := Token{
Serial: "TOTP01", Type: TypeTOTP, Secret: testSeed(t), Digits: 8,
Alg: otp.SHA256, Period: 60 * time.Second, LastStep: 58907525,
}
if err := s.Put(want); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
s.Close()
s2, _ := Open(path, nil)
defer s2.Close()
got, _, err := s2.Get("TOTP01")
if err != nil {
t.Fatal(err)
}
if got.Period != 60*time.Second || got.LastStep != want.LastStep ||
got.Digits != 8 || got.Alg != otp.SHA256 {
t.Errorf("получено %+v", got)
}
// Для TOTP counter не пишется, для HOTP не пишутся period/last_step.
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "counter") {
t.Errorf("в TOTP-секции не должно быть counter:\n%s", data)
}
}
func TestSwitchTypeClearsForeignKeys(t *testing.T) {
s, path := openTemp(t)
tok := sampleToken(t)
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
tok.Type = TypeTOTP
tok.Period = 30 * time.Second
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "counter") {
t.Errorf("counter должен исчезнуть при смене типа на totp:\n%s", data)
}
}
func TestListAndRemove(t *testing.T) {
s, _ := openTemp(t)
a := sampleToken(t)
b := sampleToken(t)
b.Serial = "0004000ABCDEF2"
if err := s.Put(a); err != nil {
t.Fatal(err)
}
if err := s.Put(b); err != nil {
t.Fatal(err)
}
list, err := s.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].Serial != a.Serial || list[1].Serial != b.Serial {
t.Errorf("List вернул %+v", list)
}
if !s.Remove(a.Serial) || s.Remove(a.Serial) {
t.Error("Remove работает неверно")
}
if got := s.Serials(); len(got) != 1 || got[0] != b.Serial {
t.Errorf("после удаления Serials() = %v", got)
}
}
func TestFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("на Windows права доступа задаются иначе")
}
s, path := openTemp(t)
if err := s.Put(sampleToken(t)); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("права на хранилище = %04o, ожидалось 0600", perm)
}
}
func TestValidation(t *testing.T) {
s, _ := openTemp(t)
bad := []struct {
name string
alter func(*Token)
}{
{"пустой серийник", func(t *Token) { t.Serial = "" }},
{"пробел в серийнике", func(t *Token) { t.Serial = "AB CD" }},
{"скобка в серийнике", func(t *Token) { t.Serial = "A]B" }},
{"неизвестный тип", func(t *Token) { t.Type = "ocra" }},
{"пустой seed", func(t *Token) { t.Secret = nil }},
{"разрядность 4", func(t *Token) { t.Digits = 4 }},
{"перевод строки в метке", func(t *Token) { t.Label = "две\nстроки" }},
}
for _, tc := range bad {
tok := sampleToken(t)
tc.alter(&tok)
if err := s.Put(tok); err == nil {
t.Errorf("%s: ожидалась ошибка", tc.name)
}
}
}
func TestOpenBrokenStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.ini")
os.WriteFile(path, []byte("это не ini\n"), 0o600)
if _, err := Open(path, nil); err == nil {
t.Error("битый файл должен давать ошибку")
}
// Замок при неудачном открытии не должен оставаться.
if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
t.Error("файл блокировки остался после неудачного Open")
}
}
func TestBrokenTokenSection(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.ini")
os.WriteFile(path, []byte("[AAA]\nseed = не-hex-и-не-base32!\n"), 0o600)
s, err := Open(path, nil)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, _, err := s.Get("AAA"); err == nil {
t.Error("некорректный seed должен давать ошибку при чтении токена")
}
}
func TestLockIsExclusive(t *testing.T) {
s, path := openTemp(t)
_ = s
start := time.Now()
if _, err := Open(path, nil); err == nil {
t.Fatal("второе открытие должно завершиться ошибкой")
}
if d := time.Since(start); d < lockTimeout {
t.Errorf("ожидание длилось %s, ожидалось не меньше %s", d, lockTimeout)
}
// После Close хранилище снова открывается.
s.Close()
s2, err := Open(path, nil)
if err != nil {
t.Fatalf("после Close открыть не удалось: %v", err)
}
s2.Close()
}
func TestStaleLockIsBroken(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tokens.ini")
lockPath := path + ".lock"
os.WriteFile(lockPath, []byte("pid 1\n"), 0o600)
old := time.Now().Add(-2 * lockStaleAfter)
os.Chtimes(lockPath, old, old)
var warned string
s, err := Open(path, func(m string) { warned = m })
if err != nil {
t.Fatalf("брошенный замок должен сниматься: %v", err)
}
defer s.Close()
if !strings.Contains(warned, "брошенный") {
t.Errorf("предупреждение не выдано: %q", warned)
}
}
func TestNewSeedAndSerial(t *testing.T) {
a, err := NewSeed(0)
if err != nil || len(a) != SeedBytes {
t.Fatalf("NewSeed: %v, %d байт", err, len(a))
}
b, _ := NewSeed(0)
if hex.EncodeToString(a) == hex.EncodeToString(b) {
t.Error("два вызова NewSeed дали одинаковый результат")
}
serial, err := NewSerial(DefaultSerialPrefix)
if err != nil {
t.Fatal(err)
}
if len(serial) != SerialLength || !strings.HasPrefix(serial, DefaultSerialPrefix) {
t.Errorf("NewSerial = %q", serial)
}
if err := ValidateSerial(serial); err != nil {
t.Errorf("сгенерированный серийник не проходит проверку: %v", err)
}
if _, err := NewSerial("плохой префикс"); err == nil {
t.Error("некорректный префикс должен отвергаться")
}
}
func TestNewUniqueSerial(t *testing.T) {
s, _ := openTemp(t)
seen := map[string]bool{}
for i := 0; i < 20; i++ {
serial, err := s.NewUniqueSerial(DefaultSerialPrefix)
if err != nil {
t.Fatal(err)
}
if seen[serial] {
t.Fatalf("серийник %s выдан дважды", serial)
}
seen[serial] = true
tok := sampleToken(t)
tok.Serial = serial
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
}
}
func TestDefaultPathHonoursEnv(t *testing.T) {
t.Setenv(EnvStore, "/tmp/особый/путь.ini")
if got := DefaultPath(); got != "/tmp/особый/путь.ini" {
t.Errorf("DefaultPath = %q", got)
}
t.Setenv(EnvStore, "")
if got := DefaultPath(); filepath.Base(got) != FileName {
t.Errorf("DefaultPath = %q, ожидалось имя %s", got, FileName)
}
}