-
Notifications
You must be signed in to change notification settings - Fork 3
/
totp.go
146 lines (130 loc) · 5.21 KB
/
totp.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package crypto
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"fmt"
"io"
"math"
"net/url"
"strconv"
"time"
"github.com/beaconsoftwarellc/gadget/v2/errors"
"github.com/beaconsoftwarellc/gadget/v2/intutil"
"github.com/skip2/go-qrcode"
"golang.org/x/exp/slices"
)
// NewOTPKey for use with HOTP or TOTP as a base32 encoded string
func NewOTPKey() (string, error) {
key := make([]byte, sha1.Size)
random := rand.Reader
_, err := io.ReadFull(random, key)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(key), err
}
// DynamicTruncate as described in RFC4226
// "The Truncate function performs Step 2 and Step 3, i.e., the dynamic
// truncation and then the reduction modulo 10^Digit. The purpose of
// the dynamic offset truncation technique is to extract a 4-byte
// dynamic binary code from a 160-bit (20-byte) HMAC-SHA-1 result.
//
// DT(String) // String = String[0]...String[19]
// Let OffsetBits be the low-order 4 bits of String[19]
// Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
// Let P = String[OffSet]...String[OffSet+3]
// Return the Last 31 bits of P"
func DynamicTruncate(hmacResult []byte, digits int) string {
offset := int(hmacResult[len(hmacResult)-1] & 0xF)
binCode := []byte{
hmacResult[offset] & 0x7f,
hmacResult[offset+1],
hmacResult[offset+2],
hmacResult[offset+3],
}
return fmt.Sprintf("%0"+strconv.Itoa(digits)+"d", binary.BigEndian.Uint32(binCode)%uint32(math.Pow10(digits)))
}
// HOTP for the passed key and counter with the specified number of digits (min 6, max 8)
func HOTP(key string, counter uint64, length int) (string, error) {
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(key)
if nil != err {
return "", err
}
counterBytes := make([]byte, 8)
if length < 6 || length > 8 {
return "", errors.New("length must be within interval [6,8]")
}
binary.BigEndian.PutUint64(counterBytes, counter)
cipher := hmac.New(sha1.New, keyBytes)
n, err := cipher.Write(counterBytes)
if nil != err {
return "", err
}
if n != len(counterBytes) {
return "", errors.New("unable to generate HOTP, unexpected number of bytes written (%d, %d)",
n, len(counterBytes))
}
return DynamicTruncate(cipher.Sum(nil), length), nil
}
// HOTPCompare the HOTP for the specified key and the passed challenge
func HOTPCompare(key string, counter uint64, length int, challenge string) (bool, error) {
hotp, err := HOTP(key, counter, length)
if nil != err {
return false, err
}
return subtle.ConstantTimeCompare([]byte(hotp), []byte(challenge)) == 1, nil
}
// TOTP for the passed key with the specified period (step size) and number of digits, step will be adjusted
// using the passed 'vary'
func TOTP(key string, period time.Duration, vary int, length int) (string, error) {
currentStep := uint64(math.Floor(float64(time.Now().Unix()) / period.Seconds()))
return HOTP(key, currentStep+uint64(vary), length)
}
// TOTPCompare the challenge to TOTP for a specific step dictated by period and adjust.
func TOTPCompare(key string, period time.Duration, adjust int, length int, challenge string) (bool, error) {
totp, err := TOTP(key, period, adjust, length)
if nil != err {
return false, err
}
return subtle.ConstantTimeCompare([]byte(totp), []byte(challenge)) == 1, nil
}
// TOTPCompareWithVariance the expected TOTP calculation with the challenge in constant time. If variance is > 0
// constant time execution is not guaranteed, allows for totp to fall with the variance range of steps + or -
func TOTPCompareWithVariance(key string, period time.Duration, length int, variance uint, challenge string) (ok bool, err error) {
ok, _, err = TOTPCompareAndGetDrift(key, period, length, variance, challenge, 0)
return
}
// TOTPCompareAndGetDrift the expected TOTP calculation with the challenge in constant time. If variance is > 0
// constant time execution is not guaranteed, allows for totp to fall with the variance range of steps + or -, variance is returned
func TOTPCompareAndGetDrift(key string, period time.Duration, length int, variance uint, challenge string, drift int) (bool, int, error) {
var eq bool
var err error
frames := make([]int, 2*variance+1)
i := drift - int(variance)
for j := 0; j < len(frames); j++ {
frames[j] = i
i++
}
slices.SortFunc(frames, func(a, b int) bool {
return intutil.Abs(a) < intutil.Abs(b)
})
for _, v := range frames {
eq, err = TOTPCompare(key, period, v, length, challenge)
if nil != err || eq {
return eq, v, err
}
}
return false, 0, nil
}
// GenerateTOTPURI for use in a QR code for registration with an authenticator application
func GenerateTOTPURI(key, issuer, user string, period time.Duration, length int) string {
issuer = url.QueryEscape(issuer)
user = url.QueryEscape(user)
return fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%2.f",
user, key, issuer, length, period.Seconds())
}
// GenerateTOTPQRCodePNG that can be served directly using content type header with 'image/png' or written to file.
func GenerateTOTPQRCodePNG(key, issuer, user string, period time.Duration, length int) ([]byte, error) {
return qrcode.Encode(GenerateTOTPURI(key, issuer, user, period, length), qrcode.High, 256)
}