Skip to content

Commit 4f8864a

Browse files
authored
jwe: parse and bound-check PBES2 p2c in int64 space; name the violated bound in the error (#2118)
decryptKeyPBES2 read p2c into float64, compared against the cap in float64 space, then cast via int(countFlt) before passing to PBKDF2. At the default MaxPBES2Count=1_000_000 every value is exact and the arithmetic is sound. The risk lives at the cap-raising end: a caller who sets WithMaxPBES2Count past 2^53 to support legitimately heavy PBES2 traffic enables float-precision drift to influence which value the cap accepts vs rejects, and the int() cast then converts the rounded float, not the wire integer. int64 throughout removes the precision class entirely. The two error sites that reject p2c (NaN/Inf/non-integer; out-of-range) both emitted the identical opaque "invalid 'p2c' value" string. The caller couldn't tell which check rejected, the offending value, or which option to call to widen/tighten. Reword each error site to include the value, the bound that was violated, and the option name. Bundled doc improvements: - Decrypt godoc now mentions WithMaxPBES2Count / WithMinPBES2Count alongside the existing WithMaxDecompressBufferSize paragraph; the PBES2 amplification surface is now visible from go doc jwe.Decrypt. - WithMinPBES2Count godoc adds the threat model (under-iteration attack on the recipient by a careless or malicious issuer) and notes the OWASP 2023 vs RFC 7518 §4.8.1.2 floor asymmetry. Float-domain bounds use float64(1 << 63) instead of math.MaxInt64 to keep the comparison platform-independent: 1<<63 is a 65-bit untyped constant that converts cleanly to float64 (= 2^63, exact) without going through the implementation's int width. The eventual int(count) cast is safe because count is bound-checked against int64(maxCount) where maxCount is platform-int-typed; on 32-bit, count is therefore always within int range when the cast runs.
1 parent 608daea commit 4f8864a

5 files changed

Lines changed: 126 additions & 13 deletions

File tree

jwe/decrypt.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,26 +83,48 @@ func decryptKeyPBES2(recipientKey []byte, alg string, key any, headers Headers,
8383
if !ok {
8484
return nil, fmt.Errorf(`jwe: decrypt key: missing %q field for PBES2`, CountKey)
8585
}
86-
var countFlt float64
86+
87+
// Parse p2c into int64 directly. Float64 cannot represent integers
88+
// above 2^53 exactly; comparing a parsed value against a high
89+
// MaxPBES2Count cap in float-space and then casting via int(...) lets
90+
// out-of-range values silently round into the accepted range, which
91+
// would defeat the cap when callers raise it past 2^53. int64 keeps
92+
// the bound check exact.
93+
var count int64
8794
switch v := countV.(type) {
8895
case float64:
89-
countFlt = v
96+
if math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v {
97+
return nil, fmt.Errorf(`jwe: decrypt key: invalid 'p2c' value: not a positive integer (got %v)`, v)
98+
}
99+
// Reject values outside int64 range before casting; the cast
100+
// of an out-of-range float to int is implementation-defined.
101+
// Use explicit float-domain bounds (2^63 / -2^63) instead of
102+
// math.MaxInt64 / MinInt64 so the comparison is independent
103+
// of the platform's int width and the constants do not need
104+
// implicit conversion.
105+
const (
106+
int64MaxAsFloat = float64(1 << 63) // 2^63, the smallest float > MaxInt64
107+
int64MinAsFloat = -int64MaxAsFloat // -2^63, exact float = MinInt64
108+
)
109+
if v >= int64MaxAsFloat || v < int64MinAsFloat {
110+
return nil, fmt.Errorf(`jwe: decrypt key: invalid 'p2c' value: not representable as int64 (got %v)`, v)
111+
}
112+
count = int64(v)
90113
case stdjson.Number:
91-
var err error
92-
countFlt, err = v.Float64()
114+
c, err := v.Int64()
93115
if err != nil {
94-
return nil, fmt.Errorf(`jwe: decrypt key: %q field is not a valid number: %w`, CountKey, err)
116+
return nil, fmt.Errorf(`jwe: decrypt key: invalid 'p2c' value: %q is not a valid integer: %w`, v.String(), err)
95117
}
118+
count = c
96119
default:
97120
return nil, fmt.Errorf(`jwe: decrypt key: %q field is not a number`, CountKey)
98121
}
99122

100-
if math.IsNaN(countFlt) || math.IsInf(countFlt, 0) || math.Trunc(countFlt) != countFlt {
101-
return nil, fmt.Errorf("jwe: decrypt key: invalid 'p2c' value")
123+
if count < int64(minCount) {
124+
return nil, fmt.Errorf(`jwe: decrypt key: invalid 'p2c' value: %d is below WithMinPBES2Count=%d (RFC 7518 §4.8.1.2 floor; loosen via jwe.WithMinPBES2Count)`, count, minCount)
102125
}
103-
104-
if countFlt > float64(maxCount) || countFlt < float64(minCount) {
105-
return nil, fmt.Errorf("jwe: decrypt key: invalid 'p2c' value")
126+
if count > int64(maxCount) {
127+
return nil, fmt.Errorf(`jwe: decrypt key: invalid 'p2c' value: %d exceeds WithMaxPBES2Count=%d (DoS amplification cap; raise via jwe.WithMaxPBES2Count)`, count, maxCount)
106128
}
107129

108130
saltBytes, err := base64.DecodeString(saltB64)
@@ -113,7 +135,7 @@ func decryptKeyPBES2(recipientKey []byte, alg string, key any, headers Headers,
113135
salt := []byte(alg)
114136
salt = append(salt, byte(0))
115137
salt = append(salt, saltBytes...)
116-
return jwebb.KeyDecryptPBES2(recipientKey, recipientKey, alg, password, salt, int(countFlt))
138+
return jwebb.KeyDecryptPBES2(recipientKey, recipientKey, alg, password, salt, int(count))
117139
}
118140

119141
func decryptKeyAESGCMKW(recipientKey []byte, alg string, key any, headers Headers) ([]byte, error) {

jwe/jwe.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,17 @@ func (ec *encryptContext) EncryptMessage(payload []byte, cek []byte) ([]byte, er
10971097
//
10981098
// jwe.Settings(jwe.WithMaxDecompressBufferSize(10*1024*1024)) // changes value globally
10991099
// jwe.Decrypt(..., jwe.WithMaxDecompressBufferSize(250*1024)) // changes just for this call
1100+
//
1101+
// PBES2 amplification: PBES2 algorithms (PBES2-HS256+A128KW, etc.)
1102+
// derive the CEK via PBKDF2 with the iteration count taken from the
1103+
// JWE's `p2c` header. An attacker-controlled iteration count multiplied
1104+
// by `WithMaxRecipients` is the major CPU-amplification vector on the
1105+
// decrypt side. Bound it via `WithMaxPBES2Count` (default 1,000,000)
1106+
// and reject too-low counts via `WithMinPBES2Count` (default 1000;
1107+
// RFC 7518 §4.8.1.2 floor — note OWASP 2023 recommends ≥600,000 for
1108+
// production password-derived key material). Both options accept a
1109+
// `Settings()` global or a per-call value the same way
1110+
// `WithMaxDecompressBufferSize` does.
11001111
func Decrypt(buf []byte, options ...DecryptOption) ([]byte, error) {
11011112
dc := decryptContextPool.Get()
11021113
defer decryptContextPool.Put(dc)

jwe/jwe_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,6 +1306,8 @@ func TestPBES2RejectsNonIntegerCount(t *testing.T) {
13061306
_, err := jwe.Decrypt(tampered, jwe.WithKey(jwa.PBES2_HS256_A128KW(), key))
13071307
require.Error(t, err, `jwe.Decrypt should fail`)
13081308
require.Contains(t, err.Error(), `invalid 'p2c' value`)
1309+
require.Contains(t, err.Error(), `not a positive integer`,
1310+
`error should name the constraint`)
13091311
})
13101312

13111313
t.Run("UseNumber decoder rejects fractional count", func(t *testing.T) {
@@ -1315,6 +1317,56 @@ func TestPBES2RejectsNonIntegerCount(t *testing.T) {
13151317
_, err := jwe.Decrypt(tampered, jwe.WithKey(jwa.PBES2_HS256_A128KW(), key))
13161318
require.Error(t, err, `jwe.Decrypt should fail`)
13171319
require.Contains(t, err.Error(), `invalid 'p2c' value`)
1320+
require.Contains(t, err.Error(), `not a valid integer`,
1321+
`UseNumber path should name the integer-parse failure`)
1322+
})
1323+
}
1324+
1325+
// TestPBES2P2cBoundsErrors locks the error-wording contract that the
1326+
// max/min bound errors name the option, the offending value, and the
1327+
// configured bound — replacing the previous opaque
1328+
// "invalid 'p2c' value" string. Same site also moved from float-space
1329+
// to int64-space comparison so the cap is enforced exactly.
1330+
func TestPBES2P2cBoundsErrors(t *testing.T) {
1331+
password := []byte(`supersecret`)
1332+
key, err := jwk.Import[jwk.Key](password)
1333+
require.NoError(t, err)
1334+
1335+
encrypted, err := jwe.Encrypt(
1336+
[]byte(`hello world`),
1337+
jwe.WithKey(jwa.PBES2_HS256_A128KW(), key),
1338+
jwe.WithPBES2Count(2000),
1339+
)
1340+
require.NoError(t, err)
1341+
1342+
t.Run("error names the bound that was violated (max)", func(t *testing.T) {
1343+
tampered := rewriteCompactPBES2Count(t, encrypted, 99999999)
1344+
_, err := jwe.Decrypt(tampered,
1345+
jwe.WithKey(jwa.PBES2_HS256_A128KW(), key),
1346+
jwe.WithMaxPBES2Count(10000),
1347+
)
1348+
require.Error(t, err)
1349+
require.Contains(t, err.Error(), `WithMaxPBES2Count`,
1350+
`max-bound error must name the option`)
1351+
require.Contains(t, err.Error(), `99999999`,
1352+
`max-bound error must include the offending value`)
1353+
require.Contains(t, err.Error(), `10000`,
1354+
`max-bound error must include the cap`)
1355+
})
1356+
1357+
t.Run("error names the bound that was violated (min)", func(t *testing.T) {
1358+
tampered := rewriteCompactPBES2Count(t, encrypted, 100)
1359+
_, err := jwe.Decrypt(tampered,
1360+
jwe.WithKey(jwa.PBES2_HS256_A128KW(), key),
1361+
jwe.WithMinPBES2Count(1000),
1362+
)
1363+
require.Error(t, err)
1364+
require.Contains(t, err.Error(), `WithMinPBES2Count`,
1365+
`min-bound error must name the option`)
1366+
require.Contains(t, err.Error(), `100`,
1367+
`min-bound error must include the offending value`)
1368+
require.Contains(t, err.Error(), `1000`,
1369+
`min-bound error must include the floor`)
13181370
})
13191371
}
13201372

jwe/options.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,21 @@ options:
190190
comment: |
191191
WithMinPBES2Count specifies the minimum number of PBES2 iterations
192192
to accept when decrypting a message. If not specified, the default
193-
value of 1,000 is used. Set to 0 to disable the minimum check.
193+
value of 1,000 is used (RFC 7518 §4.8.1.2 floor). Set to 0 to
194+
disable the minimum check (NOT RECOMMENDED for untrusted issuers
195+
— without a floor, recipients accept arbitrarily-weak password-
196+
derived keys and silently spend the producer-chosen amount of
197+
crypto work to verify them).
198+
199+
Threat model: a malicious or careless issuer signs a PBES2-wrapped
200+
JWE with a very low p2c (e.g. 100) so they spend almost no CPU on
201+
their side, while the recipient happily derives the wrap key with
202+
the same low iteration count. The result is an authenticated
203+
message whose key-derivation strength is well below industry
204+
practice (OWASP 2023 recommends ≥600,000 PBKDF2-SHA256 iterations
205+
for password-derived keys; the RFC floor of 1,000 is far below
206+
that). For receiver-side hardening against under-iteration, raise
207+
WithMinPBES2Count above 1,000.
194208
195209
This option can be used for `jwe.Settings()`, which changes the behavior
196210
globally, or for `jwe.Decrypt()`, which changes the behavior for that

jwe/options_gen.go

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)