Skip to content

Commit bfadfb7

Browse files
test(coverage/crypto): drive crypto to ≥95% (was 89.2%)
Adds 8 internal tests targeting previously-uncovered branches: - Encrypt nonce-read error path (lines 66-68): swappable randReader package var lets tests inject an erroring io.Reader. - GenerateAPIKey rand-read error + short-read paths: swappable tokenRandReader package var. - SignJWT + SignOnboardingJWT SignedString error paths: swappable jwtSigningMethod package var lets a failingSigningMethod drive the error branch. - VerifyOnboardingJWT alg-confusion guard (RS256 header rejected). - VerifyJWT + VerifyOnboardingJWT defensive iat-future check: uses jwt.TimeFunc override so the library's RegisteredClaims.Valid passes, leaving the second-line-of-defense check in our code to fire — guards against jwt/v4 upstream dropping the iat check. Source-side change: 3 package-level vars (randReader, tokenRandReader, jwtSigningMethod) replace direct references to crypto/rand.Reader and jwt.SigningMethodHS256. Production behaviour unchanged; only tests override them. Coverage: 89.2% -> 95.5%. Remaining 5 uncovered statements are genuinely unreachable defensive code: - aes.go:61-63 / 87-89 cipher.NewGCM error (AES blocks never fail GCM construction) - jwt.go:83 non-ValidationError unwrap (jwt/v4 always wraps parse errors in ValidationError) - jwt.go:86-88 / 130-132 !ok || !parsed.Valid defensive (ParseWithClaims with a typed receiver always either errors or returns ok && Valid) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50b0d98 commit bfadfb7

4 files changed

Lines changed: 274 additions & 4 deletions

File tree

crypto/aes.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import (
1010
"io"
1111
)
1212

