Skip to content

Commit f3307cf

Browse files
authored
feat: support variable time period for a token (#137)
Resolves #136 References: - #136 Signed-off-by: Victoria Nadasdi <[email protected]>
1 parent 28337f3 commit f3307cf

File tree

9 files changed

+150
-44
lines changed

9 files changed

+150
-44
lines changed

internal/cmd/add_token.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func AddTokenCommand() *cli.Command {
2020
flagLength(),
2121
flagPrefix(),
2222
flagAlgorithm(),
23+
flagTimePeriod(),
2324
},
2425
Action: func(ctx *cli.Context) error {
2526
var (
@@ -48,11 +49,12 @@ func AddTokenCommand() *cli.Command {
4849
}
4950

5051
account = &s.Account{
51-
Name: accName,
52-
Token: token,
53-
Prefix: ctx.String("prefix"),
54-
Length: ctx.Uint("length"),
55-
Algorithm: ctx.String("algorithm"),
52+
Name: accName,
53+
Token: token,
54+
Prefix: ctx.String("prefix"),
55+
Length: ctx.Uint("length"),
56+
Algorithm: ctx.String("algorithm"),
57+
TimePeriod: ctx.Int64("time-period"),
5658
}
5759
namespace.Accounts = append(namespace.Accounts, account)
5860

internal/cmd/error.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type DownloadError struct {
66
}
77

88
func (e DownloadError) Error() string {
9-
return "download error: %s" + e.Message
9+
return "download error: " + e.Message
1010
}
1111

1212
// CommandError is an error during downloading an update.
@@ -15,7 +15,7 @@ type CommandError struct {
1515
}
1616

1717
func (e CommandError) Error() string {
18-
return "error: %s" + e.Message
18+
return "error: " + e.Message
1919
}
2020

2121
func resourceNotFoundError(name string) CommandError {
@@ -28,7 +28,7 @@ type FlagError struct {
2828
}
2929

3030
func (e FlagError) Error() string {
31-
return "flag error: %s" + e.Message
31+
return "flag error: " + e.Message
3232
}
3333

3434
func invalidAlgorithmError(value string) FlagError {

internal/cmd/flags.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package cmd
22

33
import (
44
"github.com/urfave/cli/v2"
5-
"github.com/yitsushi/totp-cli/internal/storage"
5+
"github.com/yitsushi/totp-cli/internal/security"
66
)
77

88
func flagAlgorithm() *cli.StringFlag {
@@ -31,7 +31,7 @@ func flagShowRemaining() *cli.BoolFlag {
3131
func flagLength() *cli.UintFlag {
3232
return &cli.UintFlag{
3333
Name: "length",
34-
Value: storage.DefaultTokenLength,
34+
Value: security.DefaultLength,
3535
Usage: "Length of the generated token.",
3636
}
3737
}
@@ -83,3 +83,11 @@ func flagClearPrefix() *cli.BoolFlag {
8383
Usage: "Clear prefix from account.",
8484
}
8585
}
86+
87+
func flagTimePeriod() *cli.Int64Flag {
88+
return &cli.Int64Flag{
89+
Name: "time-period",
90+
Value: security.DefaultTimePeriod,
91+
Usage: "Time period in seconds.",
92+
}
93+
}

internal/cmd/generate.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ func GenerateCommand() *cli.Command {
3030
return CommandError{Message: "account is not defined"}
3131
}
3232

33+
ctx.Args().First()
34+
3335
follow := ctx.Bool("follow")
3436

3537
storage := s.NewFileStorage()
@@ -43,6 +45,7 @@ func GenerateCommand() *cli.Command {
4345
}
4446

4547
code, remaining := generateCode(account)
48+
4649
fmt.Println(formatCode(code, remaining, ctx.Bool("show-remaining")))
4750

4851
if !follow {

internal/cmd/generatehelper.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ func generateCode(account *s.Account) (string, int64) {
3131
algorithm = algo.Default{}
3232
}
3333

34-
code, remaining, err := security.GenerateOTPCode(account.Token, time.Now(), account.Length, algorithm)
34+
code, remaining, err := security.GenerateOTPCode(security.GenerateOptions{
35+
Token: account.Token,
36+
When: time.Now(),
37+
Length: account.Length,
38+
Algorithm: algorithm,
39+
TimePeriod: account.TimePeriod,
40+
})
3541
if err != nil {
3642
fmt.Printf("Error: %s\n", err.Error())
3743

internal/cmd/instant.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ func InstantCommand() *cli.Command {
1919
flagLength(),
2020
flagShowRemaining(),
2121
flagAlgorithm(),
22+
flagTimePeriod(),
2223
},
2324
Action: func(ctx *cli.Context) error {
2425
account := storage.Account{
25-
Name: "instant",
26-
Token: os.Getenv("TOTP_TOKEN"),
27-
Length: ctx.Uint("length"),
28-
Algorithm: ctx.String("algorithm"),
26+
Name: "instant",
27+
Token: os.Getenv("TOTP_TOKEN"),
28+
Length: ctx.Uint("length"),
29+
Algorithm: ctx.String("algorithm"),
30+
TimePeriod: ctx.Int64("time-period"),
2931
}
3032

3133
if account.Token == "" {

internal/security/otp.go

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,63 @@ const (
1616
mask1 = 0xf
1717
mask2 = 0x7f
1818
mask3 = 0xff
19-
timeSplitInSeconds = 30
20-
shift24 = 24
19+
passwordHashLength = 32
2120
shift16 = 16
21+
shift24 = 24
2222
shift8 = 8
2323
sumByteLength = 8
24-
passwordHashLength = 32
24+
25+
// DefaultLength is the default length of the generated OTP code.
26+
DefaultLength = 6
27+
// DefaultTimePeriod is the default time period for the TOTP.
28+
DefaultTimePeriod = 30
2529
)
2630

27-
// GenerateOTPCode generates a 6 digit TOTP from the secret Token.
28-
func GenerateOTPCode(token string, when time.Time, length uint, algorithm algo.Algorithm) (string, int64, error) {
29-
timer := uint64(math.Floor(float64(when.Unix()) / float64(timeSplitInSeconds)))
30-
remainingTime := timeSplitInSeconds - when.Unix()%timeSplitInSeconds
31+
// GenerateOptions is the option list for the GenerateOTPCode function.
32+
type GenerateOptions struct {
33+
Token string
34+
When time.Time
35+
Length uint
36+
Algorithm algo.Algorithm
37+
TimePeriod int64
38+
}
39+
40+
func (opts *GenerateOptions) normalise() {
41+
if opts.Length == 0 {
42+
opts.Length = DefaultLength
43+
}
44+
45+
if opts.Algorithm == nil {
46+
opts.Algorithm = algo.SHA1{}
47+
}
48+
49+
if opts.TimePeriod == 0 {
50+
opts.TimePeriod = DefaultTimePeriod
51+
}
3152

3253
// Remove spaces, some providers are giving us in a readable format,
3354
// so they add spaces in there. If it's not removed while pasting in,
3455
// remove it now.
35-
token = strings.ReplaceAll(token, " ", "")
56+
opts.Token = strings.ReplaceAll(opts.Token, " ", "")
3657

3758
// It should be uppercase always
38-
token = strings.ToUpper(token)
59+
opts.Token = strings.ToUpper(opts.Token)
60+
}
3961

40-
secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(token)
62+
// GenerateOTPCode generates an N digit TOTP from the secret Token.
63+
func GenerateOTPCode(opts GenerateOptions) (string, int64, error) {
64+
opts.normalise()
65+
66+
timer := uint64(math.Floor(float64(opts.When.Unix()) / float64(opts.TimePeriod)))
67+
remainingTime := opts.TimePeriod - opts.When.Unix()%opts.TimePeriod
68+
69+
secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(opts.Token)
4170
if err != nil {
4271
return "", 0, OTPError{Message: err.Error()}
4372
}
4473

45-
if length == 0 {
46-
length = 6
47-
}
48-
4974
buf := make([]byte, sumByteLength)
50-
mac := hmac.New(algorithm.Hasher(), secretBytes)
75+
mac := hmac.New(opts.Algorithm.Hasher(), secretBytes)
5176

5277
binary.BigEndian.PutUint64(buf, timer)
5378
_, _ = mac.Write(buf)
@@ -61,9 +86,9 @@ func GenerateOTPCode(token string, when time.Time, length uint, algorithm algo.A
6186
(int(sum[offset+3]) & mask3))
6287

6388
//nolint:gosec // If the user sets a size that high to get an overflow, it's on them.
64-
modulo := int32(value % int64(math.Pow10(int(length))))
89+
modulo := int32(value % int64(math.Pow10(int(opts.Length))))
6590

66-
format := fmt.Sprintf("%%0%dd", length)
91+
format := fmt.Sprintf("%%0%dd", opts.Length)
6792

6893
return fmt.Sprintf(format, modulo), remainingTime, nil
6994
}

internal/security/otp_test.go

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ func (suite *GenerateOTPCodeTestSuite) TestDefault() {
3232
}
3333

3434
for when, expected := range table {
35-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA1{})
35+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
36+
Token: input,
37+
When: when,
38+
})
3639

3740
suite.Require().NoError(err)
3841
suite.Equal(expected, code, when.String())
@@ -52,7 +55,12 @@ func (suite *GenerateOTPCodeTestSuite) TestDifferentLength() {
5255
}
5356

5457
for when, expected := range table {
55-
code, _, err := security.GenerateOTPCode(input, when, 8, algo.SHA1{})
58+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
59+
Token: input,
60+
When: when,
61+
Length: 8,
62+
Algorithm: algo.SHA1{},
63+
})
5664

5765
suite.Require().NoError(err)
5866
suite.Equal(expected, code, when.String())
@@ -72,7 +80,11 @@ func (suite *GenerateOTPCodeTestSuite) TestSpaceSeparatedToken() {
7280
}
7381

7482
for when, expected := range table {
75-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA1{})
83+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
84+
Token: input,
85+
When: when,
86+
Algorithm: algo.SHA1{},
87+
})
7688

7789
suite.Require().NoError(err)
7890
suite.Equal(expected, code, when.String())
@@ -92,7 +104,12 @@ func (suite *GenerateOTPCodeTestSuite) TestNonPaddedHashes() {
92104
}
93105

94106
for when, expected := range table {
95-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA1{})
107+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
108+
Token: input,
109+
When: when,
110+
Length: storage.DefaultTokenLength,
111+
Algorithm: algo.SHA1{},
112+
})
96113

97114
suite.Require().NoError(err)
98115
suite.Equal(expected, code, when.String())
@@ -107,7 +124,12 @@ func (suite *GenerateOTPCodeTestSuite) TestInvalidPadding() {
107124
}
108125

109126
for when, expected := range table {
110-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA1{})
127+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
128+
Token: input,
129+
When: when,
130+
Length: storage.DefaultTokenLength,
131+
Algorithm: algo.SHA1{},
132+
})
111133

112134
suite.Require().Error(err)
113135
suite.Equal(expected, code, when.String())
@@ -128,7 +150,12 @@ func (suite *GenerateOTPCodeTestSuite) TestSHA256() {
128150
}
129151

130152
for when, expected := range table {
131-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA256{})
153+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
154+
Token: input,
155+
When: when,
156+
Length: storage.DefaultTokenLength,
157+
Algorithm: algo.SHA256{},
158+
})
132159

