From 7ac04eeac003df020db1c3c3071d25d3e35f44a0 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 9 Sep 2025 02:58:56 +0300 Subject: [PATCH] one time password --- encoding/otp/options.go | 62 ++++++++++++++++ encoding/otp/totp.go | 150 ++++++++++++++++++++++++++++++++++++++ encoding/otp/totp_test.go | 77 +++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 encoding/otp/options.go create mode 100644 encoding/otp/totp.go create mode 100644 encoding/otp/totp_test.go diff --git a/encoding/otp/options.go b/encoding/otp/options.go new file mode 100644 index 0000000..bbd7529 --- /dev/null +++ b/encoding/otp/options.go @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019-2025 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package otp + +import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "fmt" +) + +type Option func(o *OTP) + +func OptHashSHA1() Option { + return func(o *OTP) { + o.hash = sha1.New + o.algorithm = "SHA1" + } +} + +func OptHashSHA256() Option { + return func(o *OTP) { + o.hash = sha256.New + o.algorithm = "SHA256" + } +} + +func OptHashSHA512() Option { + return func(o *OTP) { + o.hash = sha512.New + o.algorithm = "SHA512" + } +} + +func OptPeriod(v int64) Option { + return func(o *OTP) { + if v < 30 { + v = 30 + } + if v > 120 { + v = 120 + } + o.period = v + } +} + +func OptCode6Digits() Option { + return func(o *OTP) { + o.codeSize = 6 + o.codeTmpl = fmt.Sprintf("%%0%dd", o.codeSize) + } +} + +func OptCode8Digits() Option { + return func(o *OTP) { + o.codeSize = 8 + o.codeTmpl = fmt.Sprintf("%%0%dd", o.codeSize) + } +} diff --git a/encoding/otp/totp.go b/encoding/otp/totp.go new file mode 100644 index 0000000..c7d99eb --- /dev/null +++ b/encoding/otp/totp.go @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2019-2025 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package otp + +import ( + "crypto/hmac" + "crypto/rand" + "encoding/base32" + "encoding/binary" + "fmt" + "hash" + "io" + "math" + "net/url" + "strconv" + "strings" + "time" +) + +var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + +type OTP struct { + hash func() hash.Hash + algorithm string + + period int64 + + codeSize int + codeTmpl string +} + +func New(options ...Option) (*OTP, error) { + obj := &OTP{} + + opts := make([]Option, 0, 10) + opts = append(opts, OptHashSHA1(), OptPeriod(30), OptCode6Digits()) + opts = append(opts, options...) + + for _, opt := range opts { + opt(obj) + } + + return obj, nil +} + +func (o *OTP) NewSecret(size int) (string, error) { + if size < 1 { + size = 10 + } + secret := make([]byte, size) + if _, err := io.ReadFull(rand.Reader, secret); err != nil { + return "", err + } + return b32.EncodeToString(secret), nil +} + +func (o *OTP) validateSecret(secret string) ([]byte, error) { + secret = strings.TrimSpace(secret) + if n := len(secret) % 8; n != 0 { + secret = secret + strings.Repeat("=", 8-n) + } + secret = strings.ToUpper(secret) + secretBytes, err := base32.StdEncoding.DecodeString(secret) + if err != nil { + return nil, fmt.Errorf("invalid secret: %w", err) + } + return secretBytes, nil +} + +func (o *OTP) generate(secret string, counter uint64, delta int64) (string, error) { + b, err := o.validateSecret(secret) + if err != nil { + return "", err + } + + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, uint64(int64(counter)+delta)) + + hm := hmac.New(o.hash, b) + if _, err := hm.Write(counterBytes); err != nil { + return strings.Repeat("0", o.codeSize), err + } + timeHash := hm.Sum(nil) + + offset := int(timeHash[len(timeHash)-1] & 0x0F) + truncHash := int64( + (int(timeHash[offset])&0x7f)<<24 | + (int(timeHash[offset+1])&0xff)<<16 | + (int(timeHash[offset+2])&0xff)<<8 | + (int(timeHash[offset+3]) & 0xff)) + + otp := truncHash % int64(math.Pow10(o.codeSize)) + + return fmt.Sprintf(o.codeTmpl, otp), nil +} + +func (o *OTP) GenerateTOTP(secret string, delta int64) (string, error) { + currentTime := time.Now().Unix() + counter := uint64(math.Floor(float64(currentTime) / float64(o.period))) + + return o.generate(secret, counter, delta) +} + +func (o *OTP) GenerateHOTP(secret string, counter uint64) (string, error) { + return o.generate(secret, counter, 0) +} + +func (o *OTP) UrlTOTP(secret, account, issuer string) string { + secret = strings.TrimSpace(secret) + params := url.Values{ + "secret": []string{secret}, + "issuer": []string{issuer}, + "algorithm": []string{o.algorithm}, + "digits": []string{strconv.Itoa(o.codeSize)}, + "period": []string{strconv.Itoa(int(o.period))}, + } + + uri := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + account, + RawQuery: params.Encode(), + } + + return uri.String() +} + +func (o *OTP) UrlHOTP(secret, account, issuer string, counter uint64) string { + secret = strings.TrimSpace(secret) + params := url.Values{ + "secret": []string{secret}, + "issuer": []string{issuer}, + "algorithm": []string{o.algorithm}, + "digits": []string{strconv.Itoa(o.codeSize)}, + "period": []string{strconv.Itoa(int(o.period))}, + "counter": []string{strconv.Itoa(int(counter))}, + } + + uri := url.URL{ + Scheme: "otpauth", + Host: "hotp", + Path: "/" + account, + RawQuery: params.Encode(), + } + + return uri.String() +} diff --git a/encoding/otp/totp_test.go b/encoding/otp/totp_test.go new file mode 100644 index 0000000..66a00bd --- /dev/null +++ b/encoding/otp/totp_test.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-2025 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package otp_test + +import ( + "testing" + + "go.osspkg.com/casecheck" + + "go.osspkg.com/algorithms/encoding/otp" +) + +func TestUnit_TOTP_Generate(t *testing.T) { + otp, err := otp.New() + casecheck.NoError(t, err) + + c1, err := otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0) + casecheck.NoError(t, err) + c2, err := otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0) + casecheck.NoError(t, err) + c3, err := otp.GenerateTOTP(`JBSWY3DPEHPK3PXP`, 0) + casecheck.NoError(t, err) + + casecheck.Equal(t, c1, c2) + casecheck.NotEqual(t, c1, c3) + + link := otp.UrlTOTP(`JBSWY3DPEHPK3PXP`, `user name`, `example.com`) + want := `otpauth://totp/user%20name?algorithm=SHA1&digits=6&issuer=example.com&period=30&secret=JBSWY3DPEHPK3PXP` + casecheck.Equal(t, want, link) +} + +func TestUnit_HOTP_Generate(t *testing.T) { + otp, err := otp.New() + casecheck.NoError(t, err) + + c1, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 0) + casecheck.NoError(t, err) + c2, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 0) + casecheck.NoError(t, err) + c3, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 1) + casecheck.NoError(t, err) + c4, err := otp.GenerateHOTP(`JBSWY3DPEHPK3PXP`, 0) + casecheck.NoError(t, err) + + casecheck.Equal(t, c1, c2) + casecheck.NotEqual(t, c1, c3) + casecheck.NotEqual(t, c1, c4) + + link := otp.UrlHOTP(`JBSWY3DPEHPK3PXP`, `user name`, `example.com`, 0) + want := `otpauth://hotp/user%20name?algorithm=SHA1&counter=0&digits=6&issuer=example.com&period=30&secret=JBSWY3DPEHPK3PXP` + casecheck.Equal(t, want, link) +} + +/* +goos: linux +goarch: amd64 +pkg: go.osspkg.com/algorithms/encoding/totp +cpu: 12th Gen Intel(R) Core(TM) i9-12900KF +Benchmark_TOTP_Generate +Benchmark_TOTP_Generate-4 6530068 180.6 ns/op 512 B/op 10 allocs/op +*/ +func Benchmark_TOTP_Generate(b *testing.B) { + otp, err := otp.New() + casecheck.NoError(b, err) + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0) + } + }) +}