Skip to content

Commit 1126135

Browse files
committed
Address additional PR review comments
Security improvements: - Add ReadBoundedBytes/ReadBoundedString to BCS deserializer for DoS protection - WebAuthn: Use bounded reads to validate size BEFORE allocation - Keyless: Use bounded reads for JWT sig/payload, uid key, etc. - Keyless: IdCommitment now defensively copies bytes to prevent mutation Documentation fixes: - Fix WebAuthn verification comment to accurately describe ES256 flow - Update concurrency safety comments for Ed25519/Secp256k1/Secp256r1 private keys to clarify that Inner must not be mutated during concurrent operations Test updates: - Update WebAuthn bounds tests to match new generic error messages
1 parent b5c8392 commit 1126135

File tree

7 files changed

+110
-39
lines changed

7 files changed

+110
-39
lines changed

v2/internal/bcs/deserializer.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,53 @@ func (des *Deserializer) ReadBytes() []byte {
248248
return des.ReadFixedBytes(int(length))
249249
}
250250

251+
// ReadBoundedBytes deserializes a byte slice with bounds checking BEFORE allocation.
252+
// This provides DoS protection by rejecting oversized payloads before allocating memory.
253+
// Returns nil and sets error if length is outside [minLen, maxLen].
254+
func (des *Deserializer) ReadBoundedBytes(minLen, maxLen int) []byte {
255+
if des.err != nil {
256+
return nil
257+
}
258+
259+
length := des.Uleb128()
260+
if des.err != nil {
261+
return nil
262+
}
263+
264+
// Validate bounds BEFORE allocation to prevent DoS
265+
if int(length) < minLen {
266+
des.SetError(fmt.Errorf("byte slice too short: %d < %d", length, minLen))
267+
return nil
268+
}
269+
if int(length) > maxLen {
270+
des.SetError(fmt.Errorf("byte slice too large: %d > %d", length, maxLen))
271+
return nil
272+
}
273+
274+
return des.ReadFixedBytes(int(length))
275+
}
276+
277+
// ReadBoundedString deserializes a string with bounds checking BEFORE allocation.
278+
// This provides DoS protection by rejecting oversized payloads before allocating memory.
279+
func (des *Deserializer) ReadBoundedString(maxLen int) string {
280+
if des.err != nil {
281+
return ""
282+
}
283+
284+
length := des.Uleb128()
285+
if des.err != nil {
286+
return ""
287+
}
288+
289+
// Validate bounds BEFORE allocation
290+
if int(length) > maxLen {
291+
des.SetError(fmt.Errorf("string too large: %d > %d", length, maxLen))
292+
return ""
293+
}
294+
295+
return string(des.ReadFixedBytes(int(length)))
296+
}
297+
251298
// ReadString deserializes a UTF-8 string with a ULEB128 length prefix.
252299
func (des *Deserializer) ReadString() string {
253300
return string(des.ReadBytes())

v2/internal/crypto/crypto_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,7 +2097,7 @@ func TestWebAuthn_BoundsValidation(t *testing.T) {
20972097
paar2 := &PartialAuthenticatorAssertionResponse{}
20982098
err := bcs.Deserialize(paar2, data)
20992099
require.Error(t, err)
2100-
assert.Contains(t, err.Error(), "authenticator data too short")
2100+
assert.Contains(t, err.Error(), "too short")
21012101
})
21022102

21032103
// Test authenticator data too large
@@ -2110,7 +2110,7 @@ func TestWebAuthn_BoundsValidation(t *testing.T) {
21102110
paar2 := &PartialAuthenticatorAssertionResponse{}
21112111
err := bcs.Deserialize(paar2, data)
21122112
require.Error(t, err)
2113-
assert.Contains(t, err.Error(), "authenticator data too large")
2113+
assert.Contains(t, err.Error(), "too large")
21142114
})
21152115

21162116
// Test client data JSON too large
@@ -2124,7 +2124,7 @@ func TestWebAuthn_BoundsValidation(t *testing.T) {
21242124
paar2 := &PartialAuthenticatorAssertionResponse{}
21252125
err := bcs.Deserialize(paar2, data)
21262126
require.Error(t, err)
2127-
assert.Contains(t, err.Error(), "client data JSON too large")
2127+
assert.Contains(t, err.Error(), "too large")
21282128
})
21292129

21302130
// Test empty client data JSON
@@ -2136,7 +2136,7 @@ func TestWebAuthn_BoundsValidation(t *testing.T) {
21362136
paar2 := &PartialAuthenticatorAssertionResponse{}
21372137
err := bcs.Deserialize(paar2, data)
21382138
require.Error(t, err)
2139-
assert.Contains(t, err.Error(), "client data JSON is empty")
2139+
assert.Contains(t, err.Error(), "too short")
21402140
})
21412141
}
21422142

