Initial commit: hotpcalc CLI
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package initfile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hotpcalc/internal/otp"
|
||||
"hotpcalc/internal/store"
|
||||
)
|
||||
|
||||
const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D"
|
||||
|
||||
func testToken(t *testing.T) store.Token {
|
||||
t.Helper()
|
||||
secret, err := hex.DecodeString(testSeedHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Token{
|
||||
Serial: "0004000ABCDEF1", Type: store.TypeHOTP, Secret: secret,
|
||||
Digits: 6, Alg: otp.SHA1, Counter: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Эталон формата: структура сверена с parseSafeNetXML из privacyIDEA.
|
||||
const golden = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Tokens>
|
||||
<Token serial="0004000ABCDEF1">
|
||||
<CaseModel>5</CaseModel>
|
||||
<Model>101</Model>
|
||||
<ProductionDate>09/04/2026</ProductionDate>
|
||||
<ProductName>eToken PASS</ProductName>
|
||||
<Applications>
|
||||
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
|
||||
<Seed>765D7268D012090C28172A729BC9CC1AD82B382D</Seed>
|
||||
<MovingFactor>1</MovingFactor>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Token>
|
||||
</Tokens>
|
||||
`
|
||||
|
||||
func TestWriteSafeNetMatchesGolden(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
opts := Options{ProductionDate: time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)}
|
||||
if err := WriteSafeNet(&buf, []store.Token{testToken(t)}, opts); err != nil {
|
||||
t.Fatalf("WriteSafeNet: %v", err)
|
||||
}
|
||||
if buf.String() != golden {
|
||||
t.Errorf("вывод не совпал с эталоном:\n--- получено ---\n%s\n--- ожидалось ---\n%s",
|
||||
buf.String(), golden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
want := []store.Token{testToken(t)}
|
||||
want = append(want, store.Token{
|
||||
Serial: "0004000ABCDEF2", Type: store.TypeHOTP, Secret: want[0].Secret,
|
||||
Digits: 6, Alg: otp.SHA1, Counter: 123456,
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := WriteSafeNet(&buf, want, Options{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadSafeNet(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSafeNet: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("прочитано %d токенов, ожидалось %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].Serial != want[i].Serial || got[i].Counter != want[i].Counter ||
|
||||
!bytes.Equal(got[i].Secret, want[i].Secret) || got[i].Alg != want[i].Alg ||
|
||||
got[i].Digits != want[i].Digits || got[i].Type != want[i].Type {
|
||||
t.Errorf("токен %d: получено %+v, ожидалось %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSafeNetSample(t *testing.T) {
|
||||
// Образец в том виде, в каком формат описан у privacyIDEA/LinOTP.
|
||||
const sample = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<Tokens>
|
||||
<Token serial="000400081DFFA5">
|
||||
<CaseModel>5</CaseModel>
|
||||
<Model>101</Model>
|
||||
<ProductionDate>02/19/2009</ProductionDate>
|
||||
<ProductName>Safeword Alpine</ProductName>
|
||||
<Applications>
|
||||
<Application ConnectorID="{ab1397d2-ddb6-4705-b66e-9f83f322deb9}">
|
||||
<Seed>3132333435363738393031323334353637383930</Seed>
|
||||
<MovingFactor>1</MovingFactor>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Token>
|
||||
</Tokens>`
|
||||
got, err := ReadSafeNet(strings.NewReader(sample))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSafeNet: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("прочитано %d токенов", len(got))
|
||||
}
|
||||
tok := got[0]
|
||||
if tok.Serial != "000400081DFFA5" || tok.Counter != 1 || tok.Label != "Safeword Alpine" {
|
||||
t.Errorf("получено %+v", tok)
|
||||
}
|
||||
// Seed из RFC 4226: счётчик 0 даёт 755224.
|
||||
if code := tok.Config().HOTP(0); code != "755224" {
|
||||
t.Errorf("HOTP(0) = %s, ожидалось 755224 — seed прочитан неверно", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSafeNetAlgorithmFromSeedLength(t *testing.T) {
|
||||
long := strings.Repeat("AB", 32) // 64 hex-символа
|
||||
x := `<Tokens><Token serial="X"><Applications><Application>` +
|
||||
`<Seed>` + long + `</Seed><MovingFactor>0</MovingFactor>` +
|
||||
`</Application></Applications></Token></Tokens>`
|
||||
got, err := ReadSafeNet(strings.NewReader(x))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[0].Alg != otp.SHA256 {
|
||||
t.Errorf("для 64-символьного seed ожидался SHA256, получено %s", got[0].Alg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSafeNetErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"не XML": "просто текст",
|
||||
"чужой корень": `<KeyContainer/>`,
|
||||
"нет токенов": `<Tokens></Tokens>`,
|
||||
"нет serial": `<Tokens><Token><Applications><Application><Seed>AA</Seed></Application></Applications></Token></Tokens>`,
|
||||
"нет Application": `<Tokens><Token serial="X"><Applications></Applications></Token></Tokens>`,
|
||||
"нет Seed": `<Tokens><Token serial="X"><Applications><Application><MovingFactor>0</MovingFactor></Application></Applications></Token></Tokens>`,
|
||||
"Seed не hex": `<Tokens><Token serial="X"><Applications><Application><Seed>ZZZZ</Seed></Application></Applications></Token></Tokens>`,
|
||||
"MovingFactor не чис": `<Tokens><Token serial="X"><Applications><Application><Seed>` + testSeedHex + `</Seed><MovingFactor>abc</MovingFactor></Application></Applications></Token></Tokens>`,
|
||||
}
|
||||
for name, in := range cases {
|
||||
if _, err := ReadSafeNet(strings.NewReader(in)); err == nil {
|
||||
t.Errorf("%s: ожидалась ошибка", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSafeNetRejectsEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteSafeNet(&buf, nil, Options{}); err == nil {
|
||||
t.Error("пустой список должен отвергаться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnings(t *testing.T) {
|
||||
ok := testToken(t)
|
||||
if w := Warnings(ok); len(w) != 0 {
|
||||
t.Errorf("для обычного HOTP-токена предупреждений быть не должно: %v", w)
|
||||
}
|
||||
|
||||
totp := ok
|
||||
totp.Type = store.TypeTOTP
|
||||
if w := Warnings(totp); len(w) != 1 || !strings.Contains(w[0], "только HOTP") {
|
||||
t.Errorf("TOTP: %v", w)
|
||||
}
|
||||
|
||||
eight := ok
|
||||
eight.Digits = 8
|
||||
if w := Warnings(eight); len(w) != 1 || !strings.Contains(w[0], "разрядность") {
|
||||
t.Errorf("8 разрядов: %v", w)
|
||||
}
|
||||
|
||||
// SHA256 при 20-байтном seed сервер прочитает как SHA1.
|
||||
sha := ok
|
||||
sha.Alg = otp.SHA256
|
||||
if w := Warnings(sha); len(w) != 1 || !strings.Contains(w[0], "алгоритм") {
|
||||
t.Errorf("SHA256 с коротким seed: %v", w)
|
||||
}
|
||||
|
||||
// SHA256 при 32-байтном seed передаётся корректно.
|
||||
good := ok
|
||||
good.Alg = otp.SHA256
|
||||
good.Secret = bytes.Repeat([]byte{1}, 32)
|
||||
if w := Warnings(good); len(w) != 0 {
|
||||
t.Errorf("SHA256 с 32-байтным seed не должен давать предупреждений: %v", w)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user