Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
// Package otp реализует алгоритмы одноразовых паролей HOTP (RFC 4226)
// и TOTP (RFC 6238) в том виде, в каком их использует SafeNet eToken PASS.
package otp
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/binary"
"fmt"
"hash"
"strings"
"time"
)
// Algorithm — хеш, на котором строится HMAC. eToken PASS использует SHA1.
type Algorithm int
const (
SHA1 Algorithm = iota
SHA256
SHA512
)
func (a Algorithm) String() string {
switch a {
case SHA256:
return "SHA256"
case SHA512:
return "SHA512"
default:
return "SHA1"
}
}
func (a Algorithm) new() func() hash.Hash {
switch a {
case SHA256:
return sha256.New
case SHA512:
return sha512.New
default:
return sha1.New
}
}
// ParseAlgorithm разбирает название алгоритма без учёта регистра.
func ParseAlgorithm(s string) (Algorithm, error) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SHA1", "SHA-1", "":
return SHA1, nil
case "SHA256", "SHA-256":
return SHA256, nil
case "SHA512", "SHA-512":
return SHA512, nil
}
return SHA1, fmt.Errorf("неизвестный алгоритм %q (допустимо: sha1, sha256, sha512)", s)
}
// DefaultStep — шаг времени TOTP по умолчанию (RFC 6238).
const DefaultStep = 30 * time.Second
// Config описывает параметры конкретного токена.
type Config struct {
Secret []byte // вектор инициализации (seed)
Digits int // разрядность кода: 6 или 8
Alg Algorithm // хеш для HMAC
Step time.Duration // шаг времени, только для TOTP
}
// Validate проверяет параметры на осмысленность.
func (c Config) Validate() error {
if len(c.Secret) == 0 {
return fmt.Errorf("пустой seed")
}
if c.Digits < 6 || c.Digits > 10 {
return fmt.Errorf("разрядность %d вне допустимого диапазона 6..10", c.Digits)
}
if c.Step <= 0 {
return fmt.Errorf("шаг времени должен быть положительным")
}
return nil
}
var pow10 = [...]uint32{1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000}
// HOTP считает одноразовый пароль для указанного значения счётчика (RFC 4226).
func (c Config) HOTP(counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(c.Alg.new(), c.Secret)
mac.Write(buf[:])
sum := mac.Sum(nil)
// Динамическое усечение: младшие 4 бита последнего байта задают смещение.
offset := sum[len(sum)-1] & 0x0F
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
digits := c.Digits
if digits < 1 || digits >= len(pow10) {
digits = 6
}
return fmt.Sprintf("%0*d", digits, code%pow10[digits])
}
// TimeStep возвращает номер временного шага T для момента t (RFC 6238).
func (c Config) TimeStep(t time.Time) uint64 {
step := c.Step
if step <= 0 {
step = DefaultStep
}
return uint64(t.Unix() / int64(step/time.Second))
}
// StepStart возвращает момент начала временного шага с номером step.
func (c Config) StepStart(step uint64) time.Time {
d := c.Step
if d <= 0 {
d = DefaultStep
}
return time.Unix(int64(step)*int64(d/time.Second), 0)
}
// TOTP считает одноразовый пароль, действующий в момент t.
func (c Config) TOTP(t time.Time) string {
return c.HOTP(c.TimeStep(t))
}
// equalCode сравнивает коды за постоянное время.
func equalCode(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}