commit dbbd5116b5c8f06cd0f169416e45d4b328c04589 Author: Kyle Isom Date: Wed Dec 18 21:48:14 2013 -0700 Initial import. Basic HOTP functionality. diff --git a/hotp.go b/hotp.go new file mode 100644 index 0000000..81bf5b0 --- /dev/null +++ b/hotp.go @@ -0,0 +1,40 @@ +package twofactor + +import ( + "crypto" + "crypto/sha1" +) + +type HOTP struct { + *oath +} + +func (otp *HOTP) Type() Type { + return OATH_HOTP +} + +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, + }, + } +} + +func (otp *HOTP) OTP() string { + code := otp.oath.OTP(otp.counter) + otp.counter++ + return code +} + +func (otp *HOTP) URL(label string) string { + return otp.oath.URL(otp.Type(), label) +} + +func (otp *HOTP) SetProvider(provider string) { + otp.provider = provider +} diff --git a/hotp_test.go b/hotp_test.go new file mode 100644 index 0000000..81632da --- /dev/null +++ b/hotp_test.go @@ -0,0 +1,91 @@ +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} + +func newZeroHOTP() *HOTP { + return NewHOTP(testKey, 0, 6) +} + +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() + } +} + +var rfcKey = []byte("12345678901234567890") +var rfcExpected = []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 TestRFC(t *testing.T) { + otp := NewHOTP(rfcKey, 0, 6) + for i := 0; i < len(rfcExpected); i++ { + if otp.Counter() != uint64(i) { + fmt.Printf("hotp: invalid counter (should be %d, is %d", + i, otp.Counter()) + t.FailNow() + } + code := otp.OTP() + if code == "" { + fmt.Printf("hotp: failed to produce an OTP\n") + t.FailNow() + } else if code != rfcExpected[i] { + fmt.Printf("hotp: invalid OTP\n") + fmt.Printf("\tExpected: %s\n", rfcExpected[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 TestBadRFC(t *testing.T) { + otp := NewHOTP(testKey, 0, 6) + for i := 0; i < len(rfcExpected); i++ { + code := otp.OTP() + if code == "" { + fmt.Printf("hotp: failed to produce an OTP\n") + t.FailNow() + } else if code == rfcExpected[i] { + fmt.Printf("hotp: should not have received a valid OTP\n") + t.FailNow() + } + } +} diff --git a/oath.go b/oath.go new file mode 100644 index 0000000..a185056 --- /dev/null +++ b/oath.go @@ -0,0 +1,135 @@ +package twofactor + +import ( + "crypto" + "crypto/hmac" + "encoding/base32" + "encoding/binary" + "fmt" + "hash" + "net/url" +) + +const defaultSize = 6 + +// oath provides a baseline struct for the two OATH algorithms. +type oath struct { + key []byte + counter uint64 + size int + hash func() hash.Hash + algo crypto.Hash + provider string +} + +// 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 +} + +func (o oath) Size() int { + return o.size +} + +func (o oath) Counter() uint64 { + return o.counter +} + +func (o oath) SetCounter(counter uint64) { + o.counter = counter +} + +func (o oath) Key() []byte { + return o.key[:] +} + +func (o oath) Hash() func() hash.Hash { + return o.hash +} + +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 { + case o.algo == crypto.SHA256: + v.Add("algorithm", "SHA256") + case o.algo == crypto.SHA512: + v.Add("algorithm", "SHA512") + } + + if o.provider != "" { + v.Add("provider", o.provider) + } + + u.RawQuery = v.Encode() + return u.String() + +} + +func (o oath) QR(label string) ([]byte, error) { + return nil, nil +} + +var digits = []int{ + 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 int = 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)) + dt = dt % int64(mod) + fmtStr := fmt.Sprintf("%%%dd", o.size) + return fmt.Sprintf(fmtStr, dt) +} diff --git a/otp.go b/otp.go new file mode 100644 index 0000000..2453fb9 --- /dev/null +++ b/otp.go @@ -0,0 +1,61 @@ +package twofactor + +import ( + "crypto/rand" + "fmt" + "hash" +) + +type Type uint + +const ( + OATH_HOTP = iota + OATH_TOTP +) + +var PRNG = rand.Reader + +// 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 + + // URL generates a Google Authenticator url (or perhaps some other url) + URL(string) string + + // QR outputs a byte slice containing a PNG-encoded QR code + // of the URL. + QR(string) ([]byte, error) + + // 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" + } + return fmt.Sprintf("%s, %d", typeName, otp.Size()) +} diff --git a/otp_test.go b/otp_test.go new file mode 100644 index 0000000..93c693f --- /dev/null +++ b/otp_test.go @@ -0,0 +1,13 @@ +package twofactor + +import "fmt" +import "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() + } +}