Skip to content

Commit d072c9e

Browse files
authored
one time password (#27)
1 parent c24ec5f commit d072c9e

File tree

3 files changed

+289
-0
lines changed

3 files changed

+289
-0
lines changed

encoding/otp/options.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright (c) 2019-2025 Mikhail Knyazhev <[email protected]>. All rights reserved.
3+
* Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
4+
*/
5+
6+
package otp
7+
8+
import (
9+
"crypto/sha1"
10+
"crypto/sha256"
11+
"crypto/sha512"
12+
"fmt"
13+
)
14+
15+
type Option func(o *OTP)
16+
17+
func OptHashSHA1() Option {
18+
return func(o *OTP) {
19+
o.hash = sha1.New
20+
o.algorithm = "SHA1"
21+
}
22+
}
23+
24+
func OptHashSHA256() Option {
25+
return func(o *OTP) {
26+
o.hash = sha256.New
27+
o.algorithm = "SHA256"
28+
}
29+
}
30+
31+
func OptHashSHA512() Option {
32+
return func(o *OTP) {
33+
o.hash = sha512.New
34+
o.algorithm = "SHA512"
35+
}
36+
}
37+
38+
func OptPeriod(v int64) Option {
39+
return func(o *OTP) {
40+
if v < 30 {
41+
v = 30
42+
}
43+
if v > 120 {
44+
v = 120
45+
}
46+
o.period = v
47+
}
48+
}
49+
50+
func OptCode6Digits() Option {
51+
return func(o *OTP) {
52+
o.codeSize = 6
53+
o.codeTmpl = fmt.Sprintf("%%0%dd", o.codeSize)
54+
}
55+
}
56+
57+
func OptCode8Digits() Option {
58+
return func(o *OTP) {
59+
o.codeSize = 8
60+
o.codeTmpl = fmt.Sprintf("%%0%dd", o.codeSize)
61+
}
62+
}

encoding/otp/totp.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright (c) 2019-2025 Mikhail Knyazhev <[email protected]>. All rights reserved.
3+
* Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
4+
*/
5+
6+
package otp
7+
8+
import (
9+
"crypto/hmac"
10+
"crypto/rand"
11+
"encoding/base32"
12+
"encoding/binary"
13+
"fmt"
14+
"hash"
15+
"io"
16+
"math"
17+
"net/url"
18+
"strconv"
19+
"strings"
20+
"time"
21+
)
22+
23+
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
24+
25+
type OTP struct {
26+
hash func() hash.Hash
27+
algorithm string
28+
29+
period int64
30+
31+
codeSize int
32+
codeTmpl string
33+
}
34+
35+
func New(options ...Option) (*OTP, error) {
36+
obj := &OTP{}
37+
38+
opts := make([]Option, 0, 10)
39+
opts = append(opts, OptHashSHA1(), OptPeriod(30), OptCode6Digits())
40+
opts = append(opts, options...)
41+
42+
for _, opt := range opts {
43+
opt(obj)
44+
}
45+
46+
return obj, nil
47+
}
48+
49+
func (o *OTP) NewSecret(size int) (string, error) {
50+
if size < 1 {
51+
size = 10
52+
}
53+
secret := make([]byte, size)
54+
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
55+
return "", err
56+
}
57+
return b32.EncodeToString(secret), nil
58+
}
59+
60+
func (o *OTP) validateSecret(secret string) ([]byte, error) {
61+
secret = strings.TrimSpace(secret)
62+
if n := len(secret) % 8; n != 0 {
63+
secret = secret + strings.Repeat("=", 8-n)
64+
}
65+
secret = strings.ToUpper(secret)
66+
secretBytes, err := base32.StdEncoding.DecodeString(secret)
67+
if err != nil {
68+
return nil, fmt.Errorf("invalid secret: %w", err)
69+
}
70+
return secretBytes, nil
71+
}
72+
73+
func (o *OTP) generate(secret string, counter uint64, delta int64) (string, error) {
74+
b, err := o.validateSecret(secret)
75+
if err != nil {
76+
return "", err
77+
}
78+
79+
counterBytes := make([]byte, 8)
80+
binary.BigEndian.PutUint64(counterBytes, uint64(int64(counter)+delta))
81+
82+
hm := hmac.New(o.hash, b)
83+
if _, err := hm.Write(counterBytes); err != nil {
84+
return strings.Repeat("0", o.codeSize), err
85+
}
86+
timeHash := hm.Sum(nil)
87+
88+
offset := int(timeHash[len(timeHash)-1] & 0x0F)
89+
truncHash := int64(
90+
(int(timeHash[offset])&0x7f)<<24 |
91+
(int(timeHash[offset+1])&0xff)<<16 |
92+
(int(timeHash[offset+2])&0xff)<<8 |
93+
(int(timeHash[offset+3]) & 0xff))
94+
95+
otp := truncHash % int64(math.Pow10(o.codeSize))
96+
97+
return fmt.Sprintf(o.codeTmpl, otp), nil
98+
}
99+
100+
func (o *OTP) GenerateTOTP(secret string, delta int64) (string, error) {
101+
currentTime := time.Now().Unix()
102+
counter := uint64(math.Floor(float64(currentTime) / float64(o.period)))
103+
104+
return o.generate(secret, counter, delta)
105+
}
106+
107+
func (o *OTP) GenerateHOTP(secret string, counter uint64) (string, error) {
108+
return o.generate(secret, counter, 0)
109+
}
110+
111+
func (o *OTP) UrlTOTP(secret, account, issuer string) string {
112+
secret = strings.TrimSpace(secret)
113+
params := url.Values{
114+
"secret": []string{secret},
115+
"issuer": []string{issuer},
116+
"algorithm": []string{o.algorithm},
117+
"digits": []string{strconv.Itoa(o.codeSize)},
118+
"period": []string{strconv.Itoa(int(o.period))},
119+
}
120+
121+
uri := url.URL{
122+
Scheme: "otpauth",
123+
Host: "totp",
124+
Path: "/" + account,
125+
RawQuery: params.Encode(),
126+
}
127+
128+
return uri.String()
129+
}
130+
131+
func (o *OTP) UrlHOTP(secret, account, issuer string, counter uint64) string {
132+
secret = strings.TrimSpace(secret)
133+
params := url.Values{
134+
"secret": []string{secret},
135+
"issuer": []string{issuer},
136+
"algorithm": []string{o.algorithm},
137+
"digits": []string{strconv.Itoa(o.codeSize)},
138+
"period": []string{strconv.Itoa(int(o.period))},
139+
"counter": []string{strconv.Itoa(int(counter))},
140+
}
141+
142+
uri := url.URL{
143+
Scheme: "otpauth",
144+
Host: "hotp",
145+
Path: "/" + account,
146+
RawQuery: params.Encode(),
147+
}
148+
149+
return uri.String()
150+
}

