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