tenseleyflow/shithub / 74ae64c

Browse files

Add TOTP package: secret/URI/Verify with skew + counter-anti-replay; recovery codes; SVG QR

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
74ae64ccaf27e9c423ac0ad9681eeb0fc4c63e25
Parents
bb02d2b
Tree
439bac0

6 changed files

StatusFile+-
M go.mod 2 0
M go.sum 4 0
A internal/auth/totp/qr.go 59 0
A internal/auth/totp/recovery.go 121 0
A internal/auth/totp/totp.go 152 0
A internal/auth/totp/totp_test.go 169 0
go.modmodified
@@ -21,6 +21,7 @@ require (
2121
 
2222
 require (
2323
 	github.com/beorn7/perks v1.0.1 // indirect
24
+	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
2425
 	github.com/cenkalti/backoff/v5 v5.0.3 // indirect
2526
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
2627
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -42,6 +43,7 @@ require (
4243
 	github.com/minio/minio-go/v7 v7.1.0 // indirect
4344
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
4445
 	github.com/philhofer/fwd v1.2.0 // indirect
46
+	github.com/pquerna/otp v1.5.0 // indirect
4547
 	github.com/prometheus/client_model v0.6.2 // indirect
4648
 	github.com/prometheus/common v0.66.1 // indirect
4749
 	github.com/prometheus/procfs v0.20.1 // indirect
go.summodified
@@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
22
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
33
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
44
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
6
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
57
 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
68
 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
79
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -80,6 +82,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
8082
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
8183
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
8284
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
85
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
86
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
8387
 github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
8488
 github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
8589
 github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
internal/auth/totp/qr.goadded
@@ -0,0 +1,59 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package totp
4
+
5
+import (
6
+	"fmt"
7
+	"html/template"
8
+	"strings"
9
+
10
+	"github.com/boombuler/barcode/qr"
11
+)
12
+
13
+// QRSize is the rendered SVG side length in CSS pixels (the SVG itself
14
+// is resolution-independent; this drives the viewport-mapped size).
15
+const QRSize = 256
16
+
17
+// QRSVG renders the otpauth URI as an SVG QR code suitable for inline
18
+// embedding in a template. Returns template.HTML so html/template doesn't
19
+// double-escape the markup.
20
+//
21
+// The URI is high-entropy and contains the secret. Callers MUST NOT log
22
+// either the URI or the rendered SVG.
23
+func QRSVG(otpauthURI string) (template.HTML, error) {
24
+	code, err := qr.Encode(otpauthURI, qr.M, qr.Auto)
25
+	if err != nil {
26
+		return "", fmt.Errorf("totp: qr encode: %w", err)
27
+	}
28
+	bounds := code.Bounds()
29
+	side := bounds.Dx()
30
+	if side <= 0 {
31
+		return "", fmt.Errorf("totp: qr empty bounds")
32
+	}
33
+
34
+	var b strings.Builder
35
+	fmt.Fprintf(&b,
36
+		`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" shape-rendering="crispEdges" role="img" aria-label="2FA setup QR code">`,
37
+		QRSize, QRSize, side, side)
38
+	// White background.
39
+	fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#ffffff"/>`, side, side)
40
+
41
+	// Emit one <rect> per module. For typical otpauth URIs the QR is
42
+	// ~33×33 modules, so the SVG stays under a few KB.
43
+	type img interface {
44
+		Get(x, y int) bool
45
+	}
46
+	g, ok := code.(img)
47
+	if !ok {
48
+		return "", fmt.Errorf("totp: qr type lacks Get(x,y) accessor")
49
+	}
50
+	for y := 0; y < side; y++ {
51
+		for x := 0; x < side; x++ {
52
+			if g.Get(x, y) {
53
+				fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" fill="#000000"/>`, x, y)
54
+			}
55
+		}
56
+	}
57
+	b.WriteString(`</svg>`)
58
+	return template.HTML(b.String()), nil //nolint:gosec // we built the SVG ourselves; no untrusted input.
59
+}
internal/auth/totp/recovery.goadded
@@ -0,0 +1,121 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package totp
4
+
5
+import (
6
+	"crypto/rand"
7
+	"crypto/sha256"
8
+	"crypto/subtle"
9
+	"encoding/base32"
10
+	"errors"
11
+	"strings"
12
+)
13
+
14
+// RecoveryCodeCount is the number of recovery codes generated per user.
15
+const RecoveryCodeCount = 10
16
+
17
+// recoveryAlphabet is RFC 4648 base32 minus 0/1/8/B/I/L/O/S so adjacent
18
+// glyphs don't get mistyped. 24 characters is plenty of entropy for our
19
+// per-code length (12 chars → ~57 bits).
20
+const recoveryAlphabet = "ACDEFGHJKMNPQRTUVWXYZ234"
21
+
22
+// RecoveryCodeGroups is the number of dash-separated groups in the
23
+// rendered code (currently 3, of 4 chars each → 12 chars total).
24
+const RecoveryCodeGroups = 3
25
+
26
+// RecoveryCodeGroupSize is the length of each group.
27
+const RecoveryCodeGroupSize = 4
28
+
29
+// GenerateRecoveryCodes mints RecoveryCodeCount fresh codes. Returns the
30
+// human-readable strings (XXXX-XXXX-XXXX) that are shown to the user
31
+// once, and the matching sha256 hashes (stored in the DB).
32
+func GenerateRecoveryCodes() ([]string, [][]byte, error) {
33
+	codes := make([]string, RecoveryCodeCount)
34
+	hashes := make([][]byte, RecoveryCodeCount)
35
+	for i := 0; i < RecoveryCodeCount; i++ {
36
+		c, err := mintCode()
37
+		if err != nil {
38
+			return nil, nil, err
39
+		}
40
+		codes[i] = c
41
+		h := sha256.Sum256([]byte(NormalizeRecoveryCode(c)))
42
+		hashes[i] = h[:]
43
+	}
44
+	return codes, hashes, nil
45
+}
46
+
47
+// HashRecoveryCode returns the sha256 hash of the normalized form of
48
+// code, suitable for DB lookup.
49
+func HashRecoveryCode(code string) []byte {
50
+	h := sha256.Sum256([]byte(NormalizeRecoveryCode(code)))
51
+	return h[:]
52
+}
53
+
54
+// NormalizeRecoveryCode strips dashes/whitespace and uppercases the
55
+// result so codes typed with stray spaces or lowercase still match.
56
+func NormalizeRecoveryCode(s string) string {
57
+	s = strings.ToUpper(s)
58
+	var b strings.Builder
59
+	b.Grow(len(s))
60
+	for _, r := range s {
61
+		if r == '-' || r == ' ' {
62
+			continue
63
+		}
64
+		b.WriteRune(r)
65
+	}
66
+	return b.String()
67
+}
68
+
69
+// LooksLikeRecoveryCode is a cheap predicate the login challenge handler
70
+// uses to distinguish a recovery code from a TOTP code: alphanumeric and
71
+// of the expected post-normalization length.
72
+func LooksLikeRecoveryCode(s string) bool {
73
+	n := NormalizeRecoveryCode(s)
74
+	if len(n) != RecoveryCodeGroups*RecoveryCodeGroupSize {
75
+		return false
76
+	}
77
+	for _, r := range n {
78
+		if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
79
+			return false
80
+		}
81
+	}
82
+	return true
83
+}
84
+
85
+// EqualHash compares two recovery-code hashes in constant time. Used by
86
+// callers that read a candidate hash from the DB and want to compare to
87
+// a value they computed themselves.
88
+func EqualHash(a, b []byte) bool {
89
+	return subtle.ConstantTimeCompare(a, b) == 1
90
+}
91
+
92
+// mintCode generates one fresh formatted recovery code.
93
+func mintCode() (string, error) {
94
+	const total = RecoveryCodeGroups * RecoveryCodeGroupSize
95
+	out := make([]byte, total)
96
+	// Generate 5 raw bytes per 4 base32 chars (40 bits → 8 chars; we use
97
+	// our reduced alphabet so generate plenty and slice).
98
+	buf := make([]byte, total*2)
99
+	if _, err := rand.Read(buf); err != nil {
100
+		return "", err
101
+	}
102
+	for i := 0; i < total; i++ {
103
+		out[i] = recoveryAlphabet[int(buf[i])%len(recoveryAlphabet)]
104
+	}
105
+	// Insert dashes between groups.
106
+	var b strings.Builder
107
+	for g := 0; g < RecoveryCodeGroups; g++ {
108
+		if g > 0 {
109
+			b.WriteByte('-')
110
+		}
111
+		b.Write(out[g*RecoveryCodeGroupSize : (g+1)*RecoveryCodeGroupSize])
112
+	}
113
+	return b.String(), nil
114
+}
115
+
116
+// ErrRecoveryCodeInvalid is returned by callers when the typed code
117
+// doesn't match any stored hash.
118
+var ErrRecoveryCodeInvalid = errors.New("totp: recovery code invalid")
119
+
120
+// noOpUseStdEncoding silences unused-import warnings during refactors.
121
+var _ = base32.StdEncoding
internal/auth/totp/totp.goadded
@@ -0,0 +1,152 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package totp implements RFC 6238 TOTP (time-based one-time password)
4
+// enrollment and verification. We use SHA-1 / 30-second period / 6 digits
5
+// to maximize authenticator-app compatibility (every popular app speaks
6
+// these defaults; SHA-256/512 cause real-world surprises).
7
+//
8
+// Counter anti-replay is enforced at the DB layer (BumpTOTPCounter only
9
+// advances last_used_counter when the new step is strictly greater).
10
+package totp
11
+
12
+import (
13
+	"crypto/rand"
14
+	"encoding/base32"
15
+	"errors"
16
+	"fmt"
17
+	"net/url"
18
+	"strconv"
19
+	"strings"
20
+	"time"
21
+
22
+	"github.com/pquerna/otp"
23
+	"github.com/pquerna/otp/totp"
24
+)
25
+
26
+// SecretSize is the secret length in bytes. 20 is the RFC 4226/6238
27
+// recommendation (160 bits — comfortably above brute-force reach).
28
+const SecretSize = 20
29
+
30
+// Period is the time-step in seconds.
31
+const Period uint = 30
32
+
33
+// Digits is the number of digits in the generated code.
34
+const Digits = otp.DigitsSix
35
+
36
+// SkewSteps is the number of windows ±0 we accept either side of `now`.
37
+// A value of 1 means accept now-30s, now, now+30s — covers normal clock
38
+// drift without expanding the replay window meaningfully.
39
+const SkewSteps uint = 1
40
+
41
+// GenerateSecret returns a fresh 20-byte secret.
42
+func GenerateSecret() ([]byte, error) {
43
+	b := make([]byte, SecretSize)
44
+	if _, err := rand.Read(b); err != nil {
45
+		return nil, fmt.Errorf("totp: secret: %w", err)
46
+	}
47
+	return b, nil
48
+}
49
+
50
+// EncodeBase32 returns the Base32 encoding (no padding) authenticator
51
+// apps display to the user. Used in the otpauth URI.
52
+func EncodeBase32(secret []byte) string {
53
+	return strings.TrimRight(base32.StdEncoding.EncodeToString(secret), "=")
54
+}
55
+
56
+// OtpauthURI builds the canonical otpauth:// URI consumed by authenticator
57
+// apps. issuer is the site name (e.g. "shithub"); accountName is the user's
58
+// canonical identifier (we use "shithub:<username>"). The URI carries the
59
+// secret in plaintext — never log it; redactor scrubs it as defense in depth.
60
+func OtpauthURI(issuer, accountName string, secret []byte) string {
61
+	q := url.Values{}
62
+	q.Set("secret", EncodeBase32(secret))
63
+	q.Set("issuer", issuer)
64
+	q.Set("algorithm", "SHA1")
65
+	q.Set("digits", strconv.Itoa(int(Digits)))
66
+	q.Set("period", strconv.FormatUint(uint64(Period), 10))
67
+	u := url.URL{
68
+		Scheme:   "otpauth",
69
+		Host:     "totp",
70
+		Path:     "/" + issuer + ":" + accountName,
71
+		RawQuery: q.Encode(),
72
+	}
73
+	return u.String()
74
+}
75
+
76
+// Generate returns the current 6-digit code for secret. Test affordance —
77
+// not used at runtime by the server, only in tests.
78
+func Generate(secret []byte, t time.Time) (string, error) {
79
+	code, err := totp.GenerateCodeCustom(EncodeBase32(secret), t, totp.ValidateOpts{
80
+		Period:    Period,
81
+		Skew:      SkewSteps,
82
+		Digits:    Digits,
83
+		Algorithm: otp.AlgorithmSHA1,
84
+	})
85
+	if err != nil {
86
+		return "", fmt.Errorf("totp: generate: %w", err)
87
+	}
88
+	return code, nil
89
+}
90
+
91
+// Step returns the RFC 6238 step counter for time t.
92
+func Step(t time.Time) int64 {
93
+	return t.Unix() / int64(Period)
94
+}
95
+
96
+// Verify checks code against secret with ±SkewSteps tolerance. On
97
+// acceptance, returns the step counter that matched so the caller can
98
+// reject codes whose counter is <= last_used_counter.
99
+//
100
+// Returns (step, nil) on accepted code; (0, err) otherwise. err is
101
+// distinguishable via ErrInvalid for bad codes (vs. real errors).
102
+func Verify(secret []byte, code string, t time.Time) (int64, error) {
103
+	if !looksLikeCode(code) {
104
+		return 0, ErrInvalid
105
+	}
106
+	b32 := EncodeBase32(secret)
107
+	// Walk the skew window ourselves so we know which step matched —
108
+	// pquerna/otp's ValidateCustom returns only a boolean.
109
+	for offset := -int64(SkewSteps); offset <= int64(SkewSteps); offset++ {
110
+		atTime := t.Add(time.Duration(offset) * time.Duration(Period) * time.Second)
111
+		want, err := totp.GenerateCodeCustom(b32, atTime, totp.ValidateOpts{
112
+			Period:    Period,
113
+			Skew:      0,
114
+			Digits:    Digits,
115
+			Algorithm: otp.AlgorithmSHA1,
116
+		})
117
+		if err != nil {
118
+			return 0, fmt.Errorf("totp: verify: %w", err)
119
+		}
120
+		if constantTimeEqual(want, code) {
121
+			return Step(atTime), nil
122
+		}
123
+	}
124
+	return 0, ErrInvalid
125
+}
126
+
127
+// ErrInvalid means the code doesn't match (or doesn't look like one).
128
+// Distinct from a generation error so callers can branch on it.
129
+var ErrInvalid = errors.New("totp: invalid code")
130
+
131
+func looksLikeCode(s string) bool {
132
+	if len(s) != int(Digits) {
133
+		return false
134
+	}
135
+	for _, r := range s {
136
+		if r < '0' || r > '9' {
137
+			return false
138
+		}
139
+	}
140
+	return true
141
+}
142
+
143
+func constantTimeEqual(a, b string) bool {
144
+	if len(a) != len(b) {
145
+		return false
146
+	}
147
+	var v byte
148
+	for i := 0; i < len(a); i++ {
149
+		v |= a[i] ^ b[i]
150
+	}
151
+	return v == 0
152
+}
internal/auth/totp/totp_test.goadded
@@ -0,0 +1,169 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package totp
4
+
5
+import (
6
+	"strings"
7
+	"testing"
8
+	"time"
9
+)
10
+
11
+func TestGenerateAndVerify_RoundTrip(t *testing.T) {
12
+	t.Parallel()
13
+	secret, err := GenerateSecret()
14
+	if err != nil {
15
+		t.Fatalf("GenerateSecret: %v", err)
16
+	}
17
+	now := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
18
+	code, err := Generate(secret, now)
19
+	if err != nil {
20
+		t.Fatalf("Generate: %v", err)
21
+	}
22
+	step, err := Verify(secret, code, now)
23
+	if err != nil {
24
+		t.Fatalf("Verify: %v", err)
25
+	}
26
+	if step != Step(now) {
27
+		t.Fatalf("step = %d, want %d", step, Step(now))
28
+	}
29
+}
30
+
31
+func TestVerify_AcceptsSkew(t *testing.T) {
32
+	t.Parallel()
33
+	secret, _ := GenerateSecret()
34
+	t0 := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
35
+	codePrev, _ := Generate(secret, t0.Add(-30*time.Second))
36
+	codeNext, _ := Generate(secret, t0.Add(30*time.Second))
37
+	if _, err := Verify(secret, codePrev, t0); err != nil {
38
+		t.Fatalf("prev step rejected: %v", err)
39
+	}
40
+	if _, err := Verify(secret, codeNext, t0); err != nil {
41
+		t.Fatalf("next step rejected: %v", err)
42
+	}
43
+}
44
+
45
+func TestVerify_RejectsTooFarOff(t *testing.T) {
46
+	t.Parallel()
47
+	secret, _ := GenerateSecret()
48
+	t0 := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
49
+	old, _ := Generate(secret, t0.Add(-90*time.Second)) // ~3 steps back
50
+	if _, err := Verify(secret, old, t0); err == nil {
51
+		t.Fatal("expected rejection of code 3 steps behind")
52
+	}
53
+}
54
+
55
+func TestVerify_RejectsMalformed(t *testing.T) {
56
+	t.Parallel()
57
+	secret, _ := GenerateSecret()
58
+	now := time.Now()
59
+	cases := []string{"", "12345", "1234567", "abcdef", "12345!"}
60
+	for _, c := range cases {
61
+		if _, err := Verify(secret, c, now); err == nil {
62
+			t.Errorf("expected rejection for %q", c)
63
+		}
64
+	}
65
+}
66
+
67
+func TestOtpauthURI_Shape(t *testing.T) {
68
+	t.Parallel()
69
+	secret := []byte("0123456789abcdef0123") // 20 bytes
70
+	u := OtpauthURI("shithub", "alice", secret)
71
+	if !strings.HasPrefix(u, "otpauth://totp/shithub:alice?") {
72
+		t.Fatalf("URI shape wrong: %s", u)
73
+	}
74
+	for _, want := range []string{"secret=", "issuer=shithub", "algorithm=SHA1", "digits=6", "period=30"} {
75
+		if !strings.Contains(u, want) {
76
+			t.Errorf("URI missing %q: %s", want, u)
77
+		}
78
+	}
79
+}
80
+
81
+func TestEncodeBase32_NoPadding(t *testing.T) {
82
+	t.Parallel()
83
+	enc := EncodeBase32(make([]byte, 20))
84
+	if strings.Contains(enc, "=") {
85
+		t.Fatalf("base32 contains padding: %s", enc)
86
+	}
87
+}
88
+
89
+// ----- recovery codes -----
90
+
91
+func TestRecoveryCodes_ShapeAndUniqueness(t *testing.T) {
92
+	t.Parallel()
93
+	codes, hashes, err := GenerateRecoveryCodes()
94
+	if err != nil {
95
+		t.Fatalf("GenerateRecoveryCodes: %v", err)
96
+	}
97
+	if len(codes) != RecoveryCodeCount || len(hashes) != RecoveryCodeCount {
98
+		t.Fatalf("count mismatch")
99
+	}
100
+	seen := map[string]bool{}
101
+	for _, c := range codes {
102
+		if !LooksLikeRecoveryCode(c) {
103
+			t.Errorf("LooksLikeRecoveryCode rejected its own output: %q", c)
104
+		}
105
+		if !strings.Contains(c, "-") {
106
+			t.Errorf("missing dashes: %q", c)
107
+		}
108
+		if seen[c] {
109
+			t.Errorf("duplicate code: %q", c)
110
+		}
111
+		seen[c] = true
112
+	}
113
+}
114
+
115
+func TestRecoveryCodes_HashRoundTrip(t *testing.T) {
116
+	t.Parallel()
117
+	codes, hashes, _ := GenerateRecoveryCodes()
118
+	for i, c := range codes {
119
+		// Hashing the printed form (with dashes) and the normalized form
120
+		// must yield the same hash.
121
+		if !EqualHash(HashRecoveryCode(c), hashes[i]) {
122
+			t.Errorf("code %q hash mismatch (printed)", c)
123
+		}
124
+		if !EqualHash(HashRecoveryCode(NormalizeRecoveryCode(c)), hashes[i]) {
125
+			t.Errorf("code %q hash mismatch (normalized)", c)
126
+		}
127
+		// Lowercase + space variant should also match.
128
+		messy := "  " + strings.ToLower(c) + "  "
129
+		if !EqualHash(HashRecoveryCode(messy), hashes[i]) {
130
+			t.Errorf("code %q hash mismatch (lowercase + spaces)", c)
131
+		}
132
+	}
133
+}
134
+
135
+func TestLooksLikeRecoveryCode(t *testing.T) {
136
+	t.Parallel()
137
+	yes := []string{"ABCD-EFGH-JKMN", "abcd-efgh-jkmn", "A2C4-EF7H-JKM3"}
138
+	for _, s := range yes {
139
+		if !LooksLikeRecoveryCode(s) {
140
+			t.Errorf("expected accept: %q", s)
141
+		}
142
+	}
143
+	no := []string{"123456", "ABC-DEF", "ABCDEFGHJKMN!", ""}
144
+	for _, s := range no {
145
+		if LooksLikeRecoveryCode(s) {
146
+			t.Errorf("expected reject: %q", s)
147
+		}
148
+	}
149
+}
150
+
151
+func TestQRSVG_Renders(t *testing.T) {
152
+	t.Parallel()
153
+	secret, _ := GenerateSecret()
154
+	uri := OtpauthURI("shithub", "alice", secret)
155
+	svg, err := QRSVG(uri)
156
+	if err != nil {
157
+		t.Fatalf("QRSVG: %v", err)
158
+	}
159
+	s := string(svg)
160
+	for _, want := range []string{`<svg`, `</svg>`, `viewBox`, `<rect`} {
161
+		if !strings.Contains(s, want) {
162
+			t.Errorf("missing %q in svg", want)
163
+		}
164
+	}
165
+	// Defense-in-depth: secret must NOT appear in the SVG.
166
+	if strings.Contains(s, EncodeBase32(secret)) {
167
+		t.Fatal("secret leaked into rendered SVG")
168
+	}
169
+}