Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions encoding/otp/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2019-2025 Mikhail Knyazhev <markus621@yandex.com>. 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)
}
}
150 changes: 150 additions & 0 deletions encoding/otp/totp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2019-2025 Mikhail Knyazhev <markus621@yandex.com>. 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()
}
77 changes: 77 additions & 0 deletions encoding/otp/totp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2019-2025 Mikhail Knyazhev <markus621@yandex.com>. 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)
}
})
}