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