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