133160
suite.Require().NoError(err)
134161
suite.Equal(expected, code, when.String())
@@ -149,7 +176,39 @@ func (suite *GenerateOTPCodeTestSuite) TestSHA512() {
149176
}
150177

151178
for when, expected := range table {
152-
code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength, algo.SHA512{})
179+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
180+
Token: input,
181+
When: when,
182+
Length: storage.DefaultTokenLength,
183+
Algorithm: algo.SHA512{},
184+
})
185+
186+
suite.Require().NoError(err)
187+
suite.Equal(expected, code, when.String())
188+
}
189+
}
190+
191+
func (suite *GenerateOTPCodeTestSuite) TestSHA256WithLongerPeriod() {
192+
input := "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
193+
table := map[time.Time]string{
194+
time.Date(2025, 02, 26, 18, 12, 1, 0, time.UTC): "195134",
195+
time.Date(2025, 02, 26, 18, 12, 11, 0, time.UTC): "195134",
196+
time.Date(2025, 02, 26, 18, 12, 23, 0, time.UTC): "195134",
197+
time.Date(2025, 02, 26, 18, 12, 33, 0, time.UTC): "195134",
198+
time.Date(2025, 02, 26, 18, 12, 43, 0, time.UTC): "195134",
199+
time.Date(2025, 02, 26, 18, 12, 53, 0, time.UTC): "195134",
200+
time.Date(2025, 02, 26, 18, 13, 3, 0, time.UTC): "042795",
201+
time.Date(2025, 02, 26, 18, 13, 13, 0, time.UTC): "042795",
202+
}
203+
204+
for when, expected := range table {
205+
code, _, err := security.GenerateOTPCode(security.GenerateOptions{
206+
Token: input,
207+
When: when,
208+
Length: storage.DefaultTokenLength,
209+
TimePeriod: 60,
210+
Algorithm: algo.SHA256{},
211+
})
153212

154213
suite.Require().NoError(err)
155214
suite.Equal(expected, code, when.String())

internal/storage/account.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ const DefaultTokenLength = 6
66

77
// Account represents a TOTP account.
88
type Account struct {
9-
Name string `json:"name" yaml:"name"`
10-
Token string `json:"token" yaml:"token"`
11-
Prefix string `json:"prefix" yaml:"prefix"`
12-
Length uint `json:"length" yaml:"length"`
13-
Algorithm string `json:"algorithm" yaml:"algorithm"`
9+
Name string `json:"name" yaml:"name"`
10+
Token string `json:"token" yaml:"token"`
11+
Prefix string `json:"prefix" yaml:"prefix"`
12+
Length uint `json:"length" yaml:"length"`
13+
Algorithm string `json:"algorithm" yaml:"algorithm"`
14+
TimePeriod int64 `json:"timePeriod" yaml:"timePeriod"`
1415
}

0 commit comments

Comments
 (0)