encoding/otp/totp_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (c) 2019-2025 Mikhail Knyazhev <[email protected]>. All rights reserved.
3+
* Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file.
4+
*/
5+
6+
package otp_test
7+
8+
import (
9+
"testing"
10+
11+
"go.osspkg.com/casecheck"
12+
13+
"go.osspkg.com/algorithms/encoding/otp"
14+
)
15+
16+
func TestUnit_TOTP_Generate(t *testing.T) {
17+
otp, err := otp.New()
18+
casecheck.NoError(t, err)
19+
20+
c1, err := otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0)
21+
casecheck.NoError(t, err)
22+
c2, err := otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0)
23+
casecheck.NoError(t, err)
24+
c3, err := otp.GenerateTOTP(`JBSWY3DPEHPK3PXP`, 0)
25+
casecheck.NoError(t, err)
26+
27+
casecheck.Equal(t, c1, c2)
28+
casecheck.NotEqual(t, c1, c3)
29+
30+
link := otp.UrlTOTP(`JBSWY3DPEHPK3PXP`, `user name`, `example.com`)
31+
want := `otpauth://totp/user%20name?algorithm=SHA1&digits=6&issuer=example.com&period=30&secret=JBSWY3DPEHPK3PXP`
32+
casecheck.Equal(t, want, link)
33+
}
34+
35+
func TestUnit_HOTP_Generate(t *testing.T) {
36+
otp, err := otp.New()
37+
casecheck.NoError(t, err)
38+
39+
c1, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 0)
40+
casecheck.NoError(t, err)
41+
c2, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 0)
42+
casecheck.NoError(t, err)
43+
c3, err := otp.GenerateHOTP(`4QEXNRSWEYM5HWCG`, 1)
44+
casecheck.NoError(t, err)
45+
c4, err := otp.GenerateHOTP(`JBSWY3DPEHPK3PXP`, 0)
46+
casecheck.NoError(t, err)
47+
48+
casecheck.Equal(t, c1, c2)
49+
casecheck.NotEqual(t, c1, c3)
50+
casecheck.NotEqual(t, c1, c4)
51+
52+
link := otp.UrlHOTP(`JBSWY3DPEHPK3PXP`, `user name`, `example.com`, 0)
53+
want := `otpauth://hotp/user%20name?algorithm=SHA1&counter=0&digits=6&issuer=example.com&period=30&secret=JBSWY3DPEHPK3PXP`
54+
casecheck.Equal(t, want, link)
55+
}
56+
57+
/*
58+
goos: linux
59+
goarch: amd64
60+
pkg: go.osspkg.com/algorithms/encoding/totp
61+
cpu: 12th Gen Intel(R) Core(TM) i9-12900KF
62+
Benchmark_TOTP_Generate
63+
Benchmark_TOTP_Generate-4 6530068 180.6 ns/op 512 B/op 10 allocs/op
64+
*/
65+
func Benchmark_TOTP_Generate(b *testing.B) {
66+
otp, err := otp.New()
67+
casecheck.NoError(b, err)
68+
69+
b.ReportAllocs()
70+
b.ResetTimer()
71+
72+
b.RunParallel(func(pb *testing.PB) {
73+
for pb.Next() {
74+
otp.GenerateTOTP(`4QEXNRSWEYM5HWCG`, 0)
75+
}
76+
})
77+
}

0 commit comments

Comments
 (0)