From a1c16222af629e8fa258ecf93e354e5aa720baf2 Mon Sep 17 00:00:00 2001 From: KEINOS Date: Tue, 6 Sep 2022 21:05:15 +0900 Subject: [PATCH] feat: QR Code image support (issue #3) --- README.md | 18 +++-- go.mod | 1 + go.sum | 2 + totp/{key_algorithm.go => algorithm.go} | 0 ...ey_algorithm_test.go => algorithm_test.go} | 0 totp/{key_digits.go => digits.go} | 0 totp/example_test.go | 66 +++++++++++++++++ totp/fix_level.go | 39 ++++++++++ totp/key.go | 17 ++++- totp/key_test.go | 17 +++++ totp/{key_options.go => options.go} | 0 totp/qrcode.go | 59 +++++++++++++++ totp/qrcode_test.go | 73 +++++++++++++++++++ totp/{key_secret.go => secret.go} | 0 totp/{key_secret_test.go => secret_test.go} | 0 totp/{key_uri.go => uri.go} | 0 totp/{key_uri_test.go => uri_test.go} | 16 ++-- 17 files changed, 293 insertions(+), 15 deletions(-) rename totp/{key_algorithm.go => algorithm.go} (100%) rename totp/{key_algorithm_test.go => algorithm_test.go} (100%) rename totp/{key_digits.go => digits.go} (100%) create mode 100644 totp/fix_level.go rename totp/{key_options.go => options.go} (100%) create mode 100644 totp/qrcode.go create mode 100644 totp/qrcode_test.go rename totp/{key_secret.go => secret.go} (100%) rename totp/{key_secret_test.go => secret_test.go} (100%) rename totp/{key_uri.go => uri.go} (100%) rename totp/{key_uri_test.go => uri_test.go} (94%) diff --git a/README.md b/README.md index 3eb7ec4..3feb5e7 100644 --- a/README.md +++ b/README.md @@ -54,22 +54,27 @@ func Example() { // Basic methods of totp.Key object // -------------------------------------------------- -// Generate the current passcode +// Generate the current passcode. passcode, err := key.PassCode() -// Validate the received passcode +// Validate the received passcode. ok, err := key.Validate(passcode) -// Get the secret key in PEM format +// Get 100x100 px image of QR code as PNG byte data. +// FixLevelDefault is the 15% of error correction. +qrCodeObj, err := key.QRCode(totp.FixLevelDefault) +qrCodePNG, err := qrCodeObj.PNG(100, 100) + +// Get the secret key in PEM format text. pemKey, err := key.PEM() -// Get the secret key in TOTP URI format +// Get the secret key in TOTP URI format string. uriKey := key.URI() -// Get the secret value in Base32 format +// Get the secret value in Base32 format string. base32Key := key.Secret.Base32() -// Get the secret value in Base62 format +// Get the secret value in Base62 format string. base62Key := key.Secret.Base62() ``` @@ -95,6 +100,7 @@ base62Key := key.Secret.Base62() Any Pull-Request for improvement is welcome! - Branch to PR: `main` +- [CONTRIBUTING.md](./.github/CONTRIBUTING.md) - [CIs](https://github.com/KEINOS/go-totp/actions) on PR/Push: `unit-tests` `golangci-lint` `codeQL-analysis` `platform-tests` - [Security policy](https://github.com/KEINOS/go-totp/security/policy) diff --git a/go.mod b/go.mod index 2b20c0f..bdd1088 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/KEINOS/go-totp go 1.15 require ( + github.com/boombuler/barcode v1.0.1 // indirect github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.3.0 github.com/stretchr/testify v1.8.0 diff --git a/go.sum b/go.sum index d738100..4933959 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/totp/key_algorithm.go b/totp/algorithm.go similarity index 100% rename from totp/key_algorithm.go rename to totp/algorithm.go diff --git a/totp/key_algorithm_test.go b/totp/algorithm_test.go similarity index 100% rename from totp/key_algorithm_test.go rename to totp/algorithm_test.go diff --git a/totp/key_digits.go b/totp/digits.go similarity index 100% rename from totp/key_digits.go rename to totp/digits.go diff --git a/totp/example_test.go b/totp/example_test.go index 6d15f4a..75db121 100644 --- a/totp/example_test.go +++ b/totp/example_test.go @@ -234,6 +234,72 @@ func ExampleKey() { // AccountName: alice@example.com } +//nolint:funlen // length is 62 lines long but leave it as is due to embedded example +func ExampleKey_QRCode() { + origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + + "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" + + // Create a new Key object from a URI + key, err := totp.GenerateKeyURI(origin) + if err != nil { + log.Fatal(err) + } + + // Create QRCode object + imgQRCode, err := key.QRCode(totp.FixLevelDefault) + if err != nil { + log.Fatal(err) + } + + // Get PNG image in bytes + pngImage, err := imgQRCode.PNG(100, 100) + if err != nil { + log.Fatal(err) + } + + actual := fmt.Sprintf("%x", pngImage) + expect := `89504e470d0a1a0a0000000d4948445200000064000000641000000000051` + + `916cb0000040549444154789ce45ced6edb400c9387bcff2b67180a2f324d5272ff` + + `95d59f35f7691f4f12a9047bbddf1561afaae3d0dde76bf631bdeddfdffd5f370fd` + + `7c5f99b7df473fef1eff973ecf5f50fbb60ec74b01dfbce93eba7cce6633fae778e` + + `617dfc39d310a90101e503bdbff7f55357779ba1727e763ec69f231191c9f094ce1` + + `3c5c88363b17f4217db7709e23722c20c917077daa1721aa2be4fd789886cdebe9f` + + `6e3796a927b4703f8c722ad7f0e74c43c4f198d3d489bb2c5dc28fa6f90a19fd9c4` + + `9883c21f2d33d7ec68fb83994b4c52072bcdf8e1339edd1e76c4f7d42df4536bf5e` + + `1a2235a8b6291f4c116c52932c32b13d35a28988d4a033f054a6d355e3e5a318e49` + + `53f7eda9210f9ffa7f095d3143762f3a7317d4d75f2ac4d71b83c443668a87c33f1` + + `afefa08aa67ceef339061150882ebef77665937e983489da57a1f1b12444b675277` + + `7f771ceac1f7ccd6bc316aef5b32444746cf6a75ea6eac1906111ab4424ab07dc2f` + + `2f6a3da9064eda5ef988d335389fadc9d6ba5a0c22c7f5fd9c9ea8413b383e44b71` + + `e1057be837d79510b4dc56f15a9b67fe33ab8966b57d59bcffa31881085e878ce94` + + `6f9efa85426ce387578b41c47c3fc232b8d21e7d8eb29e5f7a5b01b25b34aee8262` + + `1a2388dd3112ab239a5c8d639cd2947e74b15f93dfbe6edcb54fe948a448ec4d6c1` + + `f570bee263773f4942a408b364a7dfc74cd590d3b61a67529fb8ce7dbf2444d4292` + + `8fea3fa3677ba067ee5f290afac2421b2b9df657247895f3738fec5c6d7c0a7fc33` + + `2621c24cc575e507cecfd867c59291b7b95ac115bd34442625876dea2ebb0c8fa7e` + + `b7899aa9c689f4a4264d20865eeee5431615ac5b16336cead9b19b55cd564527c4e` + + `6fa87bddc72aff62151b857a586637954616955c1564f22d77baca7fdc3e95f93df` + + `be02365f4b33aedd354b545b5b1bdd83adc921029f1d6ea2ebbaa8932a5f19d8264` + + `63b5928d4184fc824e2941759fb7999d459f890de33e257d2c0691d7acbd550c67a` + + `69800e366ee84fbbc69cf2f4b42a408ffaf21533b1dcea2df661eeea9d6e53e1983` + + `c8717f371769b0ed0903c6cf133a684abf87b15f52fb75bc0bc73afdcdc697d1e3b` + + `8a68aa01c993444ca5416ddbd76f7d621d7f798b4067e663e1c94d9e157a6b560a4` + + `45eeaa53747d5d954fb62c5a5b1222b31edee9f05a565ea63536ebe0b83c1fb1430` + + `ca3ddf84e5fc75544702fa791ea7653621059fc9f0f4f3484e24f8a2594f9b6d865` + + `f5fbb82444eac1f77c5d13e058cf4e791f8e719ccd7b731a22356873773fd538869` + + `ccb190e15c5bb2a568f4ca62257b72d3b9ea2e471785fc1bd33a3d664131a38ae8c` + + `26a9bafb113b75c584d197c27ca4d57e6f5ddfc80d6abc9a37712dd6c77d330d116` + + `51839d4bd773ae3b29dc8d893f6708ab2b2aaf10b3df233ec6f000000ffffa0404e` + + `fb1e0ab59a0000000049454e44ae426082` + + // Assert equal image + if expect == actual { + fmt.Println("OK") + } + + // Output: OK +} + func ExampleKey_PEM() { origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" diff --git a/totp/fix_level.go b/totp/fix_level.go new file mode 100644 index 0000000..cb8874f --- /dev/null +++ b/totp/fix_level.go @@ -0,0 +1,39 @@ +package totp + +import "github.com/boombuler/barcode/qr" + +// FixLevel is the error correction level for QR code. Use `FixLevel*` constants +// to set the level. +type FixLevel byte + +// Error correction level for QR code. +const ( + // FixLevel30 is the highest level of error correction for QR codes, capable + // of recovering 30% of the data. + FixLevel30 = FixLevel(qr.H) + // FixLevel25 is a qualified error correction level for QR codes, which can + // recover 25% of the data. + FixLevel25 = FixLevel(qr.Q) + // FixLevel15 is a medium error correction level for QR codes, capable of + // recovering 15% of the data. + FixLevel15 = FixLevel(qr.M) + // FixLevel7 is the lowest level of error correction for QR codes and can + // recover 7% of the data. + FixLevel7 = FixLevel(qr.L) + // FixLevelDefault is the default error correction level for QR codes. + // Currently set to FixLevel15. + FixLevelDefault = FixLevel15 +) + +func (f FixLevel) qrFixLevel() qr.ErrorCorrectionLevel { + return qr.ErrorCorrectionLevel(f) +} + +func (f FixLevel) isValid() bool { + switch f { + case FixLevel30, FixLevel25, FixLevel15, FixLevel7: + return true + default: + return false + } +} diff --git a/totp/key.go b/totp/key.go index 44c6e75..074a3ba 100644 --- a/totp/key.go +++ b/totp/key.go @@ -19,8 +19,8 @@ const BlockTypeTOTP = "TOTP SECRET KEY" // Key is a struct that holds the TOTP secret and its options. type Key struct { - Options Options // Options to be stored. Secret Secret // The secret key. + Options Options // Options to be stored. } // ---------------------------------------------------------------------------- @@ -186,6 +186,21 @@ func (k *Key) PEM() (string, error) { return string(out), nil } +// QRCode returns a QR code image of a specified width and height, suitable for +// registering a user's TOTP URI with many clients, such as Google-Authenticator. +func (k *Key) QRCode(fixLevel FixLevel) (*QRCode, error) { + if !fixLevel.isValid() { + return nil, errors.Errorf("unsupported fix level: %v", fixLevel) + } + + qrCode := &QRCode{ + URI: URI(k.URI()), + Level: fixLevel, + } + + return qrCode, nil +} + // URI returns the key in OTP URI format. // // It re-generates the URI from the values stored in the Key object and will not diff --git a/totp/key_test.go b/totp/key_test.go index d00c5a1..4b21992 100644 --- a/totp/key_test.go +++ b/totp/key_test.go @@ -167,6 +167,23 @@ func TestGenerateKeyURI_error_msg(t *testing.T) { require.Contains(t, err.Error(), "failed to generate key") } +// ---------------------------------------------------------------------------- +// Key.QRCode() +// ---------------------------------------------------------------------------- + +func TestKey_QRCode_bad_fix_level(t *testing.T) { + t.Parallel() + + //nolint:exhaustruct // missing fields are not required for this test + key := Key{} + + imgQRCode, err := key.QRCode(FixLevel(100)) + + require.Error(t, err, "unsupported fix level should return error") + require.Contains(t, err.Error(), "unsupported fix level: 100") + require.Nil(t, imgQRCode) +} + // ---------------------------------------------------------------------------- // Key.PEM() // ---------------------------------------------------------------------------- diff --git a/totp/key_options.go b/totp/options.go similarity index 100% rename from totp/key_options.go rename to totp/options.go diff --git a/totp/qrcode.go b/totp/qrcode.go new file mode 100644 index 0000000..63b2a29 --- /dev/null +++ b/totp/qrcode.go @@ -0,0 +1,59 @@ +package totp + +import ( + "bytes" + "image" + "image/png" + + "github.com/boombuler/barcode" + "github.com/boombuler/barcode/qr" + "github.com/pkg/errors" +) + +// QRCode is a struct that holds the information to create QR code image. +type QRCode struct { + URI URI // URI object to be encoded to QR code image. + Level FixLevel // Level is the error correction level for the QR code. +} + +// Image returns an image.Image object of the QR code. Minimum width and height +// is 49x49. +func (q *QRCode) Image(width, height int) (image.Image, error) { + uri := q.URI.String() + + qrCode, err := qr.Encode(uri, q.Level.qrFixLevel(), qr.Auto) + if err != nil || uri == "" { + if uri == "" { + err = errors.New("empty URI") + } + + return nil, errors.Wrap(err, "failed to encode URI to QR code") + } + + qrCode, err = barcode.Scale(qrCode, width, height) + if err != nil { + return nil, errors.Wrap(err, "failed to scale QR code") + } + + return qrCode, nil +} + +//nolint:gochecknoglobals // allow private global variable to mock during tests +var pngEncode = png.Encode + +// PNG returns a PNG image of the QR code in bytes. Minimum width and height +// is 49x49. +func (q *QRCode) PNG(width, height int) ([]byte, error) { + img, err := q.Image(width, height) + if err != nil { + return nil, errors.Wrap(err, "failed to generate QR code PNG image") + } + + var buf bytes.Buffer + + if err := pngEncode(&buf, img); err != nil { + return nil, errors.Wrap(err, "failed to encode QR code image to PNG") + } + + return buf.Bytes(), nil +} diff --git a/totp/qrcode_test.go b/totp/qrcode_test.go new file mode 100644 index 0000000..50c4475 --- /dev/null +++ b/totp/qrcode_test.go @@ -0,0 +1,73 @@ +package totp + +import ( + "image" + "io" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestQRCode_PNG_empty_uri(t *testing.T) { + t.Parallel() + + qr := QRCode{ + URI: URI(""), + Level: FixLevelDefault, + } + + img, err := qr.PNG(100, 100) + + require.Error(t, err, "empty URI should return error") + require.Contains(t, err.Error(), "failed to encode URI to QR code: empty URI") + require.Nil(t, img, "it should be nil on error") +} + +//nolint:paralleltest // disable parallel test due to monkey patching during test +func TestQRCode_PNG_fail_encoding(t *testing.T) { + origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + + "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" + + qrCode := QRCode{ + URI: URI(origin), + Level: FixLevelDefault, + } + + // Backup and defer restore + oldPNGEncode := pngEncode + defer func() { + pngEncode = oldPNGEncode + }() + + // Mock pngEncode to force return error + pngEncode = func(w io.Writer, m image.Image) error { + return errors.New("forced error") + } + + img, err := qrCode.PNG(100, 100) + + require.Error(t, err, "failed to encode QR code to PNG should return error") + require.Contains(t, err.Error(), "failed to encode QR code image to PNG") + require.Contains(t, err.Error(), "forced error") + require.Nil(t, img, "it should be nil on error") +} + +func TestQRCode_Image_failed_to_scale(t *testing.T) { + t.Parallel() + + origin := "otpauth://totp/Example.com:alice@example.com?algorithm=SHA1&" + + "digits=6&issuer=Example.com&period=30&secret=QF7N673VMVHYWATKICRUA7V5MUGFG3Z3" + + qr := QRCode{ + URI: URI(origin), + Level: FixLevelDefault, + } + + img, err := qr.Image(0, 0) + + require.Error(t, err, "failed to scale QR code should return error") + require.Contains(t, err.Error(), "failed to scale QR code") + require.Contains(t, err.Error(), "can not scale barcode to an image smaller than 49x49") + require.Nil(t, img, "it should be nil on error") +} diff --git a/totp/key_secret.go b/totp/secret.go similarity index 100% rename from totp/key_secret.go rename to totp/secret.go diff --git a/totp/key_secret_test.go b/totp/secret_test.go similarity index 100% rename from totp/key_secret_test.go rename to totp/secret_test.go diff --git a/totp/key_uri.go b/totp/uri.go similarity index 100% rename from totp/key_uri.go rename to totp/uri.go diff --git a/totp/key_uri_test.go b/totp/uri_test.go similarity index 94% rename from totp/key_uri_test.go rename to totp/uri_test.go index a2d8f34..04a5ac0 100644 --- a/totp/key_uri_test.go +++ b/totp/uri_test.go @@ -97,16 +97,16 @@ func TestURI_malformed_uri(t *testing.T) { // Methods should return empty string as well for _, test := range []struct { - name string function func() string + name string }{ - {"Scheme", uri.Scheme}, - {"Host", uri.Host}, - {"Issuer", uri.Issuer}, - {"AccountName", uri.AccountName}, - {"Secret", uri.Secret().String}, - {"Algorithm", uri.Algorithm}, - {"IssuerFromPath", uri.IssuerFromPath}, + {name: "Scheme", function: uri.Scheme}, + {name: "Host", function: uri.Host}, + {name: "Issuer", function: uri.Issuer}, + {name: "AccountName", function: uri.AccountName}, + {name: "Secret", function: uri.Secret().String}, + {name: "Algorithm", function: uri.Algorithm}, + {name: "IssuerFromPath", function: uri.IssuerFromPath}, } { got := test.function()