Files
hotp_calc/internal/store/store.go
T

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)
}