351 lines
10 KiB
Go
351 lines
10 KiB
Go
// 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)
|
|
}
|