211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
// 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
|
|
}
|