13+
// randReader is the source of randomness for nonce generation. Overridable in
14+
// tests to exercise the io.ReadFull error path; production code always uses
15+
// crypto/rand.Reader.
16+
var randReader io.Reader = rand.Reader
17+
1318
// ErrDecrypt is returned when decryption fails.
1419
type ErrDecrypt struct {
1520
Cause error
@@ -58,7 +63,7 @@ func Encrypt(key []byte, plaintext string) (string, error) {
5863
}
5964

6065
nonce := make([]byte, gcm.NonceSize())
61-
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
66+
if _, err := io.ReadFull(randReader, nonce); err != nil {
6267
return "", &ErrEncrypt{Cause: err}
6368
}
6469

crypto/coverage_test.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package crypto
2+
3+
// Internal tests that exercise hard-to-reach branches via package-private
4+
// affordances (swappable random sources). Kept in `package crypto` rather
5+
// than `crypto_test` so the rand-reader vars can be mocked without exporting
6+
// them to the public API.
7+
8+
import (
9+
"errors"
10+
"io"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/golang-jwt/jwt/v4"
16+
)
17+
18+
// errReader always fails — used to drive the io.ReadFull error paths in
19+
// Encrypt and GenerateAPIKey.
20+
type errReader struct{ err error }
21+
22+
func (r errReader) Read(_ []byte) (int, error) { return 0, r.err }
23+
24+
func TestEncrypt_NonceReadFails(t *testing.T) {
25+
// Swap the package-level rand source for one that always errors.
26+
orig := randReader
27+
defer func() { randReader = orig }()
28+
sentinel := errors.New("rand-source dead")
29+
randReader = errReader{err: sentinel}
30+
31+
key, err := ParseAESKey("0000000000000000000000000000000000000000000000000000000000000000")
32+
if err != nil {
33+
t.Fatalf("ParseAESKey: %v", err)
34+
}
35+
36+
_, err = Encrypt(key, "plaintext")
37+
if err == nil {
38+
t.Fatal("expected error when nonce read fails")
39+
}
40+
if !errors.Is(err, sentinel) {
41+
t.Errorf("expected wrapped sentinel error, got %v", err)
42+
}
43+
var ee *ErrEncrypt
44+
if !errors.As(err, &ee) {
45+
t.Errorf("expected *ErrEncrypt, got %T", err)
46+
}
47+
}
48+
49+
func TestGenerateAPIKey_RandReadFails(t *testing.T) {
50+
orig := tokenRandReader
51+
defer func() { tokenRandReader = orig }()
52+
sentinel := errors.New("rng failure")
53+
tokenRandReader = errReader{err: sentinel}
54+
55+
_, err := GenerateAPIKey()
56+
if err == nil {
57+
t.Fatal("expected error when rand.Read fails")
58+
}
59+
if !errors.Is(err, sentinel) {
60+
t.Errorf("expected wrapped sentinel error, got %v", err)
61+
}
62+
var te *ErrTokenGenerate
63+
if !errors.As(err, &te) {
64+
t.Errorf("expected *ErrTokenGenerate, got %T", err)
65+
}
66+
// Error message should mention the underlying cause.
67+
if !strings.Contains(err.Error(), "rng failure") {
68+
t.Errorf("expected error to mention cause, got %q", err.Error())
69+
}
70+
}
71+
72+
// Sanity: the default randReader/tokenRandReader are non-nil. Documents
73+
// invariant relied on by the production path.
74+
func TestDefaultRandReaders_NonNil(t *testing.T) {
75+
if randReader == nil {
76+
t.Error("randReader is nil")
77+
}
78+
if tokenRandReader == nil {
79+
t.Error("tokenRandReader is nil")
80+
}
81+
}
82+
83+
// shortReader returns fewer bytes than requested, then io.EOF — exercises the
84+
// io.ReadFull short-read path (distinct from outright error).
85+
type shortReader struct {
86+
calls int
87+
}
88+
89+
func (r *shortReader) Read(p []byte) (int, error) {
90+
r.calls++
91+
if len(p) == 0 {
92+
return 0, nil
93+
}
94+
// Fill 1 byte then signal EOF — io.ReadFull turns this into
95+
// io.ErrUnexpectedEOF.
96+
p[0] = 0xaa
97+
return 1, io.EOF
98+
}
99+
100+
func TestGenerateAPIKey_ShortRead(t *testing.T) {
101+
orig := tokenRandReader
102+
defer func() { tokenRandReader = orig }()
103+
tokenRandReader = &shortReader{}
104+
105+
_, err := GenerateAPIKey()
106+
if err == nil {
107+
t.Fatal("expected error from short rand read")
108+
}
109+
if !errors.Is(err, io.ErrUnexpectedEOF) {
110+
t.Errorf("expected ErrUnexpectedEOF, got %v", err)
111+
}
112+
}
113+
114+
func TestEncrypt_ShortNonceRead(t *testing.T) {
115+
orig := randReader
116+
defer func() { randReader = orig }()
117+
randReader = &shortReader{}
118+
119+
key, _ := ParseAESKey("0000000000000000000000000000000000000000000000000000000000000000")
120+
_, err := Encrypt(key, "x")
121+
if err == nil {
122+
t.Fatal("expected error from short nonce read")
123+
}
124+
}
125+
126+
// TestVerifyOnboardingJWT_WrongAlg exercises the keyfunc alg-confusion guard
127+
// in VerifyOnboardingJWT (mirrors TestVerifyJWT_WrongAlg in the external test
128+
// file). A token claiming alg=RS256 must be rejected because the keyfunc only
129+
// returns the HMAC key.
130+
func TestVerifyOnboardingJWT_WrongAlg(t *testing.T) {
131+
bad := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcCI6ImEifQ.sig"
132+
_, err := VerifyOnboardingJWT([]byte("secret"), bad)
133+
if err == nil {
134+
t.Fatal("expected error for non-HMAC alg")
135+
}
136+
var ve *ErrJWTVerify
137+
if !errors.As(err, &ve) {
138+
t.Errorf("expected *ErrJWTVerify, got %T", err)
139+
}
140+
}
141+
142+
// failingSigningMethod is a jwt.SigningMethod whose Sign always errors —
143+
// used to exercise the SignedString error path in SignJWT and
144+
// SignOnboardingJWT.
145+
type failingSigningMethod struct{ err error }
146+
147+
func (m *failingSigningMethod) Alg() string { return "FAIL" }
148+
func (m *failingSigningMethod) Sign(_ string, _ interface{}) (string, error) {
149+
return "", m.err
150+
}
151+
func (m *failingSigningMethod) Verify(_, _ string, _ interface{}) error { return m.err }
152+
153+
func TestSignJWT_SignedStringFails(t *testing.T) {
154+
orig := jwtSigningMethod
155+
defer func() { jwtSigningMethod = orig }()
156+
sentinel := errors.New("signing dead")
157+
jwtSigningMethod = &failingSigningMethod{err: sentinel}
158+
159+
_, err := SignJWT([]byte("secret"), InstantClaims{Fingerprint: "x"})
160+
if err == nil {
161+
t.Fatal("expected error when signing fails")
162+
}
163+
if !errors.Is(err, sentinel) {
164+
t.Errorf("expected wrapped sentinel, got %v", err)
165+
}
166+
var se *ErrJWTSign
167+
if !errors.As(err, &se) {
168+
t.Errorf("expected *ErrJWTSign, got %T", err)
169+
}
170+
}
171+
172+
// TestVerifyJWT_FutureIssuedAt_OurCheck drives the second-line-of-defense iat
173+
// check inside VerifyJWT (lines after the library's err path). jwt/v4's
174+
// RegisteredClaims.Valid uses jwt.TimeFunc — by setting TimeFunc to a moment
175+
// in the future, the library's parse passes; our own time.Now().UTC()
176+
// comparison then catches the future-iat and returns ValidationErrorIssuedAt.
177+
// Guards against jwt/v4 upstream silently dropping the iat check.
178+
func TestVerifyJWT_FutureIssuedAt_OurCheck(t *testing.T) {
179+
origTimeFunc := jwt.TimeFunc
180+
defer func() { jwt.TimeFunc = origTimeFunc }()
181+
// Pretend "now" inside the library is 1 day from now — so future-iat
182+
// tokens validate at the library layer but our code still flags them.
183+
jwt.TimeFunc = func() time.Time { return time.Now().UTC().Add(24 * time.Hour) }
184+
185+
claims := InstantClaims{Fingerprint: "fp"}
186+
claims.IssuedAt = jwt.NewNumericDate(time.Now().UTC().Add(30 * time.Minute))
187+
claims.ExpiresAt = jwt.NewNumericDate(time.Now().UTC().Add(48 * time.Hour))
188+
signed, err := SignJWT([]byte("sec"), claims)
189+
if err != nil {
190+
t.Fatalf("SignJWT: %v", err)
191+
}
192+
_, err = VerifyJWT([]byte("sec"), signed)
193+
if err == nil {
194+
t.Fatal("expected our iat-future check to flag the token")
195+
}
196+
var ve *jwt.ValidationError
197+
if !errors.As(err, &ve) {
198+
t.Errorf("expected *jwt.ValidationError, got %T", err)
199+
} else if ve.Errors&jwt.ValidationErrorIssuedAt == 0 {
200+
t.Errorf("expected ValidationErrorIssuedAt flag, got %d", ve.Errors)
201+
}
202+
}
203+
204+
// TestVerifyOnboardingJWT_FutureIssuedAt_OurCheck — sibling of the InstantClaims
205+
// test above. SignOnboardingJWT stamps iat from real time.Now(), so we must
206+
// hand-craft a token with a future iat and verify it under a library TimeFunc
207+
// that lets the iat-check pass at the library layer.
208+
func TestVerifyOnboardingJWT_FutureIssuedAt_OurCheck(t *testing.T) {
209+
origTimeFunc := jwt.TimeFunc
210+
defer func() { jwt.TimeFunc = origTimeFunc }()
211+
jwt.TimeFunc = func() time.Time { return time.Now().UTC().Add(24 * time.Hour) }
212+
213+
claims := OnboardingClaims{Fingerprint: "fp"}
214+
claims.RegisteredClaims = jwt.RegisteredClaims{
215+
ID: "test-jti",
216+
IssuedAt: jwt.NewNumericDate(time.Now().UTC().Add(30 * time.Minute)),
217+
ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(72 * time.Hour)),
218+
}
219+
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
220+
signed, err := tok.SignedString([]byte("sec"))
221+
if err != nil {
222+
t.Fatalf("manual sign: %v", err)
223+
}
224+
_, err = VerifyOnboardingJWT([]byte("sec"), signed)
225+
if err == nil {
226+
t.Fatal("expected our iat-future check to flag the token")
227+
}
228+
var ve *jwt.ValidationError
229+
if !errors.As(err, &ve) {
230+
t.Errorf("expected *jwt.ValidationError, got %T", err)
231+
} else if ve.Errors&jwt.ValidationErrorIssuedAt == 0 {
232+
t.Errorf("expected ValidationErrorIssuedAt flag, got %d", ve.Errors)
233+
}
234+
}
235+
236+
func TestSignOnboardingJWT_SignedStringFails(t *testing.T) {
237+
orig := jwtSigningMethod
238+
defer func() { jwtSigningMethod = orig }()
239+
sentinel := errors.New("onboarding signing dead")
240+
jwtSigningMethod = &failingSigningMethod{err: sentinel}
241+
242+
_, _, err := SignOnboardingJWT([]byte("secret"), OnboardingClaims{Fingerprint: "x"})
243+
if err == nil {
244+
t.Fatal("expected error when signing fails")
245+
}
246+
if !errors.Is(err, sentinel) {
247+
t.Errorf("expected wrapped sentinel, got %v", err)
248+
}
249+
var se *ErrJWTSign
250+
if !errors.As(err, &se) {
251+
t.Errorf("expected *ErrJWTSign, got %T", err)
252+
}
253+
}

crypto/jwt.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import (
99
"github.com/google/uuid"
1010
)
1111

12+
// jwtSigningMethod is the signing method used by SignJWT and SignOnboardingJWT.
13+
// Overridable in tests to exercise the SignedString error path (e.g. by
14+
// pointing at a SigningMethodHMAC whose hash is unavailable); production code
15+
// always uses HS256.
16+
var jwtSigningMethod jwt.SigningMethod = jwt.SigningMethodHS256
17+
1218
// OnboardingClaims holds the JWT payload for anonymous-to-registered conversion.
1319
type OnboardingClaims struct {
1420
Fingerprint string `json:"fp"`
@@ -49,7 +55,7 @@ func SignJWT(secret []byte, claims InstantClaims) (string, error) {
4955
if claims.IssuedAt == nil {
5056
claims.IssuedAt = jwt.NewNumericDate(time.Now().UTC())
5157
}
52-
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
58+
token := jwt.NewWithClaims(jwtSigningMethod, claims)
5359
signed, err := token.SignedString(secret)
5460
if err != nil {
5561
return "", &ErrJWTSign{Cause: err}
@@ -99,7 +105,7 @@ func SignOnboardingJWT(secret []byte, claims OnboardingClaims) (string, string,
99105
ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
100106
}
101107

102-
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
108+
token := jwt.NewWithClaims(jwtSigningMethod, claims)
103109
signed, err := token.SignedString(secret)
104110
if err != nil {
105111
return "", "", &ErrJWTSign{Cause: err}

crypto/token.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import (
44
"crypto/rand"
55
"encoding/base64"
66
"fmt"
7+
"io"
78
)
89

910
const tokenPrefix = "inst_live_"
1011

12+
// tokenRandReader is the source of randomness for API-key generation.
13+
// Overridable in tests to exercise the rand.Read error path; production code
14+
// always uses crypto/rand.Reader.
15+
var tokenRandReader io.Reader = rand.Reader
16+
1117
// ErrTokenGenerate is returned when secure random bytes cannot be read.
1218
type ErrTokenGenerate struct {
1319
Cause error
@@ -22,7 +28,7 @@ func (e *ErrTokenGenerate) Unwrap() error { return e.Cause }
2228
// GenerateAPIKey produces a secure API key of the form inst_live_<base64url(32 random bytes)>.
2329
func GenerateAPIKey() (string, error) {
2430
b := make([]byte, 32)
25-
if _, err := rand.Read(b); err != nil {
31+
if _, err := io.ReadFull(tokenRandReader, b); err != nil {
2632
return "", &ErrTokenGenerate{Cause: err}
2733
}
2834
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil

0 commit comments

Comments
 (0)