diff --git a/twofactor/.circleci/config.yml b/twofactor/.circleci/config.yml new file mode 100644 index 0000000..87ba58a --- /dev/null +++ b/twofactor/.circleci/config.yml @@ -0,0 +1,42 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/2.0/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/2.0/configuration-reference/#jobs +jobs: + testbuild: + working_directory: ~/repo + # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor + docker: + - image: cimg/go:1.22.2 + # Add steps to the job + # See: https://circleci.com/docs/2.0/configuration-reference/#steps + steps: + - checkout + - restore_cache: + keys: + - go-mod-v4-{{ checksum "go.sum" }} + - run: + name: Install Dependencies + command: go mod download + - save_cache: + key: go-mod-v4-{{ checksum "go.sum" }} + paths: + - "/go/pkg/mod" + - run: + name: Run tests + command: go test ./... + - run: + name: Run build + command: go build ./... + - store_test_results: + path: /tmp/test-reports + +# Invoke jobs via workflows +# See: https://circleci.com/docs/2.0/configuration-reference/#workflows +workflows: + testbuild: + jobs: + - testbuild diff --git a/twofactor/LICENSE b/twofactor/LICENSE new file mode 100644 index 0000000..027cde0 --- /dev/null +++ b/twofactor/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Kyle Isom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/twofactor/README.md b/twofactor/README.md new file mode 100644 index 0000000..7d9edb0 --- /dev/null +++ b/twofactor/README.md @@ -0,0 +1,33 @@ +## `twofactor` + +[![GoDoc](https://godoc.org/github.com/gokyle/twofactor?status.svg)](https://godoc.org/github.com/gokyle/twofactor) + + +### Author + +`twofactor` was written by Kyle Isom . + + +### License + +``` +Copyright (c) 2017 Kyle Isom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/twofactor/doc.go b/twofactor/doc.go new file mode 100644 index 0000000..6f1eef8 --- /dev/null +++ b/twofactor/doc.go @@ -0,0 +1,5 @@ +// twofactor implements two-factor authentication. +// +// Currently supported are RFC 4226 HOTP one-time passwords and +// RFC 6238 TOTP SHA-1 one-time passwords. +package twofactor diff --git a/twofactor/go.mod b/twofactor/go.mod new file mode 100644 index 0000000..e8d6c5d --- /dev/null +++ b/twofactor/go.mod @@ -0,0 +1,8 @@ +module github.com/gokyle/twofactor + +go 1.14 + +require ( + github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 + rsc.io/qr v0.1.0 +) diff --git a/twofactor/go.sum b/twofactor/go.sum new file mode 100644 index 0000000..b54497a --- /dev/null +++ b/twofactor/go.sum @@ -0,0 +1,4 @@ +github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 h1:wOysYcIdqv3WnvwqFFzrYCFALPED7qkUGaLXu359GSc= +github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3/go.mod h1:UMqtWQTnOe4byzwe7Zhwh8f8s+36uszN51sJrSIZlTE= +rsc.io/qr v0.1.0 h1:M/sAxsU2J5mlQ4W84Bxga2EgdQqOaAliipcjPmMUM5Q= +rsc.io/qr v0.1.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/twofactor/hotp.go b/twofactor/hotp.go new file mode 100644 index 0000000..292838a --- /dev/null +++ b/twofactor/hotp.go @@ -0,0 +1,103 @@ +package twofactor + +import ( + "crypto" + "crypto/sha1" + "encoding/base32" + "io" + "net/url" + "strconv" + "strings" +) + +// HOTP represents an RFC-4226 Hash-based One Time Password instance. +type HOTP struct { + *OATH +} + +// Type returns OATH_HOTP. +func (otp *HOTP) Type() Type { + return OATH_HOTP +} + +// NewHOTP takes the key, the initial counter value, and the number +// of digits (typically 6 or 8) and returns a new HOTP instance. +func NewHOTP(key []byte, counter uint64, digits int) *HOTP { + return &HOTP{ + OATH: &OATH{ + key: key, + counter: counter, + size: digits, + hash: sha1.New, + algo: crypto.SHA1, + }, + } +} + +// OTP returns the next OTP and increments the counter. +func (otp *HOTP) OTP() string { + code := otp.OATH.OTP(otp.counter) + otp.counter++ + return code +} + +// URL returns an HOTP URL (i.e. for putting in a QR code). +func (otp *HOTP) URL(label string) string { + return otp.OATH.URL(otp.Type(), label) +} + +// SetProvider sets up the provider component of the OTP URL. +func (otp *HOTP) SetProvider(provider string) { + otp.provider = provider +} + +// GenerateGoogleHOTP generates a new HOTP instance as used by +// Google Authenticator. +func GenerateGoogleHOTP() *HOTP { + key := make([]byte, sha1.Size) + if _, err := io.ReadFull(PRNG, key); err != nil { + return nil + } + return NewHOTP(key, 0, 6) +} + +func hotpFromURL(u *url.URL) (*HOTP, string, error) { + label := u.Path[1:] + v := u.Query() + + secret := strings.ToUpper(v.Get("secret")) + if secret == "" { + return nil, "", ErrInvalidURL + } + + var digits = 6 + if sdigit := v.Get("digits"); sdigit != "" { + tmpDigits, err := strconv.ParseInt(sdigit, 10, 8) + if err != nil { + return nil, "", err + } + digits = int(tmpDigits) + } + + var counter uint64 = 0 + if scounter := v.Get("counter"); scounter != "" { + var err error + counter, err = strconv.ParseUint(scounter, 10, 64) + if err != nil { + return nil, "", err + } + } + + key, err := base32.StdEncoding.DecodeString(Pad(secret)) + if err != nil { + // assume secret isn't base32 encoded + key = []byte(secret) + } + otp := NewHOTP(key, counter, digits) + return otp, label, nil +} + +// QR generates a new QR code for the HOTP. +func (otp *HOTP) QR(label string) ([]byte, error) { + return otp.OATH.QR(otp.Type(), label) +} diff --git a/twofactor/hotp_test.go b/twofactor/hotp_test.go new file mode 100644 index 0000000..3fda2fc --- /dev/null +++ b/twofactor/hotp_test.go @@ -0,0 +1,64 @@ +package twofactor + +import ( + "fmt" + "testing" +) + +var testKey = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + +var rfcHotpKey = []byte("12345678901234567890") +var rfcHotpExpected = []string{ + "755224", + "287082", + "359152", + "969429", + "338314", + "254676", + "287922", + "162583", + "399871", + "520489", +} + +// This test runs through the test cases presented in the RFC, and +// ensures that this implementation is in compliance. +func TestHotpRFC(t *testing.T) { + otp := NewHOTP(rfcHotpKey, 0, 6) + for i := 0; i < len(rfcHotpExpected); i++ { + if otp.Counter() != uint64(i) { + fmt.Printf("twofactor: invalid counter (should be %d, is %d", + i, otp.Counter()) + t.FailNow() + } + code := otp.OTP() + if code == "" { + fmt.Printf("twofactor: failed to produce an OTP\n") + t.FailNow() + } else if code != rfcHotpExpected[i] { + fmt.Printf("twofactor: invalid OTP\n") + fmt.Printf("\tExpected: %s\n", rfcHotpExpected[i]) + fmt.Printf("\t Actual: %s\n", code) + fmt.Printf("\t Counter: %d\n", otp.counter) + t.FailNow() + } + } +} + +// This test uses a different key than the test cases in the RFC, +// but runs through the same test cases to ensure that they fail as +// expected. +func TestHotpBadRFC(t *testing.T) { + otp := NewHOTP(testKey, 0, 6) + for i := 0; i < len(rfcHotpExpected); i++ { + code := otp.OTP() + switch code { + case "": + fmt.Printf("twofactor: failed to produce an OTP\n") + t.FailNow() + case rfcHotpExpected[i]: + fmt.Printf("twofactor: should not have received a valid OTP\n") + t.FailNow() + } + } +} diff --git a/twofactor/oath.go b/twofactor/oath.go new file mode 100644 index 0000000..6c60608 --- /dev/null +++ b/twofactor/oath.go @@ -0,0 +1,150 @@ +package twofactor + +import ( + "crypto" + "crypto/hmac" + "encoding/base32" + "encoding/binary" + "fmt" + "hash" + "net/url" + + "rsc.io/qr" +) + +const defaultSize = 6 + +// OATH provides a baseline structure for the two OATH algorithms. +type OATH struct { + key []byte + counter uint64 + size int + hash func() hash.Hash + algo crypto.Hash + provider string +} + +// Size returns the output size (in characters) of the password. +func (o OATH) Size() int { + return o.size +} + +// Counter returns the OATH token's counter. +func (o OATH) Counter() uint64 { + return o.counter +} + +// SetCounter updates the OATH token's counter to a new value. +func (o *OATH) SetCounter(counter uint64) { + o.counter = counter +} + +// Key returns the token's secret key. +func (o OATH) Key() []byte { + return o.key[:] +} + +// Hash returns the token's hash function. +func (o OATH) Hash() func() hash.Hash { + return o.hash +} + +// URL constructs a URL appropriate for the token (i.e. for use in a +// QR code). +func (o OATH) URL(t Type, label string) string { + secret := base32.StdEncoding.EncodeToString(o.key) + u := url.URL{} + v := url.Values{} + u.Scheme = "otpauth" + switch t { + case OATH_HOTP: + u.Host = "hotp" + case OATH_TOTP: + u.Host = "totp" + } + u.Path = label + v.Add("secret", secret) + if o.Counter() != 0 && t == OATH_HOTP { + v.Add("counter", fmt.Sprintf("%d", o.Counter())) + } + if o.Size() != defaultSize { + v.Add("digits", fmt.Sprintf("%d", o.Size())) + } + + switch o.algo { + case crypto.SHA256: + v.Add("algorithm", "SHA256") + case crypto.SHA512: + v.Add("algorithm", "SHA512") + } + + if o.provider != "" { + v.Add("provider", o.provider) + } + + u.RawQuery = v.Encode() + return u.String() + +} + +var digits = []int64{ + 0: 1, + 1: 10, + 2: 100, + 3: 1000, + 4: 10000, + 5: 100000, + 6: 1000000, + 7: 10000000, + 8: 100000000, + 9: 1000000000, + 10: 10000000000, +} + +// The top-level type should provide a counter; for example, HOTP +// will provide the counter directly while TOTP will provide the +// time-stepped counter. +func (o OATH) OTP(counter uint64) string { + var ctr [8]byte + binary.BigEndian.PutUint64(ctr[:], counter) + + var mod int64 = 1 + if len(digits) > o.size { + for i := 1; i <= o.size; i++ { + mod *= 10 + } + } else { + mod = digits[o.size] + } + + h := hmac.New(o.hash, o.key) + h.Write(ctr[:]) + dt := truncate(h.Sum(nil)) % mod + fmtStr := fmt.Sprintf("%%0%dd", o.size) + return fmt.Sprintf(fmtStr, dt) +} + +// truncate contains the DT function from the RFC; this is used to +// deterministically select a sequence of 4 bytes from the HMAC +// counter hash. +func truncate(in []byte) int64 { + offset := int(in[len(in)-1] & 0xF) + p := in[offset : offset+4] + var binCode int32 + binCode = int32((p[0] & 0x7f)) << 24 + binCode += int32((p[1] & 0xff)) << 16 + binCode += int32((p[2] & 0xff)) << 8 + binCode += int32((p[3] & 0xff)) + return int64(binCode) & 0x7FFFFFFF +} + +// QR generates a byte slice containing the a QR code encoded as a +// PNG with level Q error correction. +func (o OATH) QR(t Type, label string) ([]byte, error) { + u := o.URL(t, label) + code, err := qr.Encode(u, qr.Q) + if err != nil { + return nil, err + } + return code.PNG(), nil +} diff --git a/twofactor/oath_test.go b/twofactor/oath_test.go new file mode 100644 index 0000000..7b145b0 --- /dev/null +++ b/twofactor/oath_test.go @@ -0,0 +1,30 @@ +package twofactor + +import ( + "fmt" + "testing" +) + +var sha1Hmac = []byte{ + 0x1f, 0x86, 0x98, 0x69, 0x0e, + 0x02, 0xca, 0x16, 0x61, 0x85, + 0x50, 0xef, 0x7f, 0x19, 0xda, + 0x8e, 0x94, 0x5b, 0x55, 0x5a, +} + +var truncExpect int64 = 0x50ef7f19 + +// This test runs through the truncation example given in the RFC. +func TestTruncate(t *testing.T) { + if result := truncate(sha1Hmac); result != truncExpect { + fmt.Printf("hotp: expected truncate -> %d, saw %d\n", + truncExpect, result) + t.FailNow() + } + + sha1Hmac[19]++ + if result := truncate(sha1Hmac); result == truncExpect { + fmt.Println("hotp: expected truncation to fail") + t.FailNow() + } +} diff --git a/twofactor/otp.go b/twofactor/otp.go new file mode 100644 index 0000000..5a78358 --- /dev/null +++ b/twofactor/otp.go @@ -0,0 +1,86 @@ +package twofactor + +import ( + "crypto/rand" + "errors" + "fmt" + "hash" + "net/url" +) + +type Type uint + +const ( + OATH_HOTP = iota + OATH_TOTP +) + +// PRNG is an io.Reader that provides a cryptographically secure +// random byte stream. +var PRNG = rand.Reader + +var ( + ErrInvalidURL = errors.New("twofactor: invalid URL") + ErrInvalidAlgo = errors.New("twofactor: invalid algorithm") +) + +// Type OTP represents a one-time password token -- whether a +// software taken (as in the case of Google Authenticator) or a +// hardware token (as in the case of a YubiKey). +type OTP interface { + // Returns the current counter value; the meaning of the + // returned value is algorithm-specific. + Counter() uint64 + + // Set the counter to a specific value. + SetCounter(uint64) + + // the secret key contained in the OTP + Key() []byte + + // generate a new OTP + OTP() string + + // the output size of the OTP + Size() int + + // the hash function used by the OTP + Hash() func() hash.Hash + + // Returns the type of this OTP. + Type() Type +} + +func otpString(otp OTP) string { + var typeName string + switch otp.Type() { + case OATH_HOTP: + typeName = "OATH-HOTP" + case OATH_TOTP: + typeName = "OATH-TOTP" + default: + typeName = "UNKNOWN" + } + return fmt.Sprintf("%s, %d", typeName, otp.Size()) +} + +// FromURL constructs a new OTP token from a URL string. +func FromURL(URL string) (OTP, string, error) { + u, err := url.Parse(URL) + if err != nil { + return nil, "", err + } + + if u.Scheme != "otpauth" { + return nil, "", ErrInvalidURL + } + + switch u.Host { + case "totp": + return totpFromURL(u) + case "hotp": + return hotpFromURL(u) + default: + return nil, "", ErrInvalidURL + } +} diff --git a/twofactor/otp_test.go b/twofactor/otp_test.go new file mode 100644 index 0000000..412a0b3 --- /dev/null +++ b/twofactor/otp_test.go @@ -0,0 +1,136 @@ +package twofactor + +import ( + "fmt" + "io" + "testing" +) + +func TestHOTPString(t *testing.T) { + hotp := NewHOTP(nil, 0, 6) + hotpString := otpString(hotp) + if hotpString != "OATH-HOTP, 6" { + fmt.Println("twofactor: invalid OTP string") + t.FailNow() + } +} + +// This test generates a new OTP, outputs the URL for that OTP, +// and attempts to parse that URL. It verifies that the two OTPs +// are the same, and that they produce the same output. +func TestURL(t *testing.T) { + var ident = "testuser@foo" + otp := NewHOTP(testKey, 0, 6) + url := otp.URL("testuser@foo") + otp2, id, err := FromURL(url) + if err != nil { + fmt.Printf("hotp: failed to parse HOTP URL\n") + t.FailNow() + } else if id != ident { + fmt.Printf("hotp: bad label\n") + fmt.Printf("\texpected: %s\n", ident) + fmt.Printf("\t actual: %s\n", id) + t.FailNow() + } else if otp2.Counter() != otp.Counter() { + fmt.Printf("hotp: OTP counters aren't synced\n") + fmt.Printf("\toriginal: %d\n", otp.Counter()) + fmt.Printf("\t second: %d\n", otp2.Counter()) + t.FailNow() + } + + code1 := otp.OTP() + code2 := otp2.OTP() + if code1 != code2 { + fmt.Printf("hotp: mismatched OTPs\n") + fmt.Printf("\texpected: %s\n", code1) + fmt.Printf("\t actual: %s\n", code2) + } + + // There's not much we can do test the QR code, except to + // ensure it doesn't fail. + _, err = otp.QR(ident) + if err != nil { + fmt.Printf("hotp: failed to generate QR code PNG (%v)\n", err) + t.FailNow() + } + + // This should fail because the maximum size of an alphanumeric + // QR code with the lowest-level of error correction should + // max out at 4296 bytes. 8k may be a bit overkill... but it + // gets the job done. The value is read from the PRNG to + // increase the likelihood that the returned data is + // uncompressible. + var tooBigIdent = make([]byte, 8192) + _, err = io.ReadFull(PRNG, tooBigIdent) + if err != nil { + fmt.Printf("hotp: failed to read identity (%v)\n", err) + t.FailNow() + } else if _, err = otp.QR(string(tooBigIdent)); err == nil { + fmt.Println("hotp: QR code should fail to encode oversized URL") + t.FailNow() + } +} + +// This test makes sure we can generate codes for padded and non-padded +// entries +func TestPaddedURL(t *testing.T) { + var urlList = []string{ + "otpauth://hotp/?secret=ME", + "otpauth://hotp/?secret=MEFR", + "otpauth://hotp/?secret=MFRGG", + "otpauth://hotp/?secret=MFRGGZA", + "otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by=======", + "otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by", + "otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by%3D%3D%3D%3D%3D%3D%3D", + } + var codeList = []string{ + "413198", + "770938", + "670717", + "402378", + "069864", + "069864", + "069864", + } + + for i := range urlList { + if o, id, err := FromURL(urlList[i]); err != nil { + fmt.Println("hotp: URL should have parsed successfully (id=", id, ")") + fmt.Printf("\turl was: %s\n", urlList[i]) + t.FailNow() + fmt.Printf("\t%s, %s\n", o.OTP(), id) + } else { + code2 := o.OTP() + if code2 != codeList[i] { + fmt.Printf("hotp: mismatched OTPs\n") + fmt.Printf("\texpected: %s\n", codeList[i]) + fmt.Printf("\t actual: %s\n", code2) + t.FailNow() + } + } + } +} + +// This test attempts a variety of invalid urls against the parser +// to ensure they fail. +func TestBadURL(t *testing.T) { + var urlList = []string{ + "http://google.com", + "", + "-", + "foo", + "otpauth:/foo/bar/baz", + "://", + "otpauth://hotp/?digits=", + "otpauth://hotp/?secret=MFRGGZDF&digits=ABCD", + "otpauth://hotp/?secret=MFRGGZDF&counter=ABCD", + } + + for i := range urlList { + if _, _, err := FromURL(urlList[i]); err == nil { + fmt.Println("hotp: URL should not have parsed successfully") + fmt.Printf("\turl was: %s\n", urlList[i]) + t.FailNow() + } + } +} diff --git a/twofactor/totp.go b/twofactor/totp.go new file mode 100644 index 0000000..9259e6c --- /dev/null +++ b/twofactor/totp.go @@ -0,0 +1,172 @@ +package twofactor + +import ( + "crypto" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base32" + "hash" + "io" + "net/url" + "strconv" + "strings" + + "github.com/benbjohnson/clock" +) + +var timeSource = clock.New() + +// TOTP represents an RFC 6238 Time-based One-Time Password instance. +type TOTP struct { + *OATH + step uint64 +} + +// Type returns OATH_TOTP. +func (otp *TOTP) Type() Type { + return OATH_TOTP +} + +func (otp *TOTP) otp(counter uint64) string { + return otp.OATH.OTP(counter) +} + +// OTP returns the OTP for the current timestep. +func (otp *TOTP) OTP() string { + return otp.otp(otp.OTPCounter()) +} + +// URL returns a TOTP URL (i.e. for putting in a QR code). +func (otp *TOTP) URL(label string) string { + return otp.OATH.URL(otp.Type(), label) +} + +// SetProvider sets up the provider component of the OTP URL. +func (otp *TOTP) SetProvider(provider string) { + otp.provider = provider +} + +func (otp *TOTP) otpCounter(t uint64) uint64 { + return (t - otp.counter) / otp.step +} + +// OTPCounter returns the current time value for the OTP. +func (otp *TOTP) OTPCounter() uint64 { + return otp.otpCounter(uint64(timeSource.Now().Unix())) +} + +// NewTOTP takes a new key, a starting time, a step, the number of +// digits of output (typically 6 or 8) and the hash algorithm to +// use, and builds a new OTP. +func NewTOTP(key []byte, start uint64, step uint64, digits int, algo crypto.Hash) *TOTP { + h := hashFromAlgo(algo) + if h == nil { + return nil + } + + return &TOTP{ + OATH: &OATH{ + key: key, + counter: start, + size: digits, + hash: h, + algo: algo, + }, + step: step, + } + +} + +// NewTOTPSHA1 will build a new TOTP using SHA-1. +func NewTOTPSHA1(key []byte, start uint64, step uint64, digits int) *TOTP { + return NewTOTP(key, start, step, digits, crypto.SHA1) +} + +func hashFromAlgo(algo crypto.Hash) func() hash.Hash { + switch algo { + case crypto.SHA1: + return sha1.New + case crypto.SHA256: + return sha256.New + case crypto.SHA512: + return sha512.New + } + return nil +} + +// GenerateGoogleTOTP produces a new TOTP token with the defaults expected by +// Google Authenticator. +func GenerateGoogleTOTP() *TOTP { + key := make([]byte, sha1.Size) + if _, err := io.ReadFull(PRNG, key); err != nil { + return nil + } + return NewTOTP(key, 0, 30, 6, crypto.SHA1) +} + +// NewGoogleTOTP takes a secret as a base32-encoded string and +// returns an appropriate Google Authenticator TOTP instance. +func NewGoogleTOTP(secret string) (*TOTP, error) { + key, err := base32.StdEncoding.DecodeString(secret) + if err != nil { + return nil, err + } + return NewTOTP(key, 0, 30, 6, crypto.SHA1), nil +} + +func totpFromURL(u *url.URL) (*TOTP, string, error) { + label := u.Path[1:] + v := u.Query() + + secret := strings.ToUpper(v.Get("secret")) + if secret == "" { + return nil, "", ErrInvalidURL + } + + var algo = crypto.SHA1 + if algorithm := v.Get("algorithm"); algorithm != "" { + if strings.EqualFold(algorithm, "SHA256") { + algo = crypto.SHA256 + } else if strings.EqualFold(algorithm, "SHA512") { + algo = crypto.SHA512 + } else if !strings.EqualFold(algorithm, "SHA1") { + return nil, "", ErrInvalidAlgo + } + } + + var digits = 6 + if sdigit := v.Get("digits"); sdigit != "" { + tmpDigits, err := strconv.ParseInt(sdigit, 10, 8) + if err != nil { + return nil, "", err + } + digits = int(tmpDigits) + } + + var period uint64 = 30 + if speriod := v.Get("period"); speriod != "" { + var err error + period, err = strconv.ParseUint(speriod, 10, 64) + if err != nil { + return nil, "", err + } + } + + key, err := base32.StdEncoding.DecodeString(Pad(secret)) + if err != nil { + // assume secret isn't base32 encoded + key = []byte(secret) + } + otp := NewTOTP(key, 0, period, digits, algo) + return otp, label, nil +} + +// QR generates a new TOTP QR code. +func (otp *TOTP) QR(label string) ([]byte, error) { + return otp.OATH.QR(otp.Type(), label) +} + +func SetClock(c clock.Clock) { + timeSource = c +} diff --git a/twofactor/totp_test.go b/twofactor/totp_test.go new file mode 100644 index 0000000..ef5628f --- /dev/null +++ b/twofactor/totp_test.go @@ -0,0 +1,87 @@ +package twofactor + +import ( + "crypto" + "fmt" + "testing" + "time" + + "github.com/benbjohnson/clock" +) + +var rfcTotpKey = map[crypto.Hash][]byte{ + crypto.SHA1: []byte("12345678901234567890"), + crypto.SHA256: []byte("12345678901234567890123456789012"), + crypto.SHA512: []byte("1234567890123456789012345678901234567890123456789012345678901234"), +} +var rfcTotpStep uint64 = 30 + +var rfcTotpTests = []struct { + Time uint64 + Code string + T uint64 + Algo crypto.Hash +}{ + {59, "94287082", 1, crypto.SHA1}, + {59, "46119246", 1, crypto.SHA256}, + {59, "90693936", 1, crypto.SHA512}, + {1111111109, "07081804", 37037036, crypto.SHA1}, + {1111111109, "68084774", 37037036, crypto.SHA256}, + {1111111109, "25091201", 37037036, crypto.SHA512}, + {1111111111, "14050471", 37037037, crypto.SHA1}, + {1111111111, "67062674", 37037037, crypto.SHA256}, + {1111111111, "99943326", 37037037, crypto.SHA512}, + {1234567890, "89005924", 41152263, crypto.SHA1}, + {1234567890, "91819424", 41152263, crypto.SHA256}, + {1234567890, "93441116", 41152263, crypto.SHA512}, + {2000000000, "69279037", 66666666, crypto.SHA1}, + {2000000000, "90698825", 66666666, crypto.SHA256}, + {2000000000, "38618901", 66666666, crypto.SHA512}, + {20000000000, "65353130", 666666666, crypto.SHA1}, + {20000000000, "77737706", 666666666, crypto.SHA256}, + {20000000000, "47863826", 666666666, crypto.SHA512}, +} + +func TestTotpRFC(t *testing.T) { + for _, tc := range rfcTotpTests { + otp := NewTOTP(rfcTotpKey[tc.Algo], 0, rfcTotpStep, 8, tc.Algo) + if otp.otpCounter(tc.Time) != tc.T { + fmt.Printf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo) + fmt.Printf("\texpected: %d\n", tc.T) + fmt.Printf("\t actual: %d\n", otp.otpCounter(tc.Time)) + t.Fail() + } + + if code := otp.otp(otp.otpCounter(tc.Time)); code != tc.Code { + fmt.Printf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo) + fmt.Printf("\texpected: %s\n", tc.Code) + fmt.Printf("\t actual: %s\n", code) + t.Fail() + } + } +} + +func TestTOTPTime(t *testing.T) { + otp := GenerateGoogleTOTP() + + testClock := clock.NewMock() + testClock.Add(2 * time.Minute) + SetClock(testClock) + + code := otp.OTP() + + testClock.Add(-1 * time.Minute) + if newCode := otp.OTP(); newCode == code { + t.Errorf("twofactor: TOTP: previous code %s shouldn't match code %s", newCode, code) + } + + testClock.Add(2 * time.Minute) + if newCode := otp.OTP(); newCode == code { + t.Errorf("twofactor: TOTP: future code %s shouldn't match code %s", newCode, code) + } + + testClock.Add(-1 * time.Minute) + if newCode := otp.OTP(); newCode != code { + t.Errorf("twofactor: TOTP: current code %s shouldn't match code %s", newCode, code) + } +} diff --git a/twofactor/util.go b/twofactor/util.go new file mode 100644 index 0000000..af15c0f --- /dev/null +++ b/twofactor/util.go @@ -0,0 +1,16 @@ +package twofactor + +import ( + "strings" +) + +// Pad calculates the number of '='s to add to our encoded string +// to make base32.StdEncoding.DecodeString happy +func Pad(s string) string { + if !strings.HasSuffix(s, "=") && len(s)%8 != 0 { + for len(s)%8 != 0 { + s += "=" + } + } + return s +} diff --git a/twofactor/util_test.go b/twofactor/util_test.go new file mode 100644 index 0000000..a4eee6b --- /dev/null +++ b/twofactor/util_test.go @@ -0,0 +1,53 @@ +package twofactor + +import ( + "encoding/base32" + "fmt" + "math/rand" + "strings" + "testing" +) + +const letters = "1234567890!@#$%^&*()abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func randString() string { + b := make([]byte, rand.Intn(len(letters))) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return base32.StdEncoding.EncodeToString(b) +} + +func TestPadding(t *testing.T) { + for i := 0; i < 300; i++ { + b := randString() + origEncoding := string(b) + modEncoding := strings.ReplaceAll(string(b), "=", "") + str, err := base32.StdEncoding.DecodeString(origEncoding) + if err != nil { + fmt.Println("Can't decode: ", string(b)) + t.FailNow() + } + + paddedEncoding := Pad(modEncoding) + if origEncoding != paddedEncoding { + fmt.Println("Padding failed:") + fmt.Printf("Expected: '%s'", origEncoding) + fmt.Printf("Got: '%s'", paddedEncoding) + t.FailNow() + } else { + mstr, err := base32.StdEncoding.DecodeString(paddedEncoding) + if err != nil { + fmt.Println("Can't decode: ", paddedEncoding) + t.FailNow() + } + + if string(mstr) != string(str) { + fmt.Println("Re-padding failed:") + fmt.Printf("Expected: '%s'", str) + fmt.Printf("Got: '%s'", mstr) + t.FailNow() + } + } + } +}