diff --git a/hotp.go b/hotp.go index 81bf5b0..a643338 100644 --- a/hotp.go +++ b/hotp.go @@ -3,6 +3,10 @@ package twofactor import ( "crypto" "crypto/sha1" + "encoding/base32" + "io" + "net/url" + "strconv" ) type HOTP struct { @@ -38,3 +42,50 @@ func (otp *HOTP) URL(label string) string { func (otp *HOTP) SetProvider(provider string) { otp.provider = provider } + +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 := 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(secret) + if err != nil { + return nil, "", err + } + otp := NewHOTP(key, counter, digits) + return otp, label, nil +} + +func (otp *HOTP) QR(label string) ([]byte, error) { + return otp.oath.QR(otp.Type(), label) +} diff --git a/hotp_test.go b/hotp_test.go index 81632da..09e0f6a 100644 --- a/hotp_test.go +++ b/hotp_test.go @@ -11,32 +11,8 @@ 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{ +var rfcHotpKey = []byte("12345678901234567890") +var rfcHotpExpected = []string{ "755224", "287082", "359152", @@ -51,21 +27,21 @@ var rfcExpected = []string{ // 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++ { +func TestHotpRFC(t *testing.T) { + otp := NewHOTP(rfcHotpKey, 0, 6) + for i := 0; i < len(rfcHotpExpected); i++ { if otp.Counter() != uint64(i) { - fmt.Printf("hotp: invalid counter (should be %d, is %d", + fmt.Printf("twofactor: 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") + fmt.Printf("twofactor: 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]) + } 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() @@ -76,15 +52,15 @@ func TestRFC(t *testing.T) { // 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) { +func TestHotpBadRFC(t *testing.T) { otp := NewHOTP(testKey, 0, 6) - for i := 0; i < len(rfcExpected); i++ { + for i := 0; i < len(rfcHotpExpected); i++ { code := otp.OTP() if code == "" { - fmt.Printf("hotp: failed to produce an OTP\n") + fmt.Printf("twofactor: failed to produce an OTP\n") t.FailNow() - } else if code == rfcExpected[i] { - fmt.Printf("hotp: should not have received a valid OTP\n") + } else if code == rfcHotpExpected[i] { + fmt.Printf("twofactor: should not have received a valid OTP\n") t.FailNow() } } diff --git a/oath.go b/oath.go index a185056..b50ebb3 100644 --- a/oath.go +++ b/oath.go @@ -1,6 +1,7 @@ package twofactor import ( + "code.google.com/p/rsc/qr" "crypto" "crypto/hmac" "encoding/base32" @@ -22,20 +23,6 @@ type oath struct { 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 } @@ -92,11 +79,7 @@ func (o oath) URL(t Type, label string) string { } -func (o oath) QR(label string) ([]byte, error) { - return nil, nil -} - -var digits = []int{ +var digits = []int64{ 0: 1, 1: 10, 2: 100, @@ -117,7 +100,7 @@ func (o oath) OTP(counter uint64) string { var ctr [8]byte binary.BigEndian.PutUint64(ctr[:], counter) - var mod int = 1 + var mod int64 = 1 if len(digits) > o.size { for i := 1; i <= o.size; i++ { mod *= 10 @@ -128,8 +111,32 @@ func (o oath) OTP(counter uint64) string { 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) + 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/otp.go b/otp.go index 2453fb9..d8fcf28 100644 --- a/otp.go +++ b/otp.go @@ -2,8 +2,10 @@ package twofactor import ( "crypto/rand" + "errors" "fmt" "hash" + "net/url" ) type Type uint @@ -13,8 +15,15 @@ const ( 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). @@ -59,3 +68,23 @@ func OTPString(otp OTP) string { } return fmt.Sprintf("%s, %d", typeName, otp.Size()) } + +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 { + case u.Host == "totp": + return totpFromURL(u) + case u.Host == "hotp": + return hotpFromURL(u) + default: + return nil, "", ErrInvalidURL + } +} diff --git a/otp_test.go b/otp_test.go index 93c693f..57643cf 100644 --- a/otp_test.go +++ b/otp_test.go @@ -1,6 +1,7 @@ package twofactor import "fmt" +import "io" import "testing" func TestHOTPString(t *testing.T) { @@ -11,3 +12,86 @@ func TestHOTPString(t *testing.T) { 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 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/secret=bar", + "otpauth://hotp/?secret=QUJDRA&algorithm=SHA256", + "otpauth://hotp/?digits=", + "otpauth://hotp/?secret=123", + "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() + } + } +}