v2/internal/crypto/ed25519.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import (
1818
// to be wrapped with SingleSigner). This uses the legacy Ed25519Scheme for
1919
// backward compatibility.
2020
//
21-
// Ed25519PrivateKey is safe for concurrent use. Cached values are protected by a mutex.
21+
// Ed25519PrivateKey protects its cached values with a mutex for concurrent reads.
22+
// The underlying key material in Inner must not be mutated concurrently with
23+
// operations that read it (such as signing).
2224
//
2325
// Implements:
2426
// - [Signer]

v2/internal/crypto/keyless.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ const (
3131

3232
// EpkBlinderNumBytes is the size of the EPK blinder.
3333
EpkBlinderNumBytes = 31
34+
35+
// MaxJwtSigLen is the maximum length of a JWT signature (DoS protection).
36+
// RSA-4096 signatures are 512 bytes; this allows room for future algorithms.
37+
MaxJwtSigLen = 1024
38+
39+
// MaxJwtPayloadLen is the maximum length of a JWT payload JSON (DoS protection).
40+
// Standard JWTs should be under 8KB.
41+
MaxJwtPayloadLen = 16384
42+
43+
// MaxUidKeyLen is the maximum length of a uid key string.
44+
MaxUidKeyLen = 128
45+
46+
// MaxAudValLen is the maximum length of an audience value override.
47+
MaxAudValLen = 512
48+
49+
// MaxIssValLen is the maximum length of an issuer value.
50+
MaxIssValLen = 200
3451
)
3552

3653
// Pepper is used to create a hiding identity commitment (IDC) when deriving a keyless address.
@@ -53,16 +70,26 @@ type IdCommitment struct {
5370
}
5471

5572
// NewIdCommitment creates a new identity commitment from bytes.
73+
// The input bytes are copied to prevent external mutation after construction.
5674
func NewIdCommitment(bytes []byte) (*IdCommitment, error) {
5775
if len(bytes) != IdCommitmentNumBytes {
5876
return nil, fmt.Errorf("invalid identity commitment length: expected %d, got %d", IdCommitmentNumBytes, len(bytes))
5977
}
60-
return &IdCommitment{inner: bytes}, nil
78+
// Defensively copy the input bytes so callers cannot mutate the commitment after construction
79+
inner := make([]byte, IdCommitmentNumBytes)
80+
copy(inner, bytes)
81+
return &IdCommitment{inner: inner}, nil
6182
}
6283

63-
// Bytes returns the raw bytes of the identity commitment.
84+
// Bytes returns a copy of the raw bytes of the identity commitment.
85+
// The returned slice is a copy to prevent external mutation.
6486
func (idc *IdCommitment) Bytes() []byte {
65-
return idc.inner
87+
if idc == nil || idc.inner == nil {
88+
return nil
89+
}
90+
out := make([]byte, len(idc.inner))
91+
copy(out, idc.inner)
92+
return out
6693
}
6794

6895
// MarshalBCS serializes the identity commitment to BCS.
@@ -155,7 +182,8 @@ func (key *KeylessPublicKey) MarshalBCS(ser *bcs.Serializer) {
155182

156183
// UnmarshalBCS deserializes the keyless public key from BCS.
157184
func (key *KeylessPublicKey) UnmarshalBCS(des *bcs.Deserializer) {
158-
key.IssVal = des.ReadString()
185+
// Use bounded read for issuer value (DoS protection)
186+
key.IssVal = des.ReadBoundedString(MaxIssValLen)
159187
if des.Error() != nil {
160188
return
161189
}
@@ -436,23 +464,26 @@ func (o *OpenIdSig) MarshalBCS(ser *bcs.Serializer) {
436464
}
437465

438466
// UnmarshalBCS deserializes the OpenIdSig from BCS.
467+
// Uses bounded reads to prevent DoS attacks from oversized payloads.
439468
func (o *OpenIdSig) UnmarshalBCS(des *bcs.Deserializer) {
440-
o.JwtSig = des.ReadBytes()
469+
// Use bounded reads to validate size BEFORE allocation (DoS protection)
470+
o.JwtSig = des.ReadBoundedBytes(1, MaxJwtSigLen)
441471
if des.Error() != nil {
442472
return
443473
}
444474

445-
o.JwtPayloadJSON = des.ReadString()
475+
o.JwtPayloadJSON = des.ReadBoundedString(MaxJwtPayloadLen)
446476
if des.Error() != nil {
447477
return
448478
}
449479

450-
o.UidKey = des.ReadString()
480+
o.UidKey = des.ReadBoundedString(MaxUidKeyLen)
451481
if des.Error() != nil {
452482
return
453483
}
454484

455-
o.EpkBlinder = des.ReadBytes()
485+
// EPK blinder has a fixed size
486+
o.EpkBlinder = des.ReadBoundedBytes(EpkBlinderNumBytes, EpkBlinderNumBytes)
456487
if des.Error() != nil {
457488
return
458489
}
@@ -464,7 +495,7 @@ func (o *OpenIdSig) UnmarshalBCS(des *bcs.Deserializer) {
464495

465496
// Optional idc_aud_val
466497
if des.Bool() {
467-
s := des.ReadString()
498+
s := des.ReadBoundedString(MaxAudValLen)
468499
o.IdcAudVal = &s
469500
}
470501
}

v2/internal/crypto/secp256k1.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ const (
2727
// signer := crypto.NewSingleSigner(key)
2828
// auth, _ := signer.Sign(txnBytes)
2929
//
30-
// Secp256k1PrivateKey is safe for concurrent use. Cached values are protected by a mutex.
30+
// Secp256k1PrivateKey supports concurrent read-only use after initialization.
31+
// The mutex protects cached values (such as the derived public key), but callers
32+
// must not mutate the underlying key material (e.g., via FromBytes) concurrently
33+
// with signing or other operations that use the key.
3134
//
3235
// Implements:
3336
// - [MessageSigner]

v2/internal/crypto/secp256r1.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ const (
3636
// signer := crypto.NewSingleSigner(key)
3737
// auth, _ := signer.Sign(txnBytes)
3838
//
39-
// Secp256r1PrivateKey is safe for concurrent use. Cached values are protected by a mutex.
39+
// Secp256r1PrivateKey uses a mutex to protect cached values (such as cachedPubKey)
40+
// for concurrent read access. The underlying Inner key is not intended to be
41+
// mutated or replaced concurrently with operations like SignMessage; callers
42+
// must coordinate any re-keying externally (for example, by publishing a new
43+
// Secp256r1PrivateKey instance rather than mutating an existing one).
4044
//
4145
// Implements:
4246
// - [MessageSigner]

v2/internal/crypto/webauthn.go

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -158,35 +158,17 @@ func (p *PartialAuthenticatorAssertionResponse) UnmarshalBCS(des *bcs.Deserializ
158158
return
159159
}
160160

161-
p.AuthenticatorData = des.ReadBytes()
161+
// Use bounded read to validate size BEFORE allocation (DoS protection)
162+
p.AuthenticatorData = des.ReadBoundedBytes(MinAuthenticatorDataBytes, MaxAuthenticatorDataBytes)
162163
if des.Error() != nil {
163164
return
164165
}
165166

166-
// Validate authenticator data bounds
167-
if len(p.AuthenticatorData) < MinAuthenticatorDataBytes {
168-
des.SetError(fmt.Errorf("authenticator data too short: %d < %d", len(p.AuthenticatorData), MinAuthenticatorDataBytes))
169-
return
170-
}
171-
if len(p.AuthenticatorData) > MaxAuthenticatorDataBytes {
172-
des.SetError(fmt.Errorf("authenticator data too large: %d > %d", len(p.AuthenticatorData), MaxAuthenticatorDataBytes))
173-
return
174-
}
175-
176-
p.ClientDataJSON = des.ReadBytes()
167+
// Use bounded read for client data JSON (DoS protection)
168+
p.ClientDataJSON = des.ReadBoundedBytes(1, MaxClientDataJSONBytes)
177169
if des.Error() != nil {
178170
return
179171
}
180-
181-
// Validate client data JSON bounds
182-
if len(p.ClientDataJSON) == 0 {
183-
des.SetError(fmt.Errorf("client data JSON is empty"))
184-
return
185-
}
186-
if len(p.ClientDataJSON) > MaxClientDataJSONBytes {
187-
des.SetError(fmt.Errorf("client data JSON too large: %d > %d", len(p.ClientDataJSON), MaxClientDataJSONBytes))
188-
return
189-
}
190172
}
191173

192174
// GetChallenge extracts and decodes the challenge from the client data JSON.
@@ -281,7 +263,9 @@ func (p *PartialAuthenticatorAssertionResponse) Verify(msg []byte, pubKey *AnyPu
281263
if !ok {
282264
return false
283265
}
284-
// For WebAuthn, we verify directly over the verification data without additional hashing
266+
// For WebAuthn (ES256), the signature is over SHA-256(authenticatorData || clientDataHash).
267+
// Here, verificationData is (authenticatorData || clientDataHash), and verifySecp256r1Raw
268+
// performs the required SHA-256 before calling ecdsa.Verify.
285269
return p.verifySecp256r1Raw(verificationData, secp256r1PubKey)
286270
default:
287271
return false

0 commit comments

Comments
 (0)