Skip to content

Commit 2bf570e

Browse files
authored
jwe: WithKey validates alg-vs-key shape at option-time (#2120)
Mirror jws.validateAlgorithmForKey for the JWE side. Mismatched (alg, key) pairs that cannot succeed downstream (RSA-OAEP + []byte, A128KW + RSA key, ECDH-ES + bytes, etc.) now produce a crisp `jwe.WithKey: ...` error at option-time instead of a deep nested error from requireByteKey or keyconv inside the dispatcher. Permissive carve-outs (defer to dispatch-time validation): - jwk.Key wrappers — kty-vs-alg check happens at jwk.Export time - caller-supplied KeyEncrypter / KeyDecrypter implementations (HSM adapters, custom-decrypter test doubles) — caller owns the shape contract - HPKEKeyEncrypter / HPKEKeyDecrypter raw types (extension-pluggable) - HPKE / ML-KEM algorithm families (extension-registered, key shape is the extension's contract) Bundled doc improvement: KeyDecrypter interface godoc now spells out the library contract (what's already validated, what the implementer owns, what nil-vs-error means, what constant-time considerations remain the implementer's responsibility). Regression test covers reject paths (RSA-OAEP + bytes, A128KW + RSA, ECDH-ES + bytes) and accept paths (jwk.Key wrapper, AES-KW + bytes).
1 parent 4f8864a commit 2bf570e

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

jwe/encrypt.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package jwe
22

33
import (
4+
"crypto/ecdh"
5+
"crypto/ecdsa"
46
"crypto/rsa"
57
"fmt"
68

@@ -101,6 +103,88 @@ func requireByteKey(key any, alg string) ([]byte, error) {
101103
return b, nil
102104
}
103105

106+
// validateAlgorithmForKey checks that alg is family-compatible with
107+
// key at the WithKey option boundary, surfacing wrong-shape mismatches
108+
// as crisp `jwe.WithKey: ...` errors instead of nested errors deep in
109+
// the dispatcher (e.g. requireByteKey inside the AESKW path). Mirrors
110+
// jws.validateAlgorithmForKey in spirit but is shaped to JWE's
111+
// per-family key conventions.
112+
//
113+
// Permissive carve-outs (return nil, deferring validation):
114+
//
115+
// - Untyped/extension algorithms: HPKE and ML-KEM. These are
116+
// extension-pluggable via Register{HPKE,MLKEM}Algorithm; an
117+
// extension may accept arbitrary key shapes (e.g. an
118+
// HPKEKeyEncrypter raw type), so the option-time gate cannot
119+
// enforce a closed-set rule. The downstream dispatch handles
120+
// unsupported shapes via a typed error.
121+
// - jwk.Key: the dispatcher unwraps via jwk.Export to a raw key,
122+
// so the kty-vs-alg check happens then.
123+
// - Nil key: legitimate for `dir` (caller provides CEK separately
124+
// via WithCEK) and for callers exploring the API.
125+
//
126+
// All other built-in algorithm families enforce a concrete key-shape
127+
// expectation here. The error is wrapped by the WithKey site so the
128+
// caller sees `jwe.WithKey: ...` consistently.
129+
func validateAlgorithmForKey(alg jwa.KeyEncryptionAlgorithm, key any) error {
130+
if key == nil {
131+
return nil
132+
}
133+
// jwk.Key wrappers: defer to dispatch-time kty validation.
134+
if _, ok := key.(jwk.Key); ok {
135+
return nil
136+
}
137+
// Caller-supplied KeyEncrypter / KeyDecrypter implementations
138+
// take responsibility for their own key-shape validation. Defer.
139+
if _, ok := key.(KeyEncrypter); ok {
140+
return nil
141+
}
142+
if _, ok := key.(KeyDecrypter); ok {
143+
return nil
144+
}
145+
// HPKE raw key types implementing the HPKE key interfaces are
146+
// extension-pluggable; defer.
147+
if _, ok := key.(jwebb.HPKEKeyEncrypter); ok {
148+
return nil
149+
}
150+
if _, ok := key.(jwebb.HPKEKeyDecrypter); ok {
151+
return nil
152+
}
153+
154+
algStr := alg.String()
155+
switch {
156+
case jwebb.IsHPKE(algStr) || jwebb.IsMLKEM(algStr) || jwebb.IsMLKEMDirect(algStr):
157+
// Extension-pluggable; key shape is the extension's contract.
158+
return nil
159+
case jwebb.IsDirect(algStr):
160+
// "dir" requires a byte slice (the CEK) or a symmetric jwk.
161+
if _, ok := key.([]byte); !ok {
162+
return fmt.Errorf(`algorithm %q requires a []byte key (got %T)`, algStr, key)
163+
}
164+
case jwebb.IsAESKW(algStr) || jwebb.IsAESGCMKW(algStr) || jwebb.IsPBES2(algStr):
165+
if _, ok := key.([]byte); !ok {
166+
return fmt.Errorf(`algorithm %q requires a []byte key (got %T)`, algStr, key)
167+
}
168+
case jwebb.IsRSA15(algStr) || jwebb.IsRSAOAEP(algStr):
169+
switch key.(type) {
170+
case *rsa.PublicKey, rsa.PublicKey, *rsa.PrivateKey, rsa.PrivateKey:
171+
default:
172+
return fmt.Errorf(`algorithm %q requires an RSA key (got %T)`, algStr, key)
173+
}
174+
case jwebb.IsECDHES(algStr):
175+
switch key.(type) {
176+
case *ecdsa.PublicKey, ecdsa.PublicKey, *ecdsa.PrivateKey, ecdsa.PrivateKey,
177+
*ecdh.PublicKey, ecdh.PublicKey, *ecdh.PrivateKey, ecdh.PrivateKey:
178+
default:
179+
return fmt.Errorf(`algorithm %q requires an ECDSA or ECDH key (got %T)`, algStr, key)
180+
}
181+
default:
182+
// Unknown algorithm family: defer to dispatch.
183+
return nil
184+
}
185+
return nil
186+
}
187+
104188
func encryptKeyRSA(cek []byte, alg string, key any, encryptFn func([]byte, string, *rsa.PublicKey) (keygen.ByteSource, error)) (keygen.ByteSource, error) {
105189
// Handle rsa.PublicKey by value - convert to pointer
106190
if pk, ok := key.(rsa.PublicKey); ok {

jwe/interface.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ type KeyIDer interface {
5454
// expose the secret key in memory, for example, when you want to use
5555
// hardware security modules (HSMs) to decrypt the key.
5656
//
57+
// Library contract for implementers (read carefully):
58+
//
59+
// - The library has already verified that the wire-level `alg` is
60+
// consistent across the protected header and per-recipient header
61+
// (RFC 7516 §7.2.1 disjointness). Your DecryptKey is invoked with
62+
// the alg the library has decided to use for this attempt.
63+
// - The library has NOT validated key-shape-vs-alg compatibility for
64+
// your custom decrypter. You receive the raw recipient and message;
65+
// headers are split between protected (signed/integrity-protected)
66+
// and per-recipient (unprotected). If you read a value from the
67+
// unprotected per-recipient header for a security decision, you
68+
// must enforce its consistency with the protected header yourself
69+
// — the standard built-in decrypters route through a merged-headers
70+
// helper that performs this check, but DecryptKey receives the
71+
// unmerged inputs.
72+
// - Returning a non-nil error short-circuits this recipient. Returning
73+
// nil bytes with nil error is treated as "decryption failed" by the
74+
// dispatcher (use a non-nil error for clarity).
75+
// - You are responsible for any constant-time considerations relevant
76+
// to your decryption primitive (e.g. RFC 3218 random-CEK fallback
77+
// for RSA-PKCS1v1.5; the library does this for the built-in path).
78+
//
5779
// This API is experimental and may change without notice, even
5880
// in minor releases.
5981
type KeyDecrypter interface {

jwe/jwe.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,9 @@ func (dc *decryptContext) ProcessOptions(options []DecryptOption) error {
371371
if !ok {
372372
return fmt.Errorf("jwe.decrypt: WithKey() option must be specified using jwa.KeyEncryptionAlgorithm (got %T)", pair.alg)
373373
}
374+
if err := validateAlgorithmForKey(alg, pair.key); err != nil {
375+
return fmt.Errorf("jwe.WithKey: %w", err)
376+
}
374377
dc.keyProviders = append(dc.keyProviders, &staticKeyProvider{alg: alg, key: pair.key})
375378
case identCEK{}:
376379
dc.cek = option.MustGet[*[]byte](opt)
@@ -786,6 +789,9 @@ func (ec *encryptContext) ProcessOptions(options []EncryptOption) error {
786789
if !ok {
787790
return fmt.Errorf("jwe.encrypt: WithKey() option must be specified using jwa.KeyEncryptionAlgorithm (got %T)", wk.alg)
788791
}
792+
if err := validateAlgorithmForKey(v, wk.key); err != nil {
793+
return fmt.Errorf("jwe.WithKey: %w", err)
794+
}
789795
if jwebb.IsDirectCEK(v.String()) {
790796
useRawCEK = true
791797
}

jwe/jwe_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2195,3 +2195,62 @@ func TestDecryptHonorsContextCancellation(t *testing.T) {
21952195
require.ErrorIs(t, err, context.Canceled,
21962196
`cancellation must propagate as context.Canceled`)
21972197
}
2198+
2199+
// TestWithKeyValidatesAlgKeyShape locks the option-time alg-vs-key
2200+
// shape check on jwe.WithKey. Mismatched (alg, key) pairs that cannot
2201+
// succeed downstream produce a crisp `jwe.WithKey: ...` error at
2202+
// option-time instead of a deep nested error from the dispatcher's
2203+
// requireByteKey / keyconv gate.
2204+
func TestWithKeyValidatesAlgKeyShape(t *testing.T) {
2205+
rsaKey, err := jwxtest.GenerateRsaJwk()
2206+
require.NoError(t, err)
2207+
rsaPub, err := jwk.PublicKeyOf(rsaKey)
2208+
require.NoError(t, err)
2209+
rawRSAPub, err := jwk.Export[*rsa.PublicKey](rsaPub)
2210+
require.NoError(t, err)
2211+
2212+
t.Run("rejects RSA-OAEP + []byte at option-time on Encrypt", func(t *testing.T) {
2213+
_, err := jwe.Encrypt([]byte("payload"),
2214+
jwe.WithKey(jwa.RSA_OAEP(), []byte("not-an-rsa-key")),
2215+
)
2216+
require.Error(t, err)
2217+
require.Contains(t, err.Error(), `jwe.WithKey:`,
2218+
`error must originate at the option boundary`)
2219+
require.Contains(t, err.Error(), `requires an RSA key`,
2220+
`error must name the family expected`)
2221+
})
2222+
2223+
t.Run("rejects A128KW + RSA key at option-time on Encrypt", func(t *testing.T) {
2224+
_, err := jwe.Encrypt([]byte("payload"),
2225+
jwe.WithKey(jwa.A128KW(), rawRSAPub),
2226+
)
2227+
require.Error(t, err)
2228+
require.Contains(t, err.Error(), `jwe.WithKey:`)
2229+
require.Contains(t, err.Error(), `requires a []byte key`)
2230+
})
2231+
2232+
t.Run("rejects ECDH-ES + []byte at option-time", func(t *testing.T) {
2233+
_, err := jwe.Encrypt([]byte("payload"),
2234+
jwe.WithKey(jwa.ECDH_ES(), []byte("not-an-ec-key")),
2235+
)
2236+
require.Error(t, err)
2237+
require.Contains(t, err.Error(), `jwe.WithKey:`)
2238+
require.Contains(t, err.Error(), `requires an ECDSA or ECDH key`)
2239+
})
2240+
2241+
t.Run("accepts jwk.Key wrapper (defers to dispatch)", func(t *testing.T) {
2242+
// rsaPub wrapped as jwk.Key with RSA-OAEP — legitimate.
2243+
encrypted, err := jwe.Encrypt([]byte("payload"),
2244+
jwe.WithKey(jwa.RSA_OAEP(), rsaPub),
2245+
)
2246+
require.NoError(t, err)
2247+
require.NotEmpty(t, encrypted)
2248+
})
2249+
2250+
t.Run("accepts symmetric byte key for AES-KW", func(t *testing.T) {
2251+
_, err := jwe.Encrypt([]byte("payload"),
2252+
jwe.WithKey(jwa.A128KW(), make([]byte, 16)),
2253+
)
2254+
require.NoError(t, err)
2255+
})
2256+
}

0 commit comments

Comments
 (0)