Skip to content

Commit e27eb95

Browse files
authored
Merge pull request #22 from yaronf/jwx-v4-cutover
feat!: jwx v4 cutover and v0.6.0 (Go 1.27, ML-DSA)
2 parents 5ae63d7 + 03fa6bb commit e27eb95

21 files changed

Lines changed: 725 additions & 442 deletions

.github/workflows/lint.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ jobs:
1111
- name: Install Go
1212
uses: actions/setup-go@v6
1313
with:
14-
go-version: '1.24'
14+
go-version: '1.27'
1515
cache: false
1616
- name: golangci-lint
1717
uses: golangci/golangci-lint-action@v9
1818
with:
19-
version: v2.12.2
19+
# v2.13+ is built with Go 1.27 (v2.12.x was go1.26 and rejects go.mod 1.27.0)
20+
version: v2.13.1

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
- name: Install Go
1212
uses: actions/setup-go@v6
1313
with:
14-
go-version: '1.24'
14+
go-version: '1.27'
1515
cache: false
1616
- name: Test
1717
run: go test ./...

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,23 @@ signatures.go ← Mid-level API: SignRequest, SignResponse, VerifyR
3636
3737
message.go / httpparse.go ← RFC 9421 message canonicalization and signature base string construction
3838
39-
crypto.go / ecdsa.go ← Signer / Verifier types and algorithm implementations
39+
crypto.go / ecdsa.go / jwskey.go ← Signer / Verifier types, native algs, foreign-JWS key checks
4040
4141
fields.go / digest.go ← Component field abstraction + Content-Digest header support
4242
```
4343

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`).
4748
- **`SignConfig` / `VerifyConfig`** (`config.go`) — builder-style configuration for signature metadata (keyID, nonce, tag, expiry, clock tolerance). Constructed via `NewSignConfig()` / `NewVerifyConfig()` with method chaining.
4849
- **`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.
4950
- **`Message` / `MessageDetails`** (`message.go`) — internal canonicalized request/response representation. `MessageDetails` is the public output of `RequestDetails` / `ResponseDetails`.
5051
- **`HandlerConfig` / `ClientConfig`** (`config.go`) — configures server-side and client-side HTTP wrappers.
5152

5253
### JWX dual-version support
5354

54-
The library supports both `lestrrat-go/jwx/v2` (kept for backward compatibility) and `lestrrat-go/jwx/v3` (recommended for new code). Use `NewJWSSignerV3` / `NewJWSVerifierV3` for new integrations.
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+.
5556

5657
### Content-Digest
5758

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,39 @@ in the [API reference](https://pkg.go.dev/github.com/yaronf/httpsign).
2626
serverText, _ := io.ReadAll(res.Body)
2727
_ = res.Body.Close()
2828
```
29+
30+
### Upgrading from v0.5.x
31+
32+
**v0.6.0** is a breaking release for foreign-JWS users and raises the Go floor to **1.27+**.
33+
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). |
39+
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`.
41+
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).
43+
44+
### Foreign JWS and ML-DSA
45+
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`).
47+
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).
49+
50+
```go
51+
priv, _ := mldsa.GenerateKey(mldsa.MLDSA65())
52+
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)
56+
```
57+
58+
HMAC keys must be `[]byte` (minimum length per RFC 7518).
59+
2960
### Notes and Missing Features
61+
* Requires **Go 1.27+**.
3062
* The `Accept-Signature` header is unimplemented.
3163
* In responses, when using the "wrapped handler" feature, the `Content-Type` header is only signed if set explicitly by the server. This is different, but arguably more secure, than the normal `net.http` behavior.
3264
* **Behind a TLS-terminating reverse proxy:** The `@scheme` derived component defaults to `req.TLS != nil`. Behind nginx, Envoy, AWS ALB, etc., `req.TLS` is nil, so `@scheme` becomes `"http"` even for HTTPS traffic. Use `SetSchemeFromRequest` on `SignConfig` and `VerifyConfig` to derive the scheme from `X-Forwarded-Proto` or similar headers.

client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ type Client struct {
2121
}
2222

2323
// NewClient constructs a new client, with the flexibility of including a custom http.Client.
24+
// config may be nil for a default configuration.
2425
func NewClient(client http.Client, config *ClientConfig) *Client {
26+
if config == nil {
27+
config = NewClientConfig()
28+
}
2529
return &Client{config: *config, client: client}
2630
}
2731

client_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,9 @@ func TestClient_PostForm(t *testing.T) {
302302
})
303303
}
304304
}
305+
306+
func TestNewDefaultClientNilConfig(t *testing.T) {
307+
c := NewDefaultClient(nil)
308+
assert.NotNil(t, c)
309+
assert.NoError(t, validateClient(c))
310+
}

config.go

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

46-
// SignAlg indicates that an "alg" signature parameters must be generated and signed (default: true).
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).
4748
func (c *SignConfig) SignAlg(b bool) *SignConfig {
4849
c.signAlg = b
4950
return c
@@ -184,9 +185,11 @@ func (v *VerifyConfig) SetRejectExpired(rejectExpired bool) *VerifyConfig {
184185
return v
185186
}
186187

187-
// SetAllowedAlgs defines the allowed values of the "alg" parameter.
188-
// This is useful if the actual algorithm used in verification is taken from the message - not a recommended practice.
189-
// Default: an empty list, signifying all values are accepted.
188+
// SetAllowedAlgs defines the allowed values of the HTTP Message Signatures "alg" parameter
189+
// (RFC 9421), not the JWS algorithm passed to NewJWSSigner/NewJWSVerifier.
190+
// Useful only if verification takes "alg" from the message (not recommended). NewJWSSigner
191+
// cannot emit "alg" (see SignAlg), so this policy applies only when a peer still includes it.
192+
// Default: empty list — all values accepted.
190193
func (v *VerifyConfig) SetAllowedAlgs(allowedAlgs []string) *VerifyConfig {
191194
v.allowedAlgs = allowedAlgs
192195
return v

crypto.go

Lines changed: 28 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,8 @@ import (
1313
"crypto/subtle"
1414
"fmt"
1515

16-
// JWX v2 - for backward compatibility (used by existing NewJWSSigner/NewJWSVerifier)
17-
"github.com/lestrrat-go/jwx/v2/jwa"
18-
"github.com/lestrrat-go/jwx/v2/jws"
19-
20-
// JWX v3 - for new V3 functions (used by NewJWSSignerV3/NewJWSVerifierV3)
21-
jwav3 "github.com/lestrrat-go/jwx/v3/jwa"
22-
jwsv3 "github.com/lestrrat-go/jwx/v3/jws"
16+
"github.com/lestrrat-go/jwx/v4/jwa"
17+
"github.com/lestrrat-go/jwx/v4/jws"
2318
)
2419

2520
// Signer includes a cryptographic key (typically a private key) and configuration of what needs to be signed.
@@ -131,51 +126,24 @@ func NewEd25519SignerFromSeed(seed []byte, config *SignConfig, fields Fields) (*
131126
return NewEd25519Signer(key, config, fields)
132127
}
133128

134-
// NewJWSSigner creates a generic signer for JWS algorithms, using the go-jwx v2 package. The particular key type for each algorithm
135-
// is documented in that package.
136-
// Config may be nil for a default configuration.
137-
//
138-
// Note: This function uses jwx v2. For jwx v3 support, use NewJWSSignerV3 instead.
129+
// NewJWSSigner creates a generic signer for JWS algorithms via github.com/lestrrat-go/jwx/v4.
130+
// The particular key type for each algorithm is documented in that package (including
131+
// 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).
139133
func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) {
140134
if key == nil {
141135
return nil, fmt.Errorf("key must not be nil")
142136
}
143-
if alg == jwa.NoSignature {
137+
if alg == jwa.NoSignature() {
144138
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
145139
}
146-
if config == nil {
147-
config = NewSignConfig()
148-
}
149-
jwsSigner, err := jws.NewSigner(alg)
150-
if err != nil {
140+
if err := validateJWSKeyAlg(alg, key, true); err != nil {
151141
return nil, err
152142
}
153-
return &Signer{
154-
key: key,
155-
alg: "",
156-
config: config,
157-
fields: fields,
158-
foreignSigner: jwsSigner,
159-
}, nil
160-
}
161-
162-
// NewJWSSignerV3 creates a generic signer for JWS algorithms, using the go-jwx v3 package. The particular key type for each algorithm
163-
// is documented in that package.
164-
// Config may be nil for a default configuration.
165-
//
166-
// This function uses jwx v3 and is the recommended choice for new code using jwx v3.
167-
// It uses the recommended SignerFor() API which returns Signer2 interface.
168-
func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) {
169-
if key == nil {
170-
return nil, fmt.Errorf("key must not be nil")
171-
}
172-
if alg == jwav3.NoSignature() {
173-
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
174-
}
175143
if config == nil {
176144
config = NewSignConfig()
177145
}
178-
jwsSigner, err := jwsv3.SignerFor(alg)
146+
jwsSigner, err := jws.SignerFor(alg)
179147
if err != nil {
180148
return nil, err
181149
}
@@ -190,21 +158,11 @@ func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, key interface{}, config *SignC
190158

191159
func (s Signer) sign(buff []byte) ([]byte, error) {
192160
if s.foreignSigner != nil {
193-
// Try v2 signer first (jws.Signer interface: Sign(payload, key))
194-
if signerV2, ok := s.foreignSigner.(jws.Signer); ok {
195-
return signerV2.Sign(buff, s.key)
196-
}
197-
198-
// Try v3 Signer2 interface (new recommended API: Sign(key, payload))
199-
// Note: parameter order is SWAPPED compared to v2!
200-
type Signer2 interface {
201-
Sign(key interface{}, payload []byte) ([]byte, error)
202-
}
203-
if signerV3, ok := s.foreignSigner.(Signer2); ok {
204-
return signerV3.Sign(s.key, buff) // Note: key first, payload second
161+
signer, ok := s.foreignSigner.(jws.Signer)
162+
if !ok {
163+
return nil, fmt.Errorf("expected jws.Signer, got %T", s.foreignSigner)
205164
}
206-
207-
return nil, fmt.Errorf("expected jws.Signer or Signer2 interface, got %T", s.foreignSigner)
165+
return signer.Sign(s.key, buff)
208166
}
209167
switch s.alg {
210168
case "hmac-sha256":
@@ -369,51 +327,25 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel
369327
}, nil
370328
}
371329

372-
// NewJWSVerifier creates a generic verifier for JWS algorithms, using the go-jwx v2 package. The particular key type for each algorithm
373-
// is documented in that package. Set config to nil for a default configuration.
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.
374334
// Fields is the list of required headers and fields, which may be empty (but this is typically insecure).
375-
//
376-
// Note: This function uses jwx v2. For jwx v3 support, use NewJWSVerifierV3 instead.
377335
func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) {
378336
if key == nil {
379337
return nil, fmt.Errorf("key must not be nil")
380338
}
381-
if config == nil {
382-
config = NewVerifyConfig()
383-
}
384-
if alg == jwa.NoSignature {
339+
if alg == jwa.NoSignature() {
385340
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
386341
}
387-
verifier, err := jws.NewVerifier(alg)
388-
if err != nil {
342+
if err := validateJWSKeyAlg(alg, key, false); err != nil {
389343
return nil, err
390344
}
391-
return &Verifier{
392-
key: key,
393-
alg: "",
394-
config: config,
395-
fields: fields,
396-
foreignVerifier: verifier,
397-
}, nil
398-
}
399-
400-
// NewJWSVerifierV3 creates a generic verifier for JWS algorithms, using the go-jwx v3 package. The particular key type for each algorithm
401-
// is documented in that package. Set config to nil for a default configuration.
402-
// Fields is the list of required headers and fields, which may be empty (but this is typically insecure).
403-
//
404-
// This function uses jwx v3 and is the recommended choice for new code using jwx v3.
405-
// It uses the recommended VerifierFor() API which returns Verifier2 interface.
406-
func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) {
407-
if key == nil {
408-
return nil, fmt.Errorf("key must not be nil")
409-
}
410345
if config == nil {
411346
config = NewVerifyConfig()
412347
}
413-
if alg == jwav3.NoSignature() {
414-
return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed")
415-
}
416-
verifier, err := jwsv3.VerifierFor(alg)
348+
verifier, err := jws.VerifierFor(alg)
417349
if err != nil {
418350
return nil, err
419351
}
@@ -428,31 +360,16 @@ func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, key interface{}, config *Ver
428360

429361
func (v Verifier) verify(buff []byte, sig []byte) (bool, error) {
430362
if v.foreignVerifier != nil {
431-
// Try v2 verifier first (jws.Verifier interface: Verify(payload, sig, key))
432-
if verifierV2, ok := v.foreignVerifier.(jws.Verifier); ok {
433-
err := verifierV2.Verify(buff, sig, v.key)
434-
if err != nil {
435-
// Return opaque error; underlying err discarded for consistency
436-
return false, fmt.Errorf("signature verification failed")
437-
}
438-
return true, nil
439-
}
440-
441-
// Try v3 Verifier2 interface (new recommended API: Verify(key, payload, sig))
442-
// Note: parameter order is DIFFERENT compared to v2!
443-
type Verifier2 interface {
444-
Verify(key interface{}, payload, signature []byte) error
363+
verifier, ok := v.foreignVerifier.(jws.Verifier)
364+
if !ok {
365+
return false, fmt.Errorf("expected jws.Verifier, got %T", v.foreignVerifier)
445366
}
446-
if verifierV3, ok := v.foreignVerifier.(Verifier2); ok {
447-
err := verifierV3.Verify(v.key, buff, sig) // Note: key first, then payload, then signature
448-
if err != nil {
449-
// Return opaque error; underlying err discarded for consistency
450-
return false, fmt.Errorf("signature verification failed")
451-
}
452-
return true, nil
367+
err := verifier.Verify(v.key, buff, sig)
368+
if err != nil {
369+
// Return opaque error; underlying err discarded for consistency
370+
return false, fmt.Errorf("signature verification failed")
453371
}
454-
455-
return false, fmt.Errorf("expected jws.Verifier or Verifier2 interface, got %T", v.foreignVerifier)
372+
return true, nil
456373
}
457374

458375
switch v.alg {

0 commit comments

Comments
 (0)