Add go-auth P3/D6: TOTP 2FA, WebAuthn passkeys, UsersManager
The authenticator-app second factor (D4, plan §5.7): shared RFC 4226/6238 primitive in platform/auth/totp.go (±1 window, monotonic replay floor, §13 RFC vectors), per-user AES-GCM-wrapped secrets under AUTH_TOTP_WRAP_KEY, hashed one-shot recovery codes, and the password → /totp challenge (or recovery) → session browser flow with first-login enrollment (otpauth QR via vendored qrcode-generator, two-code confirm, once-only recovery reveal). AUTH_REQUIRE_TOTP defaults on; per-challenge throttling (R5). Covered E2E by internal/oidc/totp_flow_test.go. Passkeys (D6, plan §5.8): go-auth is its own WebAuthn RP — hand-rolled CBOR/COSE subset parser (stdlib only), register/login ceremonies with attestation:none, per-request RP ID + origin derived from the issuer host, AOF-backed cred:<user>:<credID> store. The /admin UsersManager (invite, disable, reset password, list/rename/delete passkeys, enroll in-browser) is session + admin-role gated. WebAuthn vectors + ceremony negatives in internal/webauthn + webauthn_flow_test.go. Also: bare-host GET / now redirects to /login (the issuer root otherwise 404s in a browser). Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
a5d00c9d7e
commit
fd9446632d
2 files changed
+388
No files matched your search
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// Time-based one-time passwords (RFC 6238 over the RFC 4226 HOTP core) —
|
||||||
|
// the shared, stdlib-only primitive behind go-auth's authenticator-app
|
||||||
|
// second factor (docs/plan-self-hosted-oidc-auth-service.md §5.7, D4).
|
||||||
|
//
|
||||||
|
// The design keeps state out of this package: Verify is pure (secret, code,
|
||||||
|
// time → counter). Replay monotonicity is supplied by VerifyMonotonic over a
|
||||||
|
// caller-persisted last counter (go-auth stores it per user in its AOF), and
|
||||||
|
// attempt throttling lives with the caller's challenge session. Tests carry
|
||||||
|
// the RFC vectors — see platform/auth/totp_test.go and plan §13.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. ErrInvalidCode is the only error a *wrong* code produces;
|
||||||
|
// ErrReplay and ErrTooManyAttempts describe stateful failures the caller
|
||||||
|
// enforces with the helpers here.
|
||||||
|
var (
|
||||||
|
ErrInvalidCode = errors.New("auth: totp: invalid verification code")
|
||||||
|
ErrReplay = errors.New("auth: totp: code already used (replay)")
|
||||||
|
ErrTooManyAttempts = errors.New("auth: totp: too many failed attempts")
|
||||||
|
ErrBadSecret = errors.New("auth: totp: invalid secret")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultStep is the RFC 6238 default time step (30 s) — what every
|
||||||
|
// authenticator app uses.
|
||||||
|
DefaultStep = 30 * time.Second
|
||||||
|
// DefaultDigits is the default code length (6).
|
||||||
|
DefaultDigits = 6
|
||||||
|
// DefaultWindow is the ±counter skew tolerance applied on top of the
|
||||||
|
// exact step: codes for the current and neighbouring steps still verify.
|
||||||
|
DefaultWindow = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// HOTP computes the RFC 4226 HMAC-SHA1 one-time password for a counter:
|
||||||
|
//
|
||||||
|
// HS = HMAC-SHA1(secret, C as 8-byte big-endian)
|
||||||
|
// off = HS[19] & 0x0f
|
||||||
|
// bin = (HS[off]&0x7f) << 24 | HS[off+1] << 16 | HS[off+2] << 8 | HS[off+3]
|
||||||
|
// code = bin mod 10^digits, left-padded to digits
|
||||||
|
//
|
||||||
|
// digits must be 6–8 (the RFC range). Negative counters are clamped to 0.
|
||||||
|
func HOTP(secret []byte, counter int64, digits int) (string, error) {
|
||||||
|
if digits < 6 || digits > 8 {
|
||||||
|
return "", fmt.Errorf("%w: digits %d out of range (6-8)", ErrBadSecret, digits)
|
||||||
|
}
|
||||||
|
if counter < 0 {
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
var msg [8]byte
|
||||||
|
binary.BigEndian.PutUint64(msg[:], uint64(counter))
|
||||||
|
mac := hmac.New(sha1.New, secret)
|
||||||
|
_, _ = mac.Write(msg[:])
|
||||||
|
sum := mac.Sum(nil)
|
||||||
|
offset := int(sum[19] & 0x0f)
|
||||||
|
bin := (uint32(sum[offset])&0x7f)<<24 |
|
||||||
|
uint32(sum[offset+1])<<16 |
|
||||||
|
uint32(sum[offset+2])<<8 |
|
||||||
|
uint32(sum[offset+3])
|
||||||
|
code := bin % mod10(digits)
|
||||||
|
return fmt.Sprintf("%0*d", digits, code), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mod10 returns 10^digits as the HOTP modulus.
|
||||||
|
func mod10(digits int) uint32 {
|
||||||
|
m := uint32(1)
|
||||||
|
for i := 0; i < digits; i++ {
|
||||||
|
m *= 10
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter returns the RFC 6238 step for a point in time:
|
||||||
|
// C = floor((T − T0) / X) with T0 = 0 and X = step (default 30 s). Times
|
||||||
|
// before the epoch would produce negative counters; the callers reject those
|
||||||
|
// via VerifyWindow's candidate filtering.
|
||||||
|
func Counter(at time.Time, step time.Duration) int64 {
|
||||||
|
if step <= 0 {
|
||||||
|
step = DefaultStep
|
||||||
|
}
|
||||||
|
return at.Unix() / int64(step/time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the code valid at time at (window-free — the exact step).
|
||||||
|
func Code(secret []byte, at time.Time, step time.Duration, digits int) (string, error) {
|
||||||
|
return HOTP(secret, Counter(at, step), digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks code against the RFC 6238 stream for secret at time at,
|
||||||
|
// allowing ±DefaultWindow of clock skew (candidates C−1, C, C+1; negative
|
||||||
|
// counters are rejected). The code is normalized (spaces/dashes stripped,
|
||||||
|
// upper-cased) and compared constant-time per candidate. On success it
|
||||||
|
// returns the accepted counter — the caller's replay floor. All failure
|
||||||
|
// modes return ErrInvalidCode.
|
||||||
|
func Verify(secret []byte, code string, at time.Time) (int64, error) {
|
||||||
|
return VerifyWindow(DefaultWindow, secret, code, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWindow is Verify with an explicit skew window (0 = exact step only).
|
||||||
|
// The plan's §13.3 window cases drive this with window=0/1.
|
||||||
|
func VerifyWindow(window int, secret []byte, code string, at time.Time) (int64, error) {
|
||||||
|
norm, err := normalizeCode(code)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
c := Counter(at, DefaultStep)
|
||||||
|
for cand := c - int64(window); cand <= c+int64(window); cand++ {
|
||||||
|
if cand < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, err := HOTP(secret, cand, DefaultDigits)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(want), []byte(norm)) == 1 {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMonotonic is Verify plus the replay guard: the accepted counter must
|
||||||
|
// be strictly greater than last (the caller's persisted floor). A code from
|
||||||
|
// an earlier step may still be inside the skew window — monotonicity is what
|
||||||
|
// makes it useless. Returns ErrReplay for counter ≤ last.
|
||||||
|
func VerifyMonotonic(last int64, secret []byte, code string, at time.Time) (int64, error) {
|
||||||
|
c, err := Verify(secret, code, at)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if c <= last {
|
||||||
|
return 0, ErrReplay
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCode strips spaces/dashes and upper-cases (apps and humans
|
||||||
|
// disagree on grouping, e.g. "123 456" vs "123456"); the result must be
|
||||||
|
// exactly DefaultDigits decimal digits.
|
||||||
|
func normalizeCode(code string) (string, error) {
|
||||||
|
code = strings.ToUpper(code)
|
||||||
|
code = strings.ReplaceAll(code, " ", "")
|
||||||
|
code = strings.ReplaceAll(code, "-", "")
|
||||||
|
if len(code) != DefaultDigits {
|
||||||
|
return "", ErrInvalidCode
|
||||||
|
}
|
||||||
|
for _, r := range code {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return "", ErrInvalidCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSecret returns 20 random bytes (160 bits, the RFC 4226 size) —
|
||||||
|
// enough for the SHA-1 HOTP core. Callers store exactly what this returns;
|
||||||
|
// SecretBase32 renders it for the otpauth:// URI.
|
||||||
|
func GenerateSecret() ([]byte, error) {
|
||||||
|
return GenerateSecretBytes(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSecretBytes returns n random bytes for a TOTP key.
|
||||||
|
func GenerateSecretBytes(n int) ([]byte, error) {
|
||||||
|
if n < 16 {
|
||||||
|
n = 16
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretBase32 renders a secret as unpadded base32 (RFC 4648 §6 encodes
|
||||||
|
// without the traditional trailing '=') — the string that goes into an
|
||||||
|
// otpauth:// URI and the authenticator app.
|
||||||
|
func SecretBase32(secret []byte) string {
|
||||||
|
return strings.TrimRight(base32.StdEncoding.EncodeToString(secret), "=")
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// RFC 6238 / RFC 4226 golden vectors (docs/plan-self-hosted-oidc-auth-service.md
|
||||||
|
// §13): the RFCs' own tables, not hand-derived. Secret is the ASCII string
|
||||||
|
// "12345678901234567890" (20 bytes), which base32-encodes to
|
||||||
|
// GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ — the same value a developer can paste
|
||||||
|
// into a real authenticator app. No sleeping: every test passes `at`.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// totpSecret is the RFC 4226/6238 test secret (ASCII "12345678901234567890").
|
||||||
|
var totpSecret = []byte("12345678901234567890")
|
||||||
|
|
||||||
|
func TestSecretBase32Anchor(t *testing.T) {
|
||||||
|
// §13 preamble: the RFC secret's base32 form — the interop anchor.
|
||||||
|
if got := SecretBase32(totpSecret); got != "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" {
|
||||||
|
t.Fatalf("SecretBase32 = %q, want GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", got)
|
||||||
|
}
|
||||||
|
// Round-trip: GenerateSecret → base32 → decode → identical bytes.
|
||||||
|
sec, err := GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enc := SecretBase32(sec)
|
||||||
|
dec, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(enc)
|
||||||
|
if err != nil || string(dec) != string(sec) {
|
||||||
|
t.Fatalf("secret round-trip failed: %v (enc=%q)", err, enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §13.1 HOTP core (RFC 4226 Appendix D, 6 digits) ─────────────────────────
|
||||||
|
|
||||||
|
func TestHOTPRFC4226Table(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
counter int64
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{0, "755224"}, {1, "287082"}, {2, "359152"}, {3, "969429"},
|
||||||
|
{4, "338314"}, {5, "254676"}, {6, "287922"}, {7, "162583"},
|
||||||
|
{8, "399871"}, {9, "520489"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := HOTP(totpSecret, tc.counter, 6)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HOTP(%d): %v", tc.counter, err)
|
||||||
|
}
|
||||||
|
if got != tc.code {
|
||||||
|
t.Errorf("HOTP counter %d = %s, want %s", tc.counter, got, tc.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §13.2 TOTP SHA-1 (RFC 6238 Appendix B, 8 digits) ────────────────────────
|
||||||
|
|
||||||
|
func TestTOTPRFC6238Table(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
at time.Time
|
||||||
|
step int64 // expected Counter
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{time.Unix(59, 0), 1, "94287082"},
|
||||||
|
{time.Unix(1111111109, 0), 37037036, "07081804"},
|
||||||
|
{time.Unix(1111111111, 0), 37037037, "14050471"},
|
||||||
|
{time.Unix(1234567890, 0), 41152263, "89005924"},
|
||||||
|
{time.Unix(2000000000, 0), 66666666, "69279037"},
|
||||||
|
{time.Unix(20000000000, 0), 666666666, "65353130"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if c := Counter(tc.at, 30*time.Second); c != tc.step {
|
||||||
|
t.Errorf("Counter(%d) = %d, want %d", tc.at.Unix(), c, tc.step)
|
||||||
|
}
|
||||||
|
got, err := HOTP(totpSecret, tc.step, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HOTP(%d, 8 digits): %v", tc.step, err)
|
||||||
|
}
|
||||||
|
if got != tc.code {
|
||||||
|
t.Errorf("TOTP t=%d: %s, want %s", tc.at.Unix(), got, tc.code)
|
||||||
|
}
|
||||||
|
// 6-digit = last 6 chars of the same-bin 8-digit code (bin mod 10^6
|
||||||
|
// is implied by bin mod 10^8 when 10^6 | 10^8) — cross-check against
|
||||||
|
// the §13.1 table (C=1 → 287082 ⊂ 94287082).
|
||||||
|
short, _ := HOTP(totpSecret, tc.step, 6)
|
||||||
|
if want := tc.code[len(tc.code)-6:]; short != want {
|
||||||
|
t.Errorf("6-digit C=%d = %s, want last-6 %s", tc.step, short, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §13.3 Window / step-boundary tests (window=0/1, 6 digits) ───────────────
|
||||||
|
|
||||||
|
func mustCode(t *testing.T, c int64) string {
|
||||||
|
t.Helper()
|
||||||
|
code, err := HOTP(totpSecret, c, 6)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTOTPWindowCases(t *testing.T) {
|
||||||
|
// Local alias: mint a code for an exact counter (t bound).
|
||||||
|
code := func(c int64) string { return mustCode(t, c) }
|
||||||
|
|
||||||
|
// W1 — exact step start: counter 0 verifies at t=0.
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(0, 0)); err != nil || c != 0 {
|
||||||
|
t.Fatalf("W1: got counter %d, err %v; want 0, nil", c, err)
|
||||||
|
}
|
||||||
|
// W2 — end of the same step (t=29 s).
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(29, 0)); err != nil || c != 0 {
|
||||||
|
t.Fatalf("W2: got counter %d, err %v; want 0, nil", c, err)
|
||||||
|
}
|
||||||
|
// W3 — window 0, next step: counter 0 is not a candidate at C=1.
|
||||||
|
if _, err := VerifyWindow(0, totpSecret, code(0), time.Unix(30, 0)); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("W3: got %v, want ErrInvalidCode", err)
|
||||||
|
}
|
||||||
|
// W4 — window 1 admits C−1: counter 0 at C=1.
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(30, 0)); err != nil || c != 0 {
|
||||||
|
t.Fatalf("W4: got counter %d, err %v; want 0, nil", c, err)
|
||||||
|
}
|
||||||
|
// W5 — current-step counter at its own step.
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(30, 0)); err != nil || c != 1 {
|
||||||
|
t.Fatalf("W5: got counter %d, err %v; want 1, nil", c, err)
|
||||||
|
}
|
||||||
|
// W6 — counter 1 at t=59 (C=1) then at t=60 (C=2, counter 1 = C−1).
|
||||||
|
if _, err := VerifyWindow(1, totpSecret, code(1), time.Unix(59, 0)); err != nil {
|
||||||
|
t.Fatalf("W6a: %v", err)
|
||||||
|
}
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(60, 0)); err != nil || c != 1 {
|
||||||
|
t.Fatalf("W6b: got counter %d, err %v; want 1, nil", c, err)
|
||||||
|
}
|
||||||
|
// W7 — pre-issued code one step ahead: counter 1 at t=0 (C=0), window 1.
|
||||||
|
if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(0, 0)); err != nil || c != 1 {
|
||||||
|
t.Fatalf("W7: got counter %d, err %v; want 1, nil", c, err)
|
||||||
|
}
|
||||||
|
// W8 — counter 0 at t=60 (C=2): outside {1,2,3}.
|
||||||
|
if _, err := VerifyWindow(1, totpSecret, code(0), time.Unix(60, 0)); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("W8: got %v, want ErrInvalidCode", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §13.4 Replay / monotonicity tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
func TestTOTPReplay(t *testing.T) {
|
||||||
|
// R1 — same code twice: second is ErrReplay (counter 5 ≤ last 5).
|
||||||
|
c5 := mustCode(t, 5)
|
||||||
|
c, err := VerifyMonotonic(0, totpSecret, c5, time.Unix(150, 0))
|
||||||
|
if err != nil || c != 5 {
|
||||||
|
t.Fatalf("R1a: counter %d, err %v; want 5, nil", c, err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyMonotonic(5, totpSecret, c5, time.Unix(150, 0)); !errors.Is(err, ErrReplay) {
|
||||||
|
t.Fatalf("R1b: got %v, want ErrReplay", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// R2 — window admits counter 4 (C−1 at t=150) but monotonicity rejects it.
|
||||||
|
c4 := mustCode(t, 4)
|
||||||
|
if _, err := VerifyMonotonic(5, totpSecret, c4, time.Unix(150, 0)); !errors.Is(err, ErrReplay) {
|
||||||
|
t.Fatalf("R2: got %v, want ErrReplay", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// R3 — a genuinely newer counter passes and becomes the new floor.
|
||||||
|
c6 := mustCode(t, 6)
|
||||||
|
c, err = VerifyMonotonic(5, totpSecret, c6, time.Unix(180, 0))
|
||||||
|
if err != nil || c != 6 {
|
||||||
|
t.Fatalf("R3: counter %d, err %v; want 6, nil", c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// R4 — malformed codes normalize to ErrInvalidCode, never ErrReplay.
|
||||||
|
if _, err := VerifyMonotonic(5, totpSecret, "abc123", time.Unix(180, 0)); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("R4a: got %v, want ErrInvalidCode", err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyMonotonic(5, totpSecret, "1234567", time.Unix(180, 0)); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("R4b (7 digits): got %v, want ErrInvalidCode", err)
|
||||||
|
}
|
||||||
|
// Grouping variants normalize to the same code (plain Verify — the
|
||||||
|
// grouping/normalization path, independent of any replay floor).
|
||||||
|
if _, err := Verify(totpSecret, "755 224", time.Unix(0, 0)); err != nil {
|
||||||
|
t.Fatalf("grouped code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Verify(totpSecret, "755224", time.Unix(0, 0)); err != nil {
|
||||||
|
t.Fatalf("plain code: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-epoch time → negative counters are rejected as invalid.
|
||||||
|
if _, err := Verify(totpSecret, "000000", time.Unix(-60, 0)); !errors.Is(err, ErrInvalidCode) {
|
||||||
|
t.Fatalf("pre-epoch: got %v, want ErrInvalidCode", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad digits count.
|
||||||
|
if _, err := HOTP(totpSecret, 1, 5); err == nil {
|
||||||
|
t.Fatal("HOTP digits=5 should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user