Skip to content

Commit 87d222c

Browse files
authored
Fix URL query encoding (pquerna#78)
* Fix typos * Fix encoding spaces in issuer name
1 parent 45fc76e commit 87d222c

File tree

7 files changed

+63
-13
lines changed

7 files changed

+63
-13
lines changed

doc.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// one time passcodes in a Google Authenticator compatible manner.
2020
//
2121
// When adding a TOTP for a user, you must store the "secret" value
22-
// persistently. It is recommend to store the secret in an encrypted field in your
22+
// persistently. It is recommended to store the secret in an encrypted field in your
2323
// datastore. Due to how TOTP works, it is not possible to store a hash
2424
// for the secret value like you would a password.
2525
//
@@ -57,6 +57,7 @@
5757
//
5858
// Validating a TOTP passcode is very easy, just prompt the user for a passcode
5959
// and retrieve the associated user's previously stored secret.
60+
//
6061
// import "github.com/pquerna/otp/totp"
6162
//
6263
// passcode := promptForPasscode()

go.sum

-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
github.com/boombuler/barcode v1.0.0 h1:s1TvRnXwL2xJRaccrdcBQMZxq6X7DvsMogtmJeHDdrc=
2-
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
31
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
42
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
53
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=

hotp/hotp.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package hotp
1919

2020
import (
2121
"github.com/pquerna/otp"
22+
"github.com/pquerna/otp/internal"
2223
"io"
2324

2425
"crypto/hmac"
@@ -186,7 +187,7 @@ func Generate(opts GenerateOpts) (*otp.Key, error) {
186187
opts.Rand = rand.Reader
187188
}
188189

189-
// otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example
190+
// otpauth://hotp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example
190191

191192
v := url.Values{}
192193
if len(opts.Secret) != 0 {
@@ -208,7 +209,7 @@ func Generate(opts GenerateOpts) (*otp.Key, error) {
208209
Scheme: "otpauth",
209210
Host: "hotp",
210211
Path: "/" + opts.Issuer + ":" + opts.AccountName,
211-
RawQuery: v.Encode(),
212+
RawQuery: internal.EncodeQuery(v),
212213
}
213214

214215
return otp.NewKeyFromURL(u.String())

hotp/hotp_test.go

+12-5
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,24 @@ func TestGenerate(t *testing.T) {
148148
Issuer: "SnakeOil",
149149
AccountName: "[email protected]",
150150
})
151-
require.NoError(t, err, "generate basic TOTP")
151+
require.NoError(t, err, "generate basic HOTP")
152152
require.Equal(t, "SnakeOil", k.Issuer(), "Extracting Issuer")
153153
require.Equal(t, "[email protected]", k.AccountName(), "Extracting Account Name")
154154
require.Equal(t, 16, len(k.Secret()), "Secret is 16 bytes long as base32.")
155155

156+
k, err = Generate(GenerateOpts{
157+
Issuer: "Snake Oil",
158+
AccountName: "[email protected]",
159+
})
160+
require.NoError(t, err, "issuer with a space in the name")
161+
require.Contains(t, k.String(), "issuer=Snake%20Oil")
162+
156163
k, err = Generate(GenerateOpts{
157164
Issuer: "SnakeOil",
158165
AccountName: "[email protected]",
159166
SecretSize: 20,
160167
})
161-
require.NoError(t, err, "generate larger TOTP")
168+
require.NoError(t, err, "generate larger HOTP")
162169
require.Equal(t, 32, len(k.Secret()), "Secret is 32 bytes long as base32.")
163170

164171
k, err = Generate(GenerateOpts{
@@ -178,9 +185,9 @@ func TestGenerate(t *testing.T) {
178185
k, err = Generate(GenerateOpts{
179186
Issuer: "SnakeOil",
180187
AccountName: "[email protected]",
181-
SecretSize: 17, // anything that is not divisable by 5, really
188+
SecretSize: 17, // anything that is not divisible by 5, really
182189
})
183-
require.NoError(t, err, "Secret size is valid when length not divisable by 5.")
190+
require.NoError(t, err, "Secret size is valid when length not divisible by 5.")
184191
require.NotContains(t, k.Secret(), "=", "Secret has no escaped characters.")
185192

186193
k, err = Generate(GenerateOpts{
@@ -190,6 +197,6 @@ func TestGenerate(t *testing.T) {
190197
})
191198
require.NoError(t, err, "Secret generation failed")
192199
sec, err := b32NoPadding.DecodeString(k.Secret())
193-
require.NoError(t, err, "Secret wa not valid base32")
200+
require.NoError(t, err, "Secret was not valid base32")
194201
require.Equal(t, sec, []byte("helloworld"), "Specified Secret was not kept")
195202
}

internal/encode.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package internal
2+
3+
import (
4+
"net/url"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// EncodeQuery is a copy-paste of url.Values.Encode, except it uses %20 instead
10+
// of + to encode spaces. This is necessary to correctly render spaces in some
11+
// authenticator apps, like Google Authenticator.
12+
func EncodeQuery(v url.Values) string {
13+
if v == nil {
14+
return ""
15+
}
16+
var buf strings.Builder
17+
keys := make([]string, 0, len(v))
18+
for k := range v {
19+
keys = append(keys, k)
20+
}
21+
sort.Strings(keys)
22+
for _, k := range keys {
23+
vs := v[k]
24+
keyEscaped := url.PathEscape(k) // changed from url.QueryEscape
25+
for _, v := range vs {
26+
if buf.Len() > 0 {
27+
buf.WriteByte('&')
28+
}
29+
buf.WriteString(keyEscaped)
30+
buf.WriteByte('=')
31+
buf.WriteString(url.PathEscape(v)) // changed from url.QueryEscape
32+
}
33+
}
34+
return buf.String()
35+
}

totp/totp.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package totp
2020
import (
2121
"github.com/pquerna/otp"
2222
"github.com/pquerna/otp/hotp"
23+
"github.com/pquerna/otp/internal"
2324
"io"
2425

2526
"crypto/rand"
@@ -199,7 +200,7 @@ func Generate(opts GenerateOpts) (*otp.Key, error) {
199200
Scheme: "otpauth",
200201
Host: "totp",
201202
Path: "/" + opts.Issuer + ":" + opts.AccountName,
202-
RawQuery: v.Encode(),
203+
RawQuery: internal.EncodeQuery(v),
203204
}
204205

205206
return otp.NewKeyFromURL(u.String())

totp/totp_test.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ func TestGenerate(t *testing.T) {
128128
require.Equal(t, "[email protected]", k.AccountName(), "Extracting Account Name")
129129
require.Equal(t, 32, len(k.Secret()), "Secret is 32 bytes long as base32.")
130130

131+
k, err = Generate(GenerateOpts{
132+
Issuer: "Snake Oil",
133+
AccountName: "[email protected]",
134+
})
135+
require.NoError(t, err, "issuer with a space in the name")
136+
require.Contains(t, k.String(), "issuer=Snake%20Oil")
137+
131138
k, err = Generate(GenerateOpts{
132139
Issuer: "SnakeOil",
133140
AccountName: "[email protected]",
@@ -139,9 +146,9 @@ func TestGenerate(t *testing.T) {
139146
k, err = Generate(GenerateOpts{
140147
Issuer: "SnakeOil",
141148
AccountName: "[email protected]",
142-
SecretSize: 13, // anything that is not divisable by 5, really
149+
SecretSize: 13, // anything that is not divisible by 5, really
143150
})
144-
require.NoError(t, err, "Secret size is valid when length not divisable by 5.")
151+
require.NoError(t, err, "Secret size is valid when length not divisible by 5.")
145152
require.NotContains(t, k.Secret(), "=", "Secret has no escaped characters.")
146153

147154
k, err = Generate(GenerateOpts{

0 commit comments

Comments
 (0)