Skip to content

Commit fa663a6

Browse files
yaronfcursoragent
andauthored
v0.6.1: foreign-JWS allowlist, DetailsByTag, AddRequestComponent, SignerFromJWK (#25)
* deps: bump lestrrat-go/jwx/v4 to v4.5.0 Update the foreign-JWS dependency and docs that pinned the previous floor. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add foreign-JWS algorithm policy plan Capture the agreed allowlist + infer-alg NewJWSVerifier design for v0.6.x so it is not lost before implementation. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: foreign-JWS allowlist, infer-alg verify, registry-only algs Break NewJWSVerifier into infer vs WithAlg with JWSAlgAllowlist policy, require SignAlg(false) on NewJWSSigner, and resolve algs via the jwx registry so handcrafted none/unknown names cannot bypass hardening. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: satisfy lint on JWK public-key export asserts Use comma-ok type assertions for ML-DSA/Ed25519 Public() results and avoid deprecated ecdsa.PublicKey X/Y fields in nil-curve tests. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add Fields.AddRequestComponent for ;req bindings Convenience wrapper for required associated-request components, with tests and a v0.6.1 release-notes mention. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: locate signatures by tag via MessageDetails Add MessageDetails.Label and Request/Response DetailsByTag (strict) and DetailsListByTag (lenient) helpers with sentinel errors, example, and v0.6.1 release notes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: single-pass Signature-Input walk for DetailsByTag Build all MessageDetails from headers once instead of listing labels and re-parsing per name; share psiSignatureFromInnerList with parse path. Co-authored-by: Cursor <cursoragent@cursor.com> * test: broaden ByTag and signatureDetailsListFromHeaders coverage Cover response ambiguity, multi-tag/combined headers, missing Signature members, and nil/empty/malformed SFV edge cases. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: treat EdDSA and Ed25519 as equivalent for JWK infer Map OKP crv Ed25519 to RFC 9864 EdDSAEd25519, and accept legacy JWK alg "EdDSA" (and matching allowlist entries) as the same crypto. Co-authored-by: Cursor <cursoragent@cursor.com> * test: fuzz foreign JWS sign/verify, infer, and ML-DSA Add dedicated fuzz targets for ES256 and ML-DSA-44 round-trips, Signature-header panic hunting, JWK inference, and constructor alg checks. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add NewJWSSignerFromJWK for private JWK signing Infer alg from a private jwk.Key (same rules as verify), export raw private material, and construct a foreign JWS signer. Closes #24. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: disambiguate fuzz target names for CI -fuzz regexp Rename FuzzVerifyRequestJWS to FuzzJWSVerifyRequest so it is not a substring of FuzzVerifyRequest, and anchor CI/local -fuzz patterns. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9f57b64 commit fa663a6

29 files changed

Lines changed: 2336 additions & 136 deletions

.github/workflows/test.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,23 @@ jobs:
2929
go-version: '1.27'
3030
cache: false
3131
- name: Fuzz smoke
32-
# Short per-target budget (~75s total). See internal-docs/FUZZ.md.
32+
# Short per-target budget. -fuzz is a regexp — use ^Name$ so names are not
33+
# substrings of each other (see internal-docs/FUZZ.md).
3334
run: |
3435
set -euo pipefail
35-
for target in FuzzVerifyRequest FuzzVerifyViaMessage FuzzSignAndVerifyHMAC FuzzHMACViaMessage FuzzNewMessage; do
36+
for target in \
37+
FuzzVerifyRequest \
38+
FuzzVerifyViaMessage \
39+
FuzzSignAndVerifyHMAC \
40+
FuzzHMACViaMessage \
41+
FuzzNewMessage \
42+
FuzzSignAndVerifyJWS \
43+
FuzzJWSVerifyRequest \
44+
FuzzSignAndVerifyMLDSA \
45+
FuzzInferJWSVerifier \
46+
FuzzNewJWSConstructors
47+
do
3648
echo "::group::fuzz $target"
37-
go test -run='^$' -fuzz="$target" -fuzztime=15s .
49+
go test -run='^$' -fuzz="^${target}$" -fuzztime=15s .
3850
echo "::endgroup::"
3951
done

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
/.idea/inspectionProfiles/Project_Default.xml
44
/.idea/copilot.data.migration.*.xml
55
/testdata/fuzz/FuzzSignAndVerifyHMAC/
6+
/testdata/fuzz/FuzzSignAndVerifyJWS/
7+
/testdata/fuzz/FuzzJWSVerifyRequest/
8+
/testdata/fuzz/FuzzSignAndVerifyMLDSA/
9+
/testdata/fuzz/FuzzInferJWSVerifier/
10+
/testdata/fuzz/FuzzNewJWSConstructors/
611

712
.vscode/
813
coverage.out

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,16 @@ fields.go / digest.go ← Component field abstraction + Content-Digest head
4444
### Key types
4545

4646
- **`Signer` / `Verifier`** (`crypto.go`) — hold algorithm, key, and signing config. Created via `NewXxxSigner` / `NewXxxVerifier` constructors (HMAC-SHA256, RSA, RSA-PSS, P-256, P-384, Ed25519, JWS).
47-
- Foreign JWS key↔alg checks live in **`jwskey.go`** (explicit stdlib types; does not use deprecated `jws.AlgorithmsForKey`).
47+
- Foreign JWS key↔alg checks live in **`jwskey.go`** / **`jwsinfer.go`** (explicit stdlib types + JWK/ECDSA/ML-DSA alg inference; does not use deprecated `jws.AlgorithmsForKey`).
4848
- **`SignConfig` / `VerifyConfig`** (`config.go`) — builder-style configuration for signature metadata (keyID, nonce, tag, expiry, clock tolerance). Constructed via `NewSignConfig()` / `NewVerifyConfig()` with method chaining.
49+
- **`JWSAlgAllowlist`** (`jwsallow.go`) — foreign-verify alg policy for `NewJWSVerifier` / `NewJWSVerifierWithAlg` (`nil` = no policy).
4950
- **`Fields`** (`fields.go`) — specifies which HTTP components (headers, derived components) to include in the signature. Use the `Fields("header1", "@method", ...)` helper or `NewFields()` for complex cases.
5051
- **`Message` / `MessageDetails`** (`message.go`) — internal canonicalized request/response representation. `MessageDetails` is the public output of `RequestDetails` / `ResponseDetails`.
5152
- **`HandlerConfig` / `ClientConfig`** (`config.go`) — configures server-side and client-side HTTP wrappers.
5253

5354
### JWX dual-version support
5455

55-
Optional foreign JWS uses `lestrrat-go/jwx/v4` via `NewJWSSigner` / `NewJWSVerifier` (including ML-DSA with `crypto/mldsa` on Go 1.27+). Requires Go 1.27+.
56+
Optional foreign JWS uses `lestrrat-go/jwx/v4` via `NewJWSSigner` / `NewJWSSignerFromJWK` / `NewJWSVerifier` (infer) / `NewJWSVerifierWithAlg` (including ML-DSA with `crypto/mldsa` on Go 1.27+). Requires Go 1.27+.
5657

5758
### Content-Digest
5859

README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,37 @@ in the [API reference](https://pkg.go.dev/github.com/yaronf/httpsign).
2727
_ = res.Body.Close()
2828
```
2929

30-
### Upgrading from v0.5.x
30+
### Upgrading
3131

32-
**v0.6.0** is a breaking release for foreign-JWS users and raises the Go floor to **1.27+**.
32+
**v0.6.0** (shipped) raised the Go floor to **1.27+** and cut foreign JWS over to **jwx v4** + ML-DSA. See [internal-docs/RELEASE-v0.6.0.md](internal-docs/RELEASE-v0.6.0.md).
3333

34-
| Caller | Change |
35-
|--------|--------|
36-
| Native algorithms only (RSA, ECDSA, Ed25519, HMAC) | Upgrade Go to 1.27+; no API changes. |
37-
| `NewJWSSignerV3` / `NewJWSVerifierV3` | Use `NewJWSSigner` / `NewJWSVerifier` with `github.com/lestrrat-go/jwx/v4/jwa`. |
38-
| `NewJWSSigner` / `NewJWSVerifier` (jwx v2) | Same: v4 import path; algorithms are functions (`jwa.ES256()`, not string constants). |
34+
**v0.6.1** breaks foreign-JWS **verify** again: prefer `NewJWSVerifier(allowed, key, …)` (infer alg); use `NewJWSVerifierWithAlg` when needed; pass a `JWSAlgAllowlist` (`nil` skips policy). `NewJWSSigner` defaults to / requires `SignAlg(false)`; use `NewJWSSignerFromJWK` when the private key is a JWK.
3935

40-
Foreign JWS signing must use `SignConfig.SignAlg(false)` — RFC 9421 does not define an HTTP `alg` value for arbitrary JWS algorithms. Verification policy `SetAllowedAlgs` applies to the optional HTTP `alg` signature parameter in the message, not to the JWS algorithm passed to `NewJWSVerifier`.
36+
| Caller | Change in v0.6.1 |
37+
|--------|------------------|
38+
| Native algorithms only | None. |
39+
| `NewJWSVerifier(alg, key, …)` (v0.6.0) | Prefer `NewJWSVerifier(allowed, key, …)`; else `NewJWSVerifierWithAlg(allowed, alg, key, …)`. |
40+
| `NewJWSSigner` | Nil config ⇒ `SignAlg(false)`; `SignAlg(true)` errors. Prefer `NewJWSSignerFromJWK` for private JWKs. |
4141

42-
Full migration notes: [internal-docs/RELEASE-v0.6.0.md](internal-docs/RELEASE-v0.6.0.md) (maintainers: paste **Summary** into the GitHub release).
42+
Pass a non-nil allowlist when `keyid` can select among keys. `SetAllowedAlgs` still only filters Signature-Input `alg`, not JWS `jwa`.
43+
44+
Full notes: [internal-docs/RELEASE-v0.6.1.md](internal-docs/RELEASE-v0.6.1.md).
4345

4446
### Foreign JWS and ML-DSA
4547

46-
Optional algorithms beyond the native set use [`lestrrat-go/jwx/v4`](https://github.com/lestrrat-go/jwx) (≥ v4.4.0) via `NewJWSSigner` / `NewJWSVerifier`. Requires **Go 1.27+** (stdlib `encoding/json/v2`; no `GOEXPERIMENT`).
48+
Optional algorithms beyond the native set use [`lestrrat-go/jwx/v4`](https://github.com/lestrrat-go/jwx) (≥ v4.5.0) via `NewJWSSigner` / `NewJWSSignerFromJWK` / `NewJWSVerifier`.
4749

48-
**ML-DSA (FIPS 204)** is supported through the same constructors with `crypto/mldsa` keys and `jwa.MLDSA44()` / `MLDSA65()` / `MLDSA87()`. RFC 9421 does not assign HTTP Message Signatures algorithm identifiers for ML-DSA; treat it like other foreign JWS algorithms (`SignAlg(false)`, JWS `alg` in the JWS layer only if your profile requires it).
50+
**ML-DSA (FIPS 204)** works through the same path with `crypto/mldsa` keys. Prefer inferring the alg from the public key:
4951

5052
```go
5153
priv, _ := mldsa.GenerateKey(mldsa.MLDSA65())
5254
pub := priv.Public().(*mldsa.PublicKey)
53-
signer, _ := httpsign.NewJWSSigner(jwa.MLDSA65(), priv,
54-
httpsign.NewSignConfig().SignAlg(false), fields)
55-
verifier, _ := httpsign.NewJWSVerifier(jwa.MLDSA65(), pub, httpsign.NewVerifyConfig(), fields)
55+
signer, _ := httpsign.NewJWSSigner(jwa.MLDSA65(), priv, nil, fields) // SignAlg(false) by default
56+
allowed, _ := httpsign.NewJWSAlgAllowlist(jwa.MLDSA65())
57+
verifier, _ := httpsign.NewJWSVerifier(allowed, pub, httpsign.NewVerifyConfig(), fields)
5658
```
5759

58-
HMAC keys must be `[]byte` (minimum length per RFC 7518).
60+
HMAC keys must be `[]byte` (minimum length per RFC 7518). Raw RSA verify needs `NewJWSVerifierWithAlg` (or a JWK that carries `alg`).
5961

6062
### Notes and Missing Features
6163
* Requires **Go 1.27+**.

config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ func NewSignConfig() *SignConfig {
4343
}
4444
}
4545

46-
// SignAlg indicates that an "alg" signature parameter must be generated and signed (default: true).
47-
// Must be false when using NewJWSSigner (foreign JWS has no HTTP Message Signatures algorithm id).
46+
// SignAlg indicates that an "alg" signature parameter must be generated and signed (default: true for native signers).
47+
// NewJWSSigner rejects SignAlg(true) and defaults nil configs to SignAlg(false).
4848
func (c *SignConfig) SignAlg(b bool) *SignConfig {
4949
c.signAlg = b
5050
return c

crypto.go

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"fmt"
1515

1616
"github.com/lestrrat-go/jwx/v4/jwa"
17+
"github.com/lestrrat-go/jwx/v4/jwk"
1718
"github.com/lestrrat-go/jwx/v4/jws"
1819
)
1920

@@ -129,19 +130,27 @@ func NewEd25519SignerFromSeed(seed []byte, config *SignConfig, fields Fields) (*
129130
// NewJWSSigner creates a generic signer for JWS algorithms via github.com/lestrrat-go/jwx/v4.
130131
// The particular key type for each algorithm is documented in that package (including
131132
// crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string).
132-
// Config may be nil for a default configuration; SignAlg must be false (see SignConfig.SignAlg).
133-
func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) {
133+
// Do not pass jwk.Key here — use NewJWSSignerFromJWK so alg can be taken from the JWK.
134+
// Config may be nil (defaults with SignAlg(false)). A non-nil config that would emit
135+
// HTTP Signature-Input "alg" is rejected — foreign JWS has no RFC 9421 algorithm id.
136+
func NewJWSSigner(alg jwa.SignatureAlgorithm, key any, config *SignConfig, fields Fields) (*Signer, error) {
134137
if key == nil {
135138
return nil, fmt.Errorf("key must not be nil")
136139
}
137-
if alg == jwa.NoSignature() {
138-
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
140+
if err := rejectJWKKeyForSigner(key); err != nil {
141+
return nil, err
142+
}
143+
alg, err := resolveRegisteredJWSAlg(alg)
144+
if err != nil {
145+
return nil, err
139146
}
140147
if err := validateJWSKeyAlg(alg, key, true); err != nil {
141148
return nil, err
142149
}
143150
if config == nil {
144-
config = NewSignConfig()
151+
config = NewSignConfig().SignAlg(false)
152+
} else if config.signAlg {
153+
return nil, fmt.Errorf("NewJWSSigner requires SignAlg(false): foreign JWS has no HTTP Message Signatures algorithm identifier")
145154
}
146155
jwsSigner, err := jws.SignerFor(alg)
147156
if err != nil {
@@ -156,6 +165,18 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi
156165
}, nil
157166
}
158167

168+
// NewJWSSignerFromJWK creates a foreign-JWS signer by inferring the algorithm from a private jwk.Key
169+
// (JWK alg and/or unambiguous crv), then exporting raw private key material. Preferred when keys
170+
// are stored as JWKs (e.g. WIMSE WIT cnf.jwk). Public-only JWKs are rejected.
171+
// Config may be nil (defaults with SignAlg(false)); SignAlg(true) is rejected as for NewJWSSigner.
172+
func NewJWSSignerFromJWK(key jwk.Key, config *SignConfig, fields Fields) (*Signer, error) {
173+
alg, raw, err := inferJWSSignerKeyFromJWK(key)
174+
if err != nil {
175+
return nil, err
176+
}
177+
return NewJWSSigner(alg, raw, config, fields)
178+
}
179+
159180
func (s Signer) sign(buff []byte) ([]byte, error) {
160181
if s.foreignSigner != nil {
161182
signer, ok := s.foreignSigner.(jws.Signer)
@@ -327,17 +348,41 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel
327348
}, nil
328349
}
329350

330-
// NewJWSVerifier creates a generic verifier for JWS algorithms via github.com/lestrrat-go/jwx/v4.
331-
// The particular key type for each algorithm is documented in that package (including
332-
// crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string).
333-
// Set config to nil for a default configuration.
334-
// Fields is the list of required headers and fields, which may be empty (but this is typically insecure).
335-
func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) {
351+
// NewJWSVerifier creates a foreign-JWS verifier by inferring the JWS algorithm from key.
352+
// Preferred over NewJWSVerifierWithAlg when the key type uniquely determines the alg.
353+
//
354+
// Allowed key types:
355+
// - jwk.Key with alg, or EC/OKP with unambiguous crv (P-256→ES256, …, Ed25519→EdDSAEd25519; legacy JWK alg "EdDSA" also accepted)
356+
// - *ecdsa.PublicKey (curve → ES256/384/512)
357+
// - *mldsa.PublicKey (Parameters → ML-DSA-44/65/87)
358+
//
359+
// Raw RSA/HMAC keys cannot be inferred — use NewJWSVerifierWithAlg.
360+
// allowed may be nil to skip alg policy; prefer a non-nil allowlist when keyid can select keys.
361+
func NewJWSVerifier(allowed *JWSAlgAllowlist, key any, config *VerifyConfig, fields Fields) (*Verifier, error) {
362+
alg, raw, err := inferJWSVerifierKey(key)
363+
if err != nil {
364+
return nil, err
365+
}
366+
return NewJWSVerifierWithAlg(allowed, alg, raw, config, fields)
367+
}
368+
369+
// NewJWSVerifierWithAlg creates a foreign-JWS verifier for an explicit JWS algorithm.
370+
// Use when the algorithm cannot be inferred (raw RSA/HMAC) or the store already chose alg.
371+
// Do not pass jwk.Key here — use NewJWSVerifier so alg can be taken from the JWK.
372+
// allowed may be nil to skip alg policy.
373+
func NewJWSVerifierWithAlg(allowed *JWSAlgAllowlist, alg jwa.SignatureAlgorithm, key any, config *VerifyConfig, fields Fields) (*Verifier, error) {
336374
if key == nil {
337375
return nil, fmt.Errorf("key must not be nil")
338376
}
339-
if alg == jwa.NoSignature() {
340-
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
377+
if err := rejectJWKKey(key); err != nil {
378+
return nil, err
379+
}
380+
alg, err := resolveRegisteredJWSAlg(alg)
381+
if err != nil {
382+
return nil, err
383+
}
384+
if err := checkJWSAlgAllowed(allowed, alg); err != nil {
385+
return nil, err
341386
}
342387
if err := validateJWSKeyAlg(alg, key, false); err != nil {
343388
return nil, err

0 commit comments

Comments
 (0)