From 1ce5e3f12612ebae98c2f19c15e37adabcbbf342 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Wed, 26 Aug 2026 19:49:04 +0300 Subject: [PATCH 1/9] feat!: cut over to jwx v4 and ML-DSA on Go 1.27 Drop dual jwx v2/v3 support for a single NewJWSSigner/NewJWSVerifier path on lestrrat-go/jwx/v4, raise the Go floor to 1.27, and add an ML-DSA-65 foreign-JWS round-trip. Prepares httpsign v0.6.0. Co-authored-by: Cursor --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- CLAUDE.md | 2 +- README.md | 2 + crypto.go | 139 ++++----------------- crypto_test.go | 239 ++++--------------------------------- go.mod | 29 +---- go.sum | 53 ++------ internal-docs/JWX.md | 34 +++--- 9 files changed, 86 insertions(+), 416 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9000239..957f5b5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: - name: Install Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.27' cache: false - name: golangci-lint uses: golangci/golangci-lint-action@v9 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38ce14c..f485b4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: - name: Install Go uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.27' cache: false - name: Test run: go test ./... diff --git a/CLAUDE.md b/CLAUDE.md index 382e7b9..6b24409 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ fields.go / digest.go ← Component field abstraction + Content-Digest head ### JWX dual-version support -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. +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+. ### Content-Digest diff --git a/README.md b/README.md index 1c54827..3cfe47b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ in the [API reference](https://pkg.go.dev/github.com/yaronf/httpsign). _ = res.Body.Close() ``` ### Notes and Missing Features +* Requires **Go 1.27+**. +* Optional foreign JWS (including **ML-DSA** via `crypto/mldsa`) uses [`lestrrat-go/jwx/v4`](https://github.com/lestrrat-go/jwx) through `NewJWSSigner` / `NewJWSVerifier`. * The `Accept-Signature` header is unimplemented. * 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. * **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. diff --git a/crypto.go b/crypto.go index 163019e..5b2fc1e 100644 --- a/crypto.go +++ b/crypto.go @@ -13,13 +13,8 @@ import ( "crypto/subtle" "fmt" - // JWX v2 - for backward compatibility (used by existing NewJWSSigner/NewJWSVerifier) - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - - // JWX v3 - for new V3 functions (used by NewJWSSignerV3/NewJWSVerifierV3) - jwav3 "github.com/lestrrat-go/jwx/v3/jwa" - jwsv3 "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jws" ) // Signer includes a cryptographic key (typically a private key) and configuration of what needs to be signed. @@ -131,51 +126,20 @@ func NewEd25519SignerFromSeed(seed []byte, config *SignConfig, fields Fields) (* return NewEd25519Signer(key, config, fields) } -// NewJWSSigner creates a generic signer for JWS algorithms, using the go-jwx v2 package. The particular key type for each algorithm -// is documented in that package. -// Config may be nil for a default configuration. -// -// Note: This function uses jwx v2. For jwx v3 support, use NewJWSSignerV3 instead. +// NewJWSSigner creates a generic signer for JWS algorithms via github.com/lestrrat-go/jwx/v4. +// The particular key type for each algorithm is documented in that package (including +// crypto/mldsa keys for ML-DSA on Go 1.27+). Config may be nil for a default configuration. func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") } - if alg == jwa.NoSignature { - return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") - } - if config == nil { - config = NewSignConfig() - } - jwsSigner, err := jws.NewSigner(alg) - if err != nil { - return nil, err - } - return &Signer{ - key: key, - alg: "", - config: config, - fields: fields, - foreignSigner: jwsSigner, - }, nil -} - -// NewJWSSignerV3 creates a generic signer for JWS algorithms, using the go-jwx v3 package. The particular key type for each algorithm -// is documented in that package. -// Config may be nil for a default configuration. -// -// This function uses jwx v3 and is the recommended choice for new code using jwx v3. -// It uses the recommended SignerFor() API which returns Signer2 interface. -func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) { - if key == nil { - return nil, fmt.Errorf("key must not be nil") - } - if alg == jwav3.NoSignature() { + if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } if config == nil { config = NewSignConfig() } - jwsSigner, err := jwsv3.SignerFor(alg) + jwsSigner, err := jws.SignerFor(alg) if err != nil { return nil, err } @@ -190,21 +154,11 @@ func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, key interface{}, config *SignC func (s Signer) sign(buff []byte) ([]byte, error) { if s.foreignSigner != nil { - // Try v2 signer first (jws.Signer interface: Sign(payload, key)) - if signerV2, ok := s.foreignSigner.(jws.Signer); ok { - return signerV2.Sign(buff, s.key) - } - - // Try v3 Signer2 interface (new recommended API: Sign(key, payload)) - // Note: parameter order is SWAPPED compared to v2! - type Signer2 interface { - Sign(key interface{}, payload []byte) ([]byte, error) - } - if signerV3, ok := s.foreignSigner.(Signer2); ok { - return signerV3.Sign(s.key, buff) // Note: key first, payload second + signer, ok := s.foreignSigner.(jws.Signer) + if !ok { + return nil, fmt.Errorf("expected jws.Signer, got %T", s.foreignSigner) } - - return nil, fmt.Errorf("expected jws.Signer or Signer2 interface, got %T", s.foreignSigner) + return signer.Sign(s.key, buff) } switch s.alg { case "hmac-sha256": @@ -369,11 +323,10 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel }, nil } -// NewJWSVerifier creates a generic verifier for JWS algorithms, using the go-jwx v2 package. The particular key type for each algorithm -// is documented in that package. Set config to nil for a default configuration. +// NewJWSVerifier creates a generic verifier for JWS algorithms via github.com/lestrrat-go/jwx/v4. +// The particular key type for each algorithm is documented in that package (including +// crypto/mldsa keys for ML-DSA on Go 1.27+). Set config to nil for a default configuration. // Fields is the list of required headers and fields, which may be empty (but this is typically insecure). -// -// Note: This function uses jwx v2. For jwx v3 support, use NewJWSVerifierV3 instead. func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") @@ -381,39 +334,10 @@ func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyC if config == nil { config = NewVerifyConfig() } - if alg == jwa.NoSignature { + if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } - verifier, err := jws.NewVerifier(alg) - if err != nil { - return nil, err - } - return &Verifier{ - key: key, - alg: "", - config: config, - fields: fields, - foreignVerifier: verifier, - }, nil -} - -// NewJWSVerifierV3 creates a generic verifier for JWS algorithms, using the go-jwx v3 package. The particular key type for each algorithm -// is documented in that package. Set config to nil for a default configuration. -// Fields is the list of required headers and fields, which may be empty (but this is typically insecure). -// -// This function uses jwx v3 and is the recommended choice for new code using jwx v3. -// It uses the recommended VerifierFor() API which returns Verifier2 interface. -func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) { - if key == nil { - return nil, fmt.Errorf("key must not be nil") - } - if config == nil { - config = NewVerifyConfig() - } - if alg == jwav3.NoSignature() { - return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") - } - verifier, err := jwsv3.VerifierFor(alg) + verifier, err := jws.VerifierFor(alg) if err != nil { return nil, err } @@ -428,31 +352,16 @@ func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, key interface{}, config *Ver func (v Verifier) verify(buff []byte, sig []byte) (bool, error) { if v.foreignVerifier != nil { - // Try v2 verifier first (jws.Verifier interface: Verify(payload, sig, key)) - if verifierV2, ok := v.foreignVerifier.(jws.Verifier); ok { - err := verifierV2.Verify(buff, sig, v.key) - if err != nil { - // Return opaque error; underlying err discarded for consistency - return false, fmt.Errorf("signature verification failed") - } - return true, nil - } - - // Try v3 Verifier2 interface (new recommended API: Verify(key, payload, sig)) - // Note: parameter order is DIFFERENT compared to v2! - type Verifier2 interface { - Verify(key interface{}, payload, signature []byte) error + verifier, ok := v.foreignVerifier.(jws.Verifier) + if !ok { + return false, fmt.Errorf("expected jws.Verifier, got %T", v.foreignVerifier) } - if verifierV3, ok := v.foreignVerifier.(Verifier2); ok { - err := verifierV3.Verify(v.key, buff, sig) // Note: key first, then payload, then signature - if err != nil { - // Return opaque error; underlying err discarded for consistency - return false, fmt.Errorf("signature verification failed") - } - return true, nil + err := verifier.Verify(v.key, buff, sig) + if err != nil { + // Return opaque error; underlying err discarded for consistency + return false, fmt.Errorf("signature verification failed") } - - return false, fmt.Errorf("expected jws.Verifier or Verifier2 interface, got %T", v.foreignVerifier) + return true, nil } switch v.alg { diff --git a/crypto_test.go b/crypto_test.go index 70d95e4..a73c53a 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -2,21 +2,17 @@ package httpsign import ( "crypto/ed25519" + "crypto/mldsa" "crypto/rand" "crypto/rsa" "reflect" "strings" "testing" - // JWX v2 - for existing tests - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - - // JWX v3 - for new V3 tests - jwav3 "github.com/lestrrat-go/jwx/v3/jwa" - jwsv3 "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v4/jwa" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewHMACSHA256Signer(t *testing.T) { @@ -148,7 +144,7 @@ func TestForeignSigner(t *testing.T) { config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) signatureName := "sig1" fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - signer, err := NewJWSSigner(jwa.ES256, priv, config.SetKeyID("key1"), fields) + signer, err := NewJWSSigner(jwa.ES256(), priv, config.SetKeyID("key1"), fields) if err != nil { t.Errorf("Failed to create JWS signer") } @@ -159,7 +155,7 @@ func TestForeignSigner(t *testing.T) { } req.Header.Add("Signature", sig) req.Header.Add("Signature-Input", sigInput) - verifier, err := NewJWSVerifier(jwa.ES256, pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) + verifier, err := NewJWSVerifier(jwa.ES256(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) if err != nil { t.Errorf("could not generate Verifier: %s", err) } @@ -179,7 +175,7 @@ func TestMessageForeignSigner(t *testing.T) { config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) signatureName := "sig1" fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - signer, err := NewJWSSigner(jwa.ES256, priv, config.SetKeyID("key1"), fields) + signer, err := NewJWSSigner(jwa.ES256(), priv, config.SetKeyID("key1"), fields) if err != nil { t.Errorf("Failed to create JWS signer") } @@ -190,7 +186,7 @@ func TestMessageForeignSigner(t *testing.T) { } req.Header.Add("Signature", sig) req.Header.Add("Signature-Input", sigInput) - verifier, err := NewJWSVerifier(jwa.ES256, pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) + verifier, err := NewJWSVerifier(jwa.ES256(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) if err != nil { t.Errorf("could not generate Verifier: %s", err) } @@ -256,11 +252,9 @@ func TestNewJWSVerifier(t *testing.T) { type args struct { alg jwa.SignatureAlgorithm key any - keyID string config *VerifyConfig fields Fields } - verifier, _ := jws.NewVerifier("HS256") tests := []struct { name string args args @@ -270,9 +264,8 @@ func TestNewJWSVerifier(t *testing.T) { { name: "happy path", args: args{ - alg: jwa.SignatureAlgorithm("HS256"), + alg: jwa.HS256(), key: "1234", - keyID: "key200", config: nil, fields: *NewFields(), }, @@ -281,16 +274,15 @@ func TestNewJWSVerifier(t *testing.T) { alg: "", config: NewVerifyConfig(), fields: *NewFields(), - foreignVerifier: verifier, + foreignVerifier: nil, // cleared below }, wantErr: false, }, { name: "none", args: args{ - alg: jwa.NoSignature, + alg: jwa.NoSignature(), key: "1234", - keyID: "key200", config: NewVerifyConfig(), fields: *NewFields(), }, @@ -298,11 +290,10 @@ func TestNewJWSVerifier(t *testing.T) { wantErr: true, }, { - name: "bad verifier", + name: "nil key", args: args{ - alg: jwa.SignatureAlgorithm("bad"), - key: "1234", - keyID: "key200", + alg: jwa.HS256(), + key: nil, config: NewVerifyConfig(), fields: *NewFields(), }, @@ -347,209 +338,25 @@ func TestVerify(t *testing.T) { assert.ErrorContains(t, err, "expected", "bad algorithm") } -// V3 Tests - Testing jwx v3 functionality - -func TestForeignSignerV3(t *testing.T) { - priv, pub, err := genP256KeyPair() - if err != nil { - t.Errorf("Failed to generate keypair: %v", err) - } +func TestForeignSignerMLDSA65(t *testing.T) { + priv, err := mldsa.GenerateKey(mldsa.MLDSA65()) + require.NoError(t, err) + pub := priv.Public().(*mldsa.PublicKey) config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) signatureName := "sig1" fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - signer, err := NewJWSSignerV3(jwav3.ES256(), priv, config.SetKeyID("key1"), fields) - if err != nil { - t.Errorf("Failed to create JWS V3 signer: %v", err) - } - req := readRequest(httpreq2) - sigInput, sig, err := SignRequest(signatureName, *signer, req) - if err != nil { - t.Errorf("signature failed: %v", err) - } - req.Header.Add("Signature", sig) - req.Header.Add("Signature-Input", sigInput) - verifier, err := NewJWSVerifierV3(jwav3.ES256(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) - if err != nil { - t.Errorf("could not generate V3 Verifier: %s", err) - } - err = VerifyRequest(signatureName, *verifier, req) - if err != nil { - t.Errorf("verification error: %s", err) - } -} + signer, err := NewJWSSigner(jwa.MLDSA65(), priv, config.SetKeyID("pq1"), fields) + require.NoError(t, err) -// Same as TestForeignSignerV3 but using Message -func TestMessageForeignSignerV3(t *testing.T) { - priv, pub, err := genP256KeyPair() - if err != nil { - t.Errorf("Failed to generate keypair: %v", err) - } - - config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) - signatureName := "sig1" - fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - signer, err := NewJWSSignerV3(jwav3.ES256(), priv, config.SetKeyID("key1"), fields) - if err != nil { - t.Errorf("Failed to create JWS V3 signer: %v", err) - } req := readRequest(httpreq2) sigInput, sig, err := SignRequest(signatureName, *signer, req) - if err != nil { - t.Errorf("signature failed: %v", err) - } + require.NoError(t, err) req.Header.Add("Signature", sig) req.Header.Add("Signature-Input", sigInput) - verifier, err := NewJWSVerifierV3(jwav3.ES256(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) - if err != nil { - t.Errorf("could not generate V3 Verifier: %s", err) - } - msg, err := NewMessage(NewMessageConfig().WithRequest(req)) - if err != nil { - t.Errorf("Failed to create Message") - } - _, err = msg.Verify(signatureName, *verifier) - if err != nil { - t.Errorf("verification error: %s", err) - } -} -func TestNewJWSVerifierV3(t *testing.T) { - type args struct { - alg jwav3.SignatureAlgorithm - key any - config *VerifyConfig - fields Fields - } - verifier, _ := jwsv3.NewVerifier(jwav3.HS256()) - tests := []struct { - name string - args args - want *Verifier - wantErr bool - }{ - { - name: "happy path", - args: args{ - alg: jwav3.HS256(), - key: "1234", - config: nil, - fields: *NewFields(), - }, - want: &Verifier{ - key: "1234", - alg: "", - config: NewVerifyConfig(), - fields: *NewFields(), - foreignVerifier: verifier, - }, - wantErr: false, - }, - { - name: "none", - args: args{ - alg: jwav3.NoSignature(), - key: "1234", - config: NewVerifyConfig(), - fields: *NewFields(), - }, - want: nil, - wantErr: true, - }, - { - name: "nil key", - args: args{ - alg: jwav3.HS256(), - key: nil, - config: NewVerifyConfig(), - fields: *NewFields(), - }, - want: nil, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := NewJWSVerifierV3(tt.args.alg, tt.args.key, tt.args.config, tt.args.fields) - if (err != nil) != tt.wantErr { - t.Errorf("NewJWSVerifierV3() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != nil { - got.foreignVerifier = nil - } - if tt.want != nil { - tt.want.foreignVerifier = nil - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewJWSVerifierV3() got = %v, want %v", got, tt.want) - } - }) - } + verifier, err := NewJWSVerifier(jwa.MLDSA65(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("pq1"), fields) + require.NoError(t, err) + require.NoError(t, VerifyRequest(signatureName, *verifier, req)) } -// Test cross-compatibility between v2 and v3 -func TestCrossVersionCompatibility(t *testing.T) { - priv, pub, err := genP256KeyPair() - if err != nil { - t.Fatalf("Failed to generate keypair: %v", err) - } - - config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) - signatureName := "sig1" - fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - - // Test 1: Sign with v2, verify with v3 - t.Run("v2_sign_v3_verify", func(t *testing.T) { - signerV2, err := NewJWSSigner(jwa.ES256, priv, config.SetKeyID("key1"), fields) - if err != nil { - t.Fatalf("Failed to create v2 signer: %v", err) - } - - req := readRequest(httpreq2) - sigInput, sig, err := SignRequest(signatureName, *signerV2, req) - if err != nil { - t.Fatalf("v2 signature failed: %v", err) - } - - req.Header.Add("Signature", sig) - req.Header.Add("Signature-Input", sigInput) - - verifierV3, err := NewJWSVerifierV3(jwav3.ES256(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) - if err != nil { - t.Fatalf("Failed to create v3 verifier: %v", err) - } - - err = VerifyRequest(signatureName, *verifierV3, req) - if err != nil { - t.Errorf("v3 verification of v2 signature failed: %v", err) - } - }) - - // Test 2: Sign with v3, verify with v2 - t.Run("v3_sign_v2_verify", func(t *testing.T) { - signerV3, err := NewJWSSignerV3(jwav3.ES256(), priv, config.SetKeyID("key1"), fields) - if err != nil { - t.Fatalf("Failed to create v3 signer: %v", err) - } - - req := readRequest(httpreq2) - sigInput, sig, err := SignRequest(signatureName, *signerV3, req) - if err != nil { - t.Fatalf("v3 signature failed: %v", err) - } - - req.Header.Add("Signature", sig) - req.Header.Add("Signature-Input", sigInput) - - verifierV2, err := NewJWSVerifier(jwa.ES256, pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) - if err != nil { - t.Fatalf("Failed to create v2 verifier: %v", err) - } - - err = VerifyRequest(signatureName, *verifierV2, req) - if err != nil { - t.Errorf("v2 verification of v3 signature failed: %v", err) - } - }) -} diff --git a/go.mod b/go.mod index bf6fe1f..c7bb4dc 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,18 @@ module github.com/yaronf/httpsign -go 1.24.0 - -toolchain go1.24.1 +go 1.27.0 require ( github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 github.com/dunglas/httpsfv v1.0.2 - github.com/lestrrat-go/jwx/v2 v2.1.2 - github.com/lestrrat-go/jwx/v3 v3.0.12 - github.com/stretchr/testify v1.11.1 + github.com/lestrrat-go/jwx/v4 v4.4.0 + github.com/stretchr/testify v1.12.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.0.0 // indirect - github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect - github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect - github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/segmentio/asm v1.2.1 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect + github.com/lestrrat-go/option/v3 v3.0.0-alpha1 // indirect github.com/sergi/go-diff v1.3.1 // indirect - github.com/valyala/fastjson v1.6.4 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/sys v0.37.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d1d4d75..da4ad8b 100644 --- a/go.sum +++ b/go.sum @@ -1,64 +1,35 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= -github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= -github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= -github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= -github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= -github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= -github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc= -github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y= -github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= -github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= -github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= +github.com/lestrrat-go/jwx/v4 v4.4.0 h1:CzoK8+u++WF7vVEmxx9fB8VaheeXWZ698F6HZbrl6SI= +github.com/lestrrat-go/jwx/v4 v4.4.0/go.mod h1:65utsGK/iSrjgGfu6iqj/TAvSfia6SSXkRpjHcKcTyg= +github.com/lestrrat-go/option/v3 v3.0.0-alpha1 h1:dvdzLwm/Ba5CJUF3jQP7w/iNYSLfy7yyh9XXNa1WjxI= +github.com/lestrrat-go/option/v3 v3.0.0-alpha1/go.mod h1:5KSg20dfsKkNJtjDmaQRLZVXuUrzuCCcz/gbDK0pfKk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= -github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal-docs/JWX.md b/internal-docs/JWX.md index e339534..193a4f5 100644 --- a/internal-docs/JWX.md +++ b/internal-docs/JWX.md @@ -6,11 +6,9 @@ Single source of truth for optional jwx-backed JWS support in httpsign. jwx is used only for **optional “foreign” JWS** signing and verification. **Native** algorithms (HMAC-SHA256, RSA, RSASSA-PSS, P-256/P-384, Ed25519) do **not** use jwx. -**Today (pre-cutover):** `go.mod` still pulls both `github.com/lestrrat-go/jwx/v2` and `.../jwx/v3`. Public API is `NewJWSSigner` / `NewJWSVerifier` (v2) and `NewJWSSignerV3` / `NewJWSVerifierV3` (v3). `crypto.go` dispatches on the embedded foreign signer/verifier interfaces (v2 vs v3 parameter order differs). +**On `jwx-v4-cutover` / for `v0.6.0`:** single dependency `github.com/lestrrat-go/jwx/v4` (**≥ v4.4.0**); public API is `NewJWSSigner` / `NewJWSVerifier` only (v2 and `*V3` removed). Go floor **1.27.0**. ML-DSA via the same constructors. -**Target:** a single jwx **v4** path, one constructor pair, and **post-quantum (PQ) signatures** as a first-class capability (see below). - -**Release vehicle:** ship as **httpsign `v0.6.0`** (breaking within `0.x`; not a leap to `v1.0.0`). Under Go modules, `v0.y.z` may introduce breaking API/dependency changes on a minor bump — that is intentional here (Go floor, drop jwx v2/v3, collapse constructors). +**Previously (≤ v0.5.x):** `go.mod` pulled both `jwx/v2` and `jwx/v3`, with `NewJWSSigner`/`NewJWSVerifier` (v2) and `NewJWSSignerV3`/`NewJWSVerifierV3` (v3). --- @@ -40,17 +38,17 @@ Go 1.27 adds stdlib [`crypto/mldsa`](https://pkg.go.dev/crypto/mldsa). jwx v4 re --- -## Status (2026-08-26): execute the cutover +## Status (2026-08-26): cutover implemented on branch `jwx-v4-cutover` -The July 2026 gate is **met**. Do **`v0.6.0`** now; do **not** keep dual v2+v3 any longer than the cutover PR. +Ship as **`v0.6.0`** when merged. Dual v2+v3 is removed on this branch. | Gate | Status | |------|--------| | Go **1.27.0** stable on [go.dev/dl](https://go.dev/dl/) | **Met** (released 2026-08-19) | | `encoding/json/v2` in stdlib (no `GOEXPERIMENT=jsonv2`) | **Met** — see [Go 1.27 notes](https://go.dev/doc/go1.27) | -| jwx v4 mature | **Met** — use **`v4.4.0+`** (v4.2.0 was the original floor; current latest as of this update is v4.4.0) | -| Smoke: `go get …/jwx/v4@v4.4.0` under `GOTOOLCHAIN=go1.27.0`, no `GOEXPERIMENT` | **Confirmed** locally (2026-08-26) | -| Stdlib **`crypto/mldsa`** + jwx native ML-DSA (PQ goal) | **Met** on Go 1.27+ (no `jwx-go/mldsa` companion needed) | +| jwx v4 mature | **Met** — pinned **`v4.4.0`** | +| Smoke / full tests under `GOTOOLCHAIN=go1.27.0` | **Met** on cutover branch | +| Stdlib **`crypto/mldsa`** + jwx native ML-DSA (PQ goal) | **Met** — `TestForeignSignerMLDSA65` | ### Why this was deferred (history) @@ -109,15 +107,15 @@ Scoped to httpsign’s use of **jwa** + **jws** only (no JWT/JWE/JWK fetch in li ### Code / deps -- [ ] `go.mod`: Go 1.27.0+; require `github.com/lestrrat-go/jwx/v4` (**≥ v4.4.0**); remove v2 and v3. -- [ ] Rewrite imports `jwx/v2|v3` → `jwx/v4`; collapse constructors; update `sign()` / `verify()` dispatch for v4 `jws.Signer` / `jws.Verifier` (renamed from v3 `Signer2` / `Verifier2`; parameter order matches today’s V3 path: key before payload). -- [ ] Confirm factory APIs (`SignerFor` / `VerifierFor`) and `NoSignature` rejection still work. -- [ ] Drop v2↔v3 cross-compat tests; keep round-trip tests on the single v4 path. -- [ ] **PQ:** foreign-JWS round-trip with `crypto/mldsa` + `jwa.MLDSA65()` (and smoke 44/87 if cheap); document in README/release notes. -- [ ] Run `jwxmigrate --fix` if helpful; fix remaining compile/test failures by hand. -- [ ] CI (`test.yml`, `lint.yml`, CodeQL): Go **1.27**; do not set `jsonv2` / `nojsonv2`. -- [ ] Docs: README / `CLAUDE.md` / this file — remove dual-version guidance; **`v0.6.0`** release notes with caller steps + PQ; link upstream Changes-v4 if relevant. -- [ ] Tag **`v0.6.0`** and publish. +- [x] `go.mod`: Go 1.27.0+; require `github.com/lestrrat-go/jwx/v4` (**≥ v4.4.0**); remove v2 and v3. +- [x] Rewrite imports `jwx/v2|v3` → `jwx/v4`; collapse constructors; update `sign()` / `verify()` dispatch for v4 `jws.Signer` / `jws.Verifier` (renamed from v3 `Signer2` / `Verifier2`; parameter order matches today’s V3 path: key before payload). +- [x] Confirm factory APIs (`SignerFor` / `VerifierFor`) and `NoSignature` rejection still work. +- [x] Drop v2↔v3 cross-compat tests; keep round-trip tests on the single v4 path. +- [x] **PQ:** foreign-JWS round-trip with `crypto/mldsa` + `jwa.MLDSA65()` (and smoke 44/87 if cheap); document in README/release notes. +- [ ] Run `jwxmigrate --fix` if helpful; fix remaining compile/test failures by hand. *(done by hand; migrate tool optional)* +- [x] CI (`test.yml`, `lint.yml`, CodeQL): Go **1.27**; do not set `jsonv2` / `nojsonv2`. +- [x] Docs: README / `CLAUDE.md` / this file — remove dual-version guidance; **`v0.6.0`** release notes with caller steps + PQ; link upstream Changes-v4 if relevant. +- [ ] Tag **`v0.6.0`** and publish. *(after merge)* ### Upstream items likely N/A or low priority From dc88c6ffa1347c00ec904792419a01e424d4a869 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Wed, 26 Aug 2026 20:14:33 +0300 Subject: [PATCH 2/9] fix: harden JWS constructors and bump golangci-lint for Go 1.27 Reject key/alg mismatches at NewJWS* construction, document SetAllowedAlgs vs foreign JWS, and move lint to v2.13.1 so CI can target go.mod 1.27.0. Track upstream jwx ECDSA/ML-DSA AlgorithmsForKey gaps for later. Co-authored-by: Cursor --- .github/workflows/lint.yml | 3 +- config.go | 8 ++- crypto.go | 40 +++++++++++-- crypto_test.go | 113 ++++++++++++++++++++++++++++++------- internal-docs/JWX.md | 21 ++++++- 5 files changed, 153 insertions(+), 32 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 957f5b5..01041ef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,4 +16,5 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.12.2 + # v2.13+ is built with Go 1.27 (v2.12.x was go1.26 and rejects go.mod 1.27.0) + version: v2.13.1 diff --git a/config.go b/config.go index 6e62667..e7d3fb7 100644 --- a/config.go +++ b/config.go @@ -184,9 +184,11 @@ func (v *VerifyConfig) SetRejectExpired(rejectExpired bool) *VerifyConfig { return v } -// SetAllowedAlgs defines the allowed values of the "alg" parameter. -// This is useful if the actual algorithm used in verification is taken from the message - not a recommended practice. -// Default: an empty list, signifying all values are accepted. +// SetAllowedAlgs defines the allowed values of the HTTP Message Signatures "alg" parameter +// (RFC 9421), not the JWS algorithm passed to NewJWSSigner/NewJWSVerifier. +// This is useful if the algorithm used in verification is taken from the message — not a recommended practice. +// With foreign JWS signers the library refuses to emit "alg", so this policy only applies when a peer +// still includes that parameter. Default: an empty list, signifying all values are accepted. func (v *VerifyConfig) SetAllowedAlgs(allowedAlgs []string) *VerifyConfig { v.allowedAlgs = allowedAlgs return v diff --git a/crypto.go b/crypto.go index 5b2fc1e..4d3b99c 100644 --- a/crypto.go +++ b/crypto.go @@ -12,6 +12,7 @@ import ( "crypto/sha512" "crypto/subtle" "fmt" + "slices" "github.com/lestrrat-go/jwx/v4/jwa" "github.com/lestrrat-go/jwx/v4/jws" @@ -128,7 +129,12 @@ func NewEd25519SignerFromSeed(seed []byte, config *SignConfig, fields Fields) (* // NewJWSSigner creates a generic signer for JWS algorithms via github.com/lestrrat-go/jwx/v4. // The particular key type for each algorithm is documented in that package (including -// crypto/mldsa keys for ML-DSA on Go 1.27+). Config may be nil for a default configuration. +// crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string). +// Config may be nil for a default configuration. +// +// Note: foreign JWS signers do not emit the HTTP Message Signatures "alg" parameter +// (see SignConfig.SignAlg). VerifyConfig.SetAllowedAlgs therefore only applies if a peer +// still includes that parameter; it does not constrain the JWS algorithm passed here. func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") @@ -136,6 +142,9 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } + if err := validateJWSKeyAlg(alg, key); err != nil { + return nil, err + } if config == nil { config = NewSignConfig() } @@ -152,6 +161,20 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi }, nil } +// validateJWSKeyAlg rejects keys that jwx does not associate with alg (wrong key family). +// It uses jws.AlgorithmsForKey; some families list multiple algs (e.g. all HS*, all ML-DSA*), +// so finer mismatches may still fail later at Sign/Verify time. +func validateJWSKeyAlg(alg jwa.SignatureAlgorithm, key interface{}) error { + algs, err := jws.AlgorithmsForKey(key) + if err != nil { + return fmt.Errorf("key is not usable for JWS signing/verification: %w", err) + } + if !slices.Contains(algs, alg) { + return fmt.Errorf("algorithm %s is not valid for key type %T", alg, key) + } + return nil +} + func (s Signer) sign(buff []byte) ([]byte, error) { if s.foreignSigner != nil { signer, ok := s.foreignSigner.(jws.Signer) @@ -325,18 +348,25 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel // NewJWSVerifier creates a generic verifier for JWS algorithms via github.com/lestrrat-go/jwx/v4. // The particular key type for each algorithm is documented in that package (including -// crypto/mldsa keys for ML-DSA on Go 1.27+). Set config to nil for a default configuration. +// crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string). +// Set config to nil for a default configuration. // Fields is the list of required headers and fields, which may be empty (but this is typically insecure). +// +// Note: SetAllowedAlgs constrains the HTTP Message Signatures "alg" parameter on the wire, +// not the JWS algorithm passed here. Foreign JWS signers omit that parameter by design. func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") } - if config == nil { - config = NewVerifyConfig() - } if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } + if err := validateJWSKeyAlg(alg, key); err != nil { + return nil, err + } + if config == nil { + config = NewVerifyConfig() + } verifier, err := jws.VerifierFor(alg) if err != nil { return nil, err diff --git a/crypto_test.go b/crypto_test.go index a73c53a..81d2e9c 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -248,7 +248,45 @@ func TestNewRSASigner1(t *testing.T) { } } +func TestNewJWSSigner(t *testing.T) { + hmacKey := []byte("1234") + priv, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + tests := []struct { + name string + alg jwa.SignatureAlgorithm + key any + wantErr bool + }{ + {name: "happy path", alg: jwa.HS256(), key: hmacKey}, + {name: "none", alg: jwa.NoSignature(), key: hmacKey, wantErr: true}, + {name: "nil key", alg: jwa.HS256(), key: nil, wantErr: true}, + {name: "string hmac key", alg: jwa.HS256(), key: "1234", wantErr: true}, + {name: "key alg mismatch", alg: jwa.HS256(), key: priv, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewJWSSigner(tt.alg, tt.key, nil, *NewFields()) + if tt.wantErr { + require.Error(t, err) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + require.NotNil(t, got.foreignSigner) + assert.Equal(t, hmacKey, got.key) + assert.Empty(t, got.alg) + }) + } +} + func TestNewJWSVerifier(t *testing.T) { + hmacKey := []byte("1234") + priv, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + type args struct { alg jwa.SignatureAlgorithm key any @@ -265,12 +303,12 @@ func TestNewJWSVerifier(t *testing.T) { name: "happy path", args: args{ alg: jwa.HS256(), - key: "1234", + key: hmacKey, config: nil, fields: *NewFields(), }, want: &Verifier{ - key: "1234", + key: hmacKey, alg: "", config: NewVerifyConfig(), fields: *NewFields(), @@ -282,7 +320,7 @@ func TestNewJWSVerifier(t *testing.T) { name: "none", args: args{ alg: jwa.NoSignature(), - key: "1234", + key: hmacKey, config: NewVerifyConfig(), fields: *NewFields(), }, @@ -300,6 +338,28 @@ func TestNewJWSVerifier(t *testing.T) { want: nil, wantErr: true, }, + { + name: "string hmac key", + args: args{ + alg: jwa.HS256(), + key: "1234", + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, + { + name: "key alg mismatch", + args: args{ + alg: jwa.HS256(), + key: priv.Public(), + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -338,25 +398,38 @@ func TestVerify(t *testing.T) { assert.ErrorContains(t, err, "expected", "bad algorithm") } -func TestForeignSignerMLDSA65(t *testing.T) { - priv, err := mldsa.GenerateKey(mldsa.MLDSA65()) - require.NoError(t, err) - pub := priv.Public().(*mldsa.PublicKey) +func TestForeignSignerMLDSA(t *testing.T) { + cases := []struct { + name string + params mldsa.Parameters + alg jwa.SignatureAlgorithm + }{ + {"MLDSA44", mldsa.MLDSA44(), jwa.MLDSA44()}, + {"MLDSA65", mldsa.MLDSA65(), jwa.MLDSA65()}, + {"MLDSA87", mldsa.MLDSA87(), jwa.MLDSA87()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + priv, err := mldsa.GenerateKey(tc.params) + require.NoError(t, err) + pub := priv.Public().(*mldsa.PublicKey) - config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) - signatureName := "sig1" - fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") - signer, err := NewJWSSigner(jwa.MLDSA65(), priv, config.SetKeyID("pq1"), fields) - require.NoError(t, err) + config := NewSignConfig().setFakeCreated(1618884475).SignAlg(false) + signatureName := "sig1" + fields := *NewFields().AddHeader("@method").AddHeader("date").AddHeader("content-type").AddQueryParam("pet") + signer, err := NewJWSSigner(tc.alg, priv, config.SetKeyID("pq1"), fields) + require.NoError(t, err) - req := readRequest(httpreq2) - sigInput, sig, err := SignRequest(signatureName, *signer, req) - require.NoError(t, err) - req.Header.Add("Signature", sig) - req.Header.Add("Signature-Input", sigInput) + req := readRequest(httpreq2) + sigInput, sig, err := SignRequest(signatureName, *signer, req) + require.NoError(t, err) + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) - verifier, err := NewJWSVerifier(jwa.MLDSA65(), pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("pq1"), fields) - require.NoError(t, err) - require.NoError(t, VerifyRequest(signatureName, *verifier, req)) + verifier, err := NewJWSVerifier(tc.alg, pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("pq1"), fields) + require.NoError(t, err) + require.NoError(t, VerifyRequest(signatureName, *verifier, req)) + }) + } } diff --git a/internal-docs/JWX.md b/internal-docs/JWX.md index 193a4f5..a3a4dd9 100644 --- a/internal-docs/JWX.md +++ b/internal-docs/JWX.md @@ -48,7 +48,7 @@ Ship as **`v0.6.0`** when merged. Dual v2+v3 is removed on this branch. | `encoding/json/v2` in stdlib (no `GOEXPERIMENT=jsonv2`) | **Met** — see [Go 1.27 notes](https://go.dev/doc/go1.27) | | jwx v4 mature | **Met** — pinned **`v4.4.0`** | | Smoke / full tests under `GOTOOLCHAIN=go1.27.0` | **Met** on cutover branch | -| Stdlib **`crypto/mldsa`** + jwx native ML-DSA (PQ goal) | **Met** — `TestForeignSignerMLDSA65` | +| Stdlib **`crypto/mldsa`** + jwx native ML-DSA (PQ goal) | **Met** — `TestForeignSignerMLDSA` (44/65/87) | ### Why this was deferred (history) @@ -93,7 +93,8 @@ No change to key types or httpsign `SignConfig` / `VerifyConfig` / `Fields` for | Item | Requirement | |------|-------------| -| Go | **1.27.0+** in `go.mod` / CI (today CI is still 1.24) | +| Go | **1.27.0+** in `go.mod` / CI | +| golangci-lint | **≥ v2.13** (built with Go 1.27; v2.12.x fails with go.mod `1.27.0`) | | `GOEXPERIMENT=jsonv2` | Not required; do not set | | `GOEXPERIMENT=nojsonv2` | Avoid in CI | @@ -111,12 +112,26 @@ Scoped to httpsign’s use of **jwa** + **jws** only (no JWT/JWE/JWK fetch in li - [x] Rewrite imports `jwx/v2|v3` → `jwx/v4`; collapse constructors; update `sign()` / `verify()` dispatch for v4 `jws.Signer` / `jws.Verifier` (renamed from v3 `Signer2` / `Verifier2`; parameter order matches today’s V3 path: key before payload). - [x] Confirm factory APIs (`SignerFor` / `VerifierFor`) and `NoSignature` rejection still work. - [x] Drop v2↔v3 cross-compat tests; keep round-trip tests on the single v4 path. -- [x] **PQ:** foreign-JWS round-trip with `crypto/mldsa` + `jwa.MLDSA65()` (and smoke 44/87 if cheap); document in README/release notes. +- [x] **PQ:** foreign-JWS round-trips with `crypto/mldsa` + `jwa.MLDSA44/65/87()`; document in README/release notes. - [ ] Run `jwxmigrate --fix` if helpful; fix remaining compile/test failures by hand. *(done by hand; migrate tool optional)* - [x] CI (`test.yml`, `lint.yml`, CodeQL): Go **1.27**; do not set `jsonv2` / `nojsonv2`. +- [x] Lint: bump **golangci-lint ≥ v2.13** (v2.12.2 is built with go1.26 → fails on go.mod 1.27.0). - [x] Docs: README / `CLAUDE.md` / this file — remove dual-version guidance; **`v0.6.0`** release notes with caller steps + PQ; link upstream Changes-v4 if relevant. +- [x] Hardening: constructor `jws.AlgorithmsForKey` check; reject `NoSignature`; HMAC keys must be `[]byte`; document `SetAllowedAlgs` vs JWS alg. - [ ] Tag **`v0.6.0`** and publish. *(after merge)* +### Later action: upstream jwx / dsig (found 2026-08-26 while hardening) + +Not blocking `v0.6.0`. File / track upstream issues; optionally tighten httpsign further if upstream stays loose. + +| Finding | Severity | Notes | +|---------|----------|-------| +| **`jws.AlgorithmsForKey` ignores ECDSA curve** for raw `*ecdsa.{Private,Public}Key` | Correctness | Returns `[ES256, ES384, ES512]` for any EC key. Docs claim curve is inferred from the Go type, but `hasCrv` is never set for stdlib ECDSA; `RegisterAlgorithmForCurve` is only used for Ed25519 in init (no P-256→ES256 etc.). | +| **ECDSA Sign/Verify do not enforce RFC 7518 curve↔alg** | Spec / footgun | `ES384` effectively means SHA-384 only; a **P-256** key can mint `"alg":"ES384"` and verify with the same P-256 pub (64-byte sig). Will not verify under a real P-384 key. Policy that allows only ES384 expecting P-384 strength can accept weaker P-256+SHA-384 if a P-256 key is registered. dsig examples treat cross-curve as intentional for custom algs. | +| **`AlgorithmsForKey` lists all ML-DSA algs** for any ML-DSA key | Classifier only | Sign/Verify correctly reject parameter-set mismatch — crypto path is fine; helper is over-broad (same class of bug as ECDSA listing). | + +**Suggested upstream asks (lestrrat-go/jwx + dsig):** (1) extract curve from raw ECDSA keys in `AlgorithmsForKey` and register P-256/P-384/P-521 → ES256/ES384/ES512; (2) optionally enforce curve↔alg in ECDSA Sign/Verify; (3) refine ML-DSA listing by parameter set. **httpsign follow-up:** if upstream does not tighten ECDSA, consider our own curve check in `validateJWSKeyAlg` (and ML-DSA `Parameters()` vs `jwa.MLDSA*`). + ### Upstream items likely N/A or low priority | Topic | httpsign | From 88f68093307a4a9fc2acc3c1fb96d70faa431756 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Wed, 26 Aug 2026 20:31:11 +0300 Subject: [PATCH 3/9] chore: bump deps and replace unmaintained andreyvit/diff Upgrade httpsfv to v1.1.1 and testify to v1.12.1. Drop andreyvit/diff in favor of sergi/go-diff plus a local CharacterDiff-style test helper. Co-authored-by: Cursor --- diff_test.go | 34 ++++++++++++++++++++++++++++++++++ go.mod | 9 ++++----- go.sum | 21 ++++++++------------- http2_test.go | 6 ++---- 4 files changed, 48 insertions(+), 22 deletions(-) create mode 100644 diff_test.go diff --git a/diff_test.go b/diff_test.go new file mode 100644 index 0000000..9346920 --- /dev/null +++ b/diff_test.go @@ -0,0 +1,34 @@ +package httpsign + +import ( + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" +) + +// characterDiff returns an inline diff using (++added++) and (~~deleted~~) markup. +// Same formatting as the old github.com/andreyvit/diff CharacterDiff helper. +func characterDiff(a, b string) string { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(a, b, true) + if len(diffs) > 2 { + diffs = dmp.DiffCleanupSemantic(diffs) + diffs = dmp.DiffCleanupEfficiency(diffs) + } + var bld strings.Builder + for _, d := range diffs { + switch d.Type { + case diffmatchpatch.DiffInsert: + bld.WriteString("(++") + bld.WriteString(d.Text) + bld.WriteString("++)") + case diffmatchpatch.DiffDelete: + bld.WriteString("(~~") + bld.WriteString(d.Text) + bld.WriteString("~~)") + case diffmatchpatch.DiffEqual: + bld.WriteString(d.Text) + } + } + return bld.String() +} diff --git a/go.mod b/go.mod index c7bb4dc..00c4e79 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,15 @@ module github.com/yaronf/httpsign go 1.27.0 require ( - github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 - github.com/dunglas/httpsfv v1.0.2 + github.com/dunglas/httpsfv v1.1.1 github.com/lestrrat-go/jwx/v4 v4.4.0 - github.com/stretchr/testify v1.12.0 + github.com/sergi/go-diff v1.4.0 + github.com/stretchr/testify v1.12.1 ) require ( github.com/lestrrat-go/dsig v1.4.0 // indirect github.com/lestrrat-go/option/v3 v3.0.0-alpha1 // indirect - github.com/sergi/go-diff v1.3.1 // indirect github.com/valyala/fastjson v1.6.10 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect ) diff --git a/go.sum b/go.sum index da4ad8b..8305a68 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,9 @@ -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= -github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI= +github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= @@ -16,20 +12,19 @@ github.com/lestrrat-go/jwx/v4 v4.4.0/go.mod h1:65utsGK/iSrjgGfu6iqj/TAvSfia6SSXk github.com/lestrrat-go/option/v3 v3.0.0-alpha1 h1:dvdzLwm/Ba5CJUF3jQP7w/iNYSLfy7yyh9XXNa1WjxI= github.com/lestrrat-go/option/v3 v3.0.0-alpha1/go.mod h1:5KSg20dfsKkNJtjDmaQRLZVXuUrzuCCcz/gbDK0pfKk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http2_test.go b/http2_test.go index 9371a35..1fd16f4 100644 --- a/http2_test.go +++ b/http2_test.go @@ -11,8 +11,6 @@ import ( "strings" "testing" "text/template" - - "github.com/andreyvit/diff" ) var wantFields = `"kuku": my awesome header @@ -88,7 +86,7 @@ func testHTTP(t *testing.T, proto string) { } if sigInput != wf { - t.Errorf("unexpected fields: %s\n", diff.CharacterDiff(sigInput, wantFields)) + t.Errorf("unexpected fields: %s\n", characterDiff(sigInput, wantFields)) } w.WriteHeader(200) } @@ -143,7 +141,7 @@ func testMessageHTTP(t *testing.T, proto string) { } if sigInput != wf { - t.Errorf("unexpected fields: %s\n", diff.CharacterDiff(sigInput, wantFields)) + t.Errorf("unexpected fields: %s\n", characterDiff(sigInput, wantFields)) } w.WriteHeader(200) } From a49124dce7347a191b609dbb4e5e9fd5e7ea8ff0 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Wed, 26 Aug 2026 21:15:42 +0300 Subject: [PATCH 4/9] docs: drop upstream jwx issue/GHSA tracking from the plan Remove the hardening follow-up notes now that reports were filed privately/upstream. Co-authored-by: Cursor --- internal-docs/JWX.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/internal-docs/JWX.md b/internal-docs/JWX.md index a3a4dd9..d0acf75 100644 --- a/internal-docs/JWX.md +++ b/internal-docs/JWX.md @@ -120,18 +120,6 @@ Scoped to httpsign’s use of **jwa** + **jws** only (no JWT/JWE/JWK fetch in li - [x] Hardening: constructor `jws.AlgorithmsForKey` check; reject `NoSignature`; HMAC keys must be `[]byte`; document `SetAllowedAlgs` vs JWS alg. - [ ] Tag **`v0.6.0`** and publish. *(after merge)* -### Later action: upstream jwx / dsig (found 2026-08-26 while hardening) - -Not blocking `v0.6.0`. File / track upstream issues; optionally tighten httpsign further if upstream stays loose. - -| Finding | Severity | Notes | -|---------|----------|-------| -| **`jws.AlgorithmsForKey` ignores ECDSA curve** for raw `*ecdsa.{Private,Public}Key` | Correctness | Returns `[ES256, ES384, ES512]` for any EC key. Docs claim curve is inferred from the Go type, but `hasCrv` is never set for stdlib ECDSA; `RegisterAlgorithmForCurve` is only used for Ed25519 in init (no P-256→ES256 etc.). | -| **ECDSA Sign/Verify do not enforce RFC 7518 curve↔alg** | Spec / footgun | `ES384` effectively means SHA-384 only; a **P-256** key can mint `"alg":"ES384"` and verify with the same P-256 pub (64-byte sig). Will not verify under a real P-384 key. Policy that allows only ES384 expecting P-384 strength can accept weaker P-256+SHA-384 if a P-256 key is registered. dsig examples treat cross-curve as intentional for custom algs. | -| **`AlgorithmsForKey` lists all ML-DSA algs** for any ML-DSA key | Classifier only | Sign/Verify correctly reject parameter-set mismatch — crypto path is fine; helper is over-broad (same class of bug as ECDSA listing). | - -**Suggested upstream asks (lestrrat-go/jwx + dsig):** (1) extract curve from raw ECDSA keys in `AlgorithmsForKey` and register P-256/P-384/P-521 → ES256/ES384/ES512; (2) optionally enforce curve↔alg in ECDSA Sign/Verify; (3) refine ML-DSA listing by parameter set. **httpsign follow-up:** if upstream does not tighten ECDSA, consider our own curve check in `validateJWSKeyAlg` (and ML-DSA `Parameters()` vs `jwa.MLDSA*`). - ### Upstream items likely N/A or low priority | Topic | httpsign | From b79d72b94e3b00f1e8e6ae1648c80331c4e6dc88 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Tue, 1 Sep 2026 01:24:59 +0300 Subject: [PATCH 5/9] fix: validate foreign JWS keys without AlgorithmsForKey Replace the deprecated jws.AlgorithmsForKey check with explicit stdlib type checks in jwskey.go, including RFC 7518 ECDSA curve and HMAC key-length binding plus ML-DSA parameter-set matching at NewJWS* construction. Co-authored-by: Cursor --- CLAUDE.md | 3 +- config.go | 9 +-- crypto.go | 24 +------ crypto_test.go | 78 ++++++++++++++++++++- jwskey.go | 186 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 jwskey.go diff --git a/CLAUDE.md b/CLAUDE.md index 6b24409..2e6b529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ signatures.go ← Mid-level API: SignRequest, SignResponse, VerifyR │ message.go / httpparse.go ← RFC 9421 message canonicalization and signature base string construction │ -crypto.go / ecdsa.go ← Signer / Verifier types and algorithm implementations +crypto.go / ecdsa.go / jwskey.go ← Signer / Verifier types, native algs, foreign-JWS key checks │ fields.go / digest.go ← Component field abstraction + Content-Digest header support ``` @@ -44,6 +44,7 @@ fields.go / digest.go ← Component field abstraction + Content-Digest head ### Key types - **`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). +- Foreign JWS key↔alg checks live in **`jwskey.go`** (explicit stdlib types; does not use deprecated `jws.AlgorithmsForKey`). - **`SignConfig` / `VerifyConfig`** (`config.go`) — builder-style configuration for signature metadata (keyID, nonce, tag, expiry, clock tolerance). Constructed via `NewSignConfig()` / `NewVerifyConfig()` with method chaining. - **`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. - **`Message` / `MessageDetails`** (`message.go`) — internal canonicalized request/response representation. `MessageDetails` is the public output of `RequestDetails` / `ResponseDetails`. diff --git a/config.go b/config.go index e7d3fb7..7e88354 100644 --- a/config.go +++ b/config.go @@ -43,7 +43,8 @@ func NewSignConfig() *SignConfig { } } -// SignAlg indicates that an "alg" signature parameters must be generated and signed (default: true). +// SignAlg indicates that an "alg" signature parameter must be generated and signed (default: true). +// Must be false when using NewJWSSigner (foreign JWS has no HTTP Message Signatures algorithm id). func (c *SignConfig) SignAlg(b bool) *SignConfig { c.signAlg = b return c @@ -186,9 +187,9 @@ func (v *VerifyConfig) SetRejectExpired(rejectExpired bool) *VerifyConfig { // SetAllowedAlgs defines the allowed values of the HTTP Message Signatures "alg" parameter // (RFC 9421), not the JWS algorithm passed to NewJWSSigner/NewJWSVerifier. -// This is useful if the algorithm used in verification is taken from the message — not a recommended practice. -// With foreign JWS signers the library refuses to emit "alg", so this policy only applies when a peer -// still includes that parameter. Default: an empty list, signifying all values are accepted. +// Useful only if verification takes "alg" from the message (not recommended). NewJWSSigner +// cannot emit "alg" (see SignAlg), so this policy applies only when a peer still includes it. +// Default: empty list — all values accepted. func (v *VerifyConfig) SetAllowedAlgs(allowedAlgs []string) *VerifyConfig { v.allowedAlgs = allowedAlgs return v diff --git a/crypto.go b/crypto.go index 4d3b99c..eb07363 100644 --- a/crypto.go +++ b/crypto.go @@ -12,7 +12,6 @@ import ( "crypto/sha512" "crypto/subtle" "fmt" - "slices" "github.com/lestrrat-go/jwx/v4/jwa" "github.com/lestrrat-go/jwx/v4/jws" @@ -130,11 +129,7 @@ func NewEd25519SignerFromSeed(seed []byte, config *SignConfig, fields Fields) (* // NewJWSSigner creates a generic signer for JWS algorithms via github.com/lestrrat-go/jwx/v4. // The particular key type for each algorithm is documented in that package (including // crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string). -// Config may be nil for a default configuration. -// -// Note: foreign JWS signers do not emit the HTTP Message Signatures "alg" parameter -// (see SignConfig.SignAlg). VerifyConfig.SetAllowedAlgs therefore only applies if a peer -// still includes that parameter; it does not constrain the JWS algorithm passed here. +// Config may be nil for a default configuration; SignAlg must be false (see SignConfig.SignAlg). func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") @@ -161,20 +156,6 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi }, nil } -// validateJWSKeyAlg rejects keys that jwx does not associate with alg (wrong key family). -// It uses jws.AlgorithmsForKey; some families list multiple algs (e.g. all HS*, all ML-DSA*), -// so finer mismatches may still fail later at Sign/Verify time. -func validateJWSKeyAlg(alg jwa.SignatureAlgorithm, key interface{}) error { - algs, err := jws.AlgorithmsForKey(key) - if err != nil { - return fmt.Errorf("key is not usable for JWS signing/verification: %w", err) - } - if !slices.Contains(algs, alg) { - return fmt.Errorf("algorithm %s is not valid for key type %T", alg, key) - } - return nil -} - func (s Signer) sign(buff []byte) ([]byte, error) { if s.foreignSigner != nil { signer, ok := s.foreignSigner.(jws.Signer) @@ -351,9 +332,6 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel // crypto/mldsa keys for ML-DSA on Go 1.27+). HMAC keys must be []byte (not string). // Set config to nil for a default configuration. // Fields is the list of required headers and fields, which may be empty (but this is typically insecure). -// -// Note: SetAllowedAlgs constrains the HTTP Message Signatures "alg" parameter on the wire, -// not the JWS algorithm passed here. Foreign JWS signers omit that parameter by design. func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) { if key == nil { return nil, fmt.Errorf("key must not be nil") diff --git a/crypto_test.go b/crypto_test.go index 81d2e9c..c1e4129 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -1,7 +1,9 @@ package httpsign import ( + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/mldsa" "crypto/rand" "crypto/rsa" @@ -249,9 +251,13 @@ func TestNewRSASigner1(t *testing.T) { } func TestNewJWSSigner(t *testing.T) { - hmacKey := []byte("1234") + hmacKey := []byte(strings.Repeat("x", 32)) // RFC 7518 HS256 minimum priv, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) + p256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + mldsa44, err := mldsa.GenerateKey(mldsa.MLDSA44()) + require.NoError(t, err) tests := []struct { name string @@ -263,7 +269,15 @@ func TestNewJWSSigner(t *testing.T) { {name: "none", alg: jwa.NoSignature(), key: hmacKey, wantErr: true}, {name: "nil key", alg: jwa.HS256(), key: nil, wantErr: true}, {name: "string hmac key", alg: jwa.HS256(), key: "1234", wantErr: true}, + {name: "short hmac key", alg: jwa.HS256(), key: []byte("too-short"), wantErr: true}, + {name: "empty hmac key", alg: jwa.HS256(), key: []byte{}, wantErr: true}, {name: "key alg mismatch", alg: jwa.HS256(), key: priv, wantErr: true}, + {name: "rsa match", alg: jwa.RS256(), key: priv}, + {name: "ecdsa curve match", alg: jwa.ES256(), key: p256}, + {name: "ecdsa curve mismatch", alg: jwa.ES384(), key: p256, wantErr: true}, + {name: "mldsa params match", alg: jwa.MLDSA44(), key: mldsa44}, + {name: "mldsa params mismatch", alg: jwa.MLDSA65(), key: mldsa44, wantErr: true}, + {name: "mldsa wrong key type", alg: jwa.MLDSA44(), key: p256, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -276,16 +290,20 @@ func TestNewJWSSigner(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) require.NotNil(t, got.foreignSigner) - assert.Equal(t, hmacKey, got.key) + assert.Equal(t, tt.key, got.key) assert.Empty(t, got.alg) }) } } func TestNewJWSVerifier(t *testing.T) { - hmacKey := []byte("1234") + hmacKey := []byte(strings.Repeat("x", 32)) // RFC 7518 HS256 minimum priv, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) + p256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + mldsa44, err := mldsa.GenerateKey(mldsa.MLDSA44()) + require.NoError(t, err) type args struct { alg jwa.SignatureAlgorithm @@ -360,6 +378,60 @@ func TestNewJWSVerifier(t *testing.T) { want: nil, wantErr: true, }, + { + name: "ecdsa curve match", + args: args{ + alg: jwa.ES256(), + key: &p256.PublicKey, + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: &Verifier{ + key: &p256.PublicKey, + alg: "", + config: NewVerifyConfig(), + fields: *NewFields(), + foreignVerifier: nil, + }, + }, + { + name: "ecdsa curve mismatch", + args: args{ + alg: jwa.ES384(), + key: &p256.PublicKey, + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, + { + name: "mldsa params match", + args: args{ + alg: jwa.MLDSA44(), + key: mldsa44.Public().(*mldsa.PublicKey), + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: &Verifier{ + key: mldsa44.Public().(*mldsa.PublicKey), + alg: "", + config: NewVerifyConfig(), + fields: *NewFields(), + foreignVerifier: nil, + }, + }, + { + name: "mldsa params mismatch", + args: args{ + alg: jwa.MLDSA65(), + key: mldsa44.Public().(*mldsa.PublicKey), + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/jwskey.go b/jwskey.go new file mode 100644 index 0000000..74a95d1 --- /dev/null +++ b/jwskey.go @@ -0,0 +1,186 @@ +package httpsign + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/mldsa" + "crypto/rsa" + "fmt" + + "github.com/lestrrat-go/jwx/v4/jwa" +) + +// validateJWSKeyAlg checks that key is an appropriate Go type for alg, without using +// jws.AlgorithmsForKey (deprecated; not a compatibility API; over-broad for ECDSA/ML-DSA). +// Only raw stdlib key types are accepted; crypto.Signer / JWK wrappers are rejected here +// so callers convert to concrete keys first. +func validateJWSKeyAlg(alg jwa.SignatureAlgorithm, key interface{}) error { + switch alg { + case jwa.HS256(), jwa.HS384(), jwa.HS512(): + return validateHMACKey(alg, key) + case jwa.RS256(), jwa.RS384(), jwa.RS512(), jwa.PS256(), jwa.PS384(), jwa.PS512(): + return validateRSAKey(alg, key) + case jwa.ES256(), jwa.ES384(), jwa.ES512(): + return validateECDSAKey(alg, key) + case jwa.EdDSA(), jwa.EdDSAEd25519(): + return validateEd25519Key(alg, key) + case jwa.MLDSA44(), jwa.MLDSA65(), jwa.MLDSA87(): + return validateMLDSAKey(alg, key) + default: + return fmt.Errorf("unsupported JWS algorithm %s", alg) + } +} + +func validateHMACKey(alg jwa.SignatureAlgorithm, key interface{}) error { + k, ok := key.([]byte) + if !ok { + return fmt.Errorf("algorithm %s requires []byte key, got %T", alg, key) + } + // RFC 7518 §3.2: key at least as large as the hash output. + var minLen int + switch alg { + case jwa.HS256(): + minLen = 32 + case jwa.HS384(): + minLen = 48 + case jwa.HS512(): + minLen = 64 + default: + return fmt.Errorf("unsupported HMAC algorithm %s", alg) + } + if len(k) < minLen { + return fmt.Errorf("algorithm %s requires a key of at least %d bytes (RFC 7518), got %d", alg, minLen, len(k)) + } + return nil +} + +func validateRSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { + switch k := key.(type) { + case *rsa.PrivateKey: + if k == nil { + return fmt.Errorf("algorithm %s: nil RSA private key", alg) + } + case rsa.PrivateKey: + // ok + case *rsa.PublicKey: + if k == nil { + return fmt.Errorf("algorithm %s: nil RSA public key", alg) + } + case rsa.PublicKey: + // ok + default: + return fmt.Errorf("algorithm %s requires an RSA key, got %T", alg, key) + } + return nil +} + +// validateECDSAKey enforces RFC 7518 §3.4: ES256/ES384/ES512 bind to P-256/P-384/P-521. +// jwx's SignerFor path does not enforce this. +func validateECDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { + curve, ok := ecdsaCurveOf(key) + if !ok { + return fmt.Errorf("algorithm %s requires an ECDSA key, got %T", alg, key) + } + var want elliptic.Curve + switch alg { + case jwa.ES256(): + want = elliptic.P256() + case jwa.ES384(): + want = elliptic.P384() + case jwa.ES512(): + want = elliptic.P521() + default: + return fmt.Errorf("unsupported ECDSA algorithm %s", alg) + } + if curve != want { + return fmt.Errorf("algorithm %s requires curve %s, got %s", alg, want.Params().Name, curve.Params().Name) + } + return nil +} + +func ecdsaCurveOf(key interface{}) (elliptic.Curve, bool) { + switch k := key.(type) { + case *ecdsa.PrivateKey: + if k == nil { + return nil, false + } + return k.Curve, true + case ecdsa.PrivateKey: + return k.Curve, true + case *ecdsa.PublicKey: + if k == nil { + return nil, false + } + return k.Curve, true + case ecdsa.PublicKey: + return k.Curve, true + default: + return nil, false + } +} + +func validateEd25519Key(alg jwa.SignatureAlgorithm, key interface{}) error { + switch k := key.(type) { + case ed25519.PrivateKey: + if len(k) != ed25519.PrivateKeySize { + return fmt.Errorf("algorithm %s: Ed25519 private key must be %d bytes, got %d", alg, ed25519.PrivateKeySize, len(k)) + } + case ed25519.PublicKey: + if len(k) != ed25519.PublicKeySize { + return fmt.Errorf("algorithm %s: Ed25519 public key must be %d bytes, got %d", alg, ed25519.PublicKeySize, len(k)) + } + case *ed25519.PrivateKey: + if k == nil || len(*k) != ed25519.PrivateKeySize { + return fmt.Errorf("algorithm %s: invalid Ed25519 private key", alg) + } + case *ed25519.PublicKey: + if k == nil || len(*k) != ed25519.PublicKeySize { + return fmt.Errorf("algorithm %s: invalid Ed25519 public key", alg) + } + default: + return fmt.Errorf("algorithm %s requires an Ed25519 key, got %T", alg, key) + } + return nil +} + +// validateMLDSAKey enforces that an ML-DSA JWS algorithm matches the key's parameter set. +// jwx also rejects mismatches at Sign/Verify; this fails earlier at NewJWS* construction. +func validateMLDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { + got, ok := mldsaParamsOf(key) + if !ok { + return fmt.Errorf("algorithm %s requires a crypto/mldsa key, got %T", alg, key) + } + var want mldsa.Parameters + switch alg { + case jwa.MLDSA44(): + want = mldsa.MLDSA44() + case jwa.MLDSA65(): + want = mldsa.MLDSA65() + case jwa.MLDSA87(): + want = mldsa.MLDSA87() + default: + return fmt.Errorf("unsupported ML-DSA algorithm %s", alg) + } + if got != want { + return fmt.Errorf("algorithm %s requires ML-DSA parameter set %s, got %s", alg, want, got) + } + return nil +} + +func mldsaParamsOf(key interface{}) (mldsa.Parameters, bool) { + switch k := key.(type) { + case *mldsa.PrivateKey: + if k == nil { + return mldsa.Parameters{}, false + } + return k.PublicKey().Parameters(), true + case *mldsa.PublicKey: + if k == nil { + return mldsa.Parameters{}, false + } + return k.Parameters(), true + default: + return mldsa.Parameters{}, false + } +} From 411210fb486c8ae83ca773118b8e20a50b9875c2 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Tue, 1 Sep 2026 09:47:07 +0300 Subject: [PATCH 6/9] fix: require private keys for JWS signers and public keys for verifiers Enforce asymmetric key role at NewJWSSigner/NewJWSVerifier construction so mismatches fail early instead of at Sign/Verify time. Co-authored-by: Cursor --- crypto.go | 4 +-- crypto_test.go | 36 +++++++++++++++++++ jwskey.go | 93 ++++++++++++++++++++++++++++++++++---------------- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/crypto.go b/crypto.go index eb07363..8c725f5 100644 --- a/crypto.go +++ b/crypto.go @@ -137,7 +137,7 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } - if err := validateJWSKeyAlg(alg, key); err != nil { + if err := validateJWSKeyAlg(alg, key, true); err != nil { return nil, err } if config == nil { @@ -339,7 +339,7 @@ func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyC if alg == jwa.NoSignature() { return nil, fmt.Errorf("the NONE signing algorithm is expressly disallowed") } - if err := validateJWSKeyAlg(alg, key); err != nil { + if err := validateJWSKeyAlg(alg, key, false); err != nil { return nil, err } if config == nil { diff --git a/crypto_test.go b/crypto_test.go index c1e4129..56e8eab 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -273,9 +273,12 @@ func TestNewJWSSigner(t *testing.T) { {name: "empty hmac key", alg: jwa.HS256(), key: []byte{}, wantErr: true}, {name: "key alg mismatch", alg: jwa.HS256(), key: priv, wantErr: true}, {name: "rsa match", alg: jwa.RS256(), key: priv}, + {name: "rsa public key", alg: jwa.RS256(), key: &priv.PublicKey, wantErr: true}, {name: "ecdsa curve match", alg: jwa.ES256(), key: p256}, + {name: "ecdsa public key", alg: jwa.ES256(), key: &p256.PublicKey, wantErr: true}, {name: "ecdsa curve mismatch", alg: jwa.ES384(), key: p256, wantErr: true}, {name: "mldsa params match", alg: jwa.MLDSA44(), key: mldsa44}, + {name: "mldsa public key", alg: jwa.MLDSA44(), key: mldsa44.Public().(*mldsa.PublicKey), wantErr: true}, {name: "mldsa params mismatch", alg: jwa.MLDSA65(), key: mldsa44, wantErr: true}, {name: "mldsa wrong key type", alg: jwa.MLDSA44(), key: p256, wantErr: true}, } @@ -378,6 +381,17 @@ func TestNewJWSVerifier(t *testing.T) { want: nil, wantErr: true, }, + { + name: "rsa private key", + args: args{ + alg: jwa.RS256(), + key: priv, + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, { name: "ecdsa curve match", args: args{ @@ -405,6 +419,17 @@ func TestNewJWSVerifier(t *testing.T) { want: nil, wantErr: true, }, + { + name: "ecdsa private key", + args: args{ + alg: jwa.ES256(), + key: p256, + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, { name: "mldsa params match", args: args{ @@ -432,6 +457,17 @@ func TestNewJWSVerifier(t *testing.T) { want: nil, wantErr: true, }, + { + name: "mldsa private key", + args: args{ + alg: jwa.MLDSA44(), + key: mldsa44, + config: NewVerifyConfig(), + fields: *NewFields(), + }, + want: nil, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/jwskey.go b/jwskey.go index 74a95d1..078a4e3 100644 --- a/jwskey.go +++ b/jwskey.go @@ -13,20 +13,21 @@ import ( // validateJWSKeyAlg checks that key is an appropriate Go type for alg, without using // jws.AlgorithmsForKey (deprecated; not a compatibility API; over-broad for ECDSA/ML-DSA). -// Only raw stdlib key types are accepted; crypto.Signer / JWK wrappers are rejected here -// so callers convert to concrete keys first. -func validateJWSKeyAlg(alg jwa.SignatureAlgorithm, key interface{}) error { +// When signing is true, asymmetric keys must be private; when false, they must be public. +// HMAC keys are symmetric and accepted for either role. Only raw stdlib key types are +// accepted; crypto.Signer / JWK wrappers are rejected here so callers convert first. +func validateJWSKeyAlg(alg jwa.SignatureAlgorithm, key interface{}, signing bool) error { switch alg { case jwa.HS256(), jwa.HS384(), jwa.HS512(): return validateHMACKey(alg, key) case jwa.RS256(), jwa.RS384(), jwa.RS512(), jwa.PS256(), jwa.PS384(), jwa.PS512(): - return validateRSAKey(alg, key) + return validateRSAKey(alg, key, signing) case jwa.ES256(), jwa.ES384(), jwa.ES512(): - return validateECDSAKey(alg, key) + return validateECDSAKey(alg, key, signing) case jwa.EdDSA(), jwa.EdDSAEd25519(): - return validateEd25519Key(alg, key) + return validateEd25519Key(alg, key, signing) case jwa.MLDSA44(), jwa.MLDSA65(), jwa.MLDSA87(): - return validateMLDSAKey(alg, key) + return validateMLDSAKey(alg, key, signing) default: return fmt.Errorf("unsupported JWS algorithm %s", alg) } @@ -55,20 +56,30 @@ func validateHMACKey(alg jwa.SignatureAlgorithm, key interface{}) error { return nil } -func validateRSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { +func validateRSAKey(alg jwa.SignatureAlgorithm, key interface{}, signing bool) error { switch k := key.(type) { case *rsa.PrivateKey: if k == nil { return fmt.Errorf("algorithm %s: nil RSA private key", alg) } + if !signing { + return fmt.Errorf("algorithm %s requires an RSA public key for verification", alg) + } case rsa.PrivateKey: - // ok + if !signing { + return fmt.Errorf("algorithm %s requires an RSA public key for verification", alg) + } case *rsa.PublicKey: if k == nil { return fmt.Errorf("algorithm %s: nil RSA public key", alg) } + if signing { + return fmt.Errorf("algorithm %s requires an RSA private key for signing", alg) + } case rsa.PublicKey: - // ok + if signing { + return fmt.Errorf("algorithm %s requires an RSA private key for signing", alg) + } default: return fmt.Errorf("algorithm %s requires an RSA key, got %T", alg, key) } @@ -77,11 +88,17 @@ func validateRSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { // validateECDSAKey enforces RFC 7518 §3.4: ES256/ES384/ES512 bind to P-256/P-384/P-521. // jwx's SignerFor path does not enforce this. -func validateECDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { - curve, ok := ecdsaCurveOf(key) +func validateECDSAKey(alg jwa.SignatureAlgorithm, key interface{}, signing bool) error { + curve, isPrivate, ok := ecdsaKeyOf(key) if !ok { return fmt.Errorf("algorithm %s requires an ECDSA key, got %T", alg, key) } + if signing && !isPrivate { + return fmt.Errorf("algorithm %s requires an ECDSA private key for signing", alg) + } + if !signing && isPrivate { + return fmt.Errorf("algorithm %s requires an ECDSA public key for verification", alg) + } var want elliptic.Curve switch alg { case jwa.ES256(): @@ -99,45 +116,57 @@ func validateECDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { return nil } -func ecdsaCurveOf(key interface{}) (elliptic.Curve, bool) { +func ecdsaKeyOf(key interface{}) (curve elliptic.Curve, isPrivate, ok bool) { switch k := key.(type) { case *ecdsa.PrivateKey: if k == nil { - return nil, false + return nil, false, false } - return k.Curve, true + return k.Curve, true, true case ecdsa.PrivateKey: - return k.Curve, true + return k.Curve, true, true case *ecdsa.PublicKey: if k == nil { - return nil, false + return nil, false, false } - return k.Curve, true + return k.Curve, false, true case ecdsa.PublicKey: - return k.Curve, true + return k.Curve, false, true default: - return nil, false + return nil, false, false } } -func validateEd25519Key(alg jwa.SignatureAlgorithm, key interface{}) error { +func validateEd25519Key(alg jwa.SignatureAlgorithm, key interface{}, signing bool) error { switch k := key.(type) { case ed25519.PrivateKey: if len(k) != ed25519.PrivateKeySize { return fmt.Errorf("algorithm %s: Ed25519 private key must be %d bytes, got %d", alg, ed25519.PrivateKeySize, len(k)) } + if !signing { + return fmt.Errorf("algorithm %s requires an Ed25519 public key for verification", alg) + } case ed25519.PublicKey: if len(k) != ed25519.PublicKeySize { return fmt.Errorf("algorithm %s: Ed25519 public key must be %d bytes, got %d", alg, ed25519.PublicKeySize, len(k)) } + if signing { + return fmt.Errorf("algorithm %s requires an Ed25519 private key for signing", alg) + } case *ed25519.PrivateKey: if k == nil || len(*k) != ed25519.PrivateKeySize { return fmt.Errorf("algorithm %s: invalid Ed25519 private key", alg) } + if !signing { + return fmt.Errorf("algorithm %s requires an Ed25519 public key for verification", alg) + } case *ed25519.PublicKey: if k == nil || len(*k) != ed25519.PublicKeySize { return fmt.Errorf("algorithm %s: invalid Ed25519 public key", alg) } + if signing { + return fmt.Errorf("algorithm %s requires an Ed25519 private key for signing", alg) + } default: return fmt.Errorf("algorithm %s requires an Ed25519 key, got %T", alg, key) } @@ -146,11 +175,17 @@ func validateEd25519Key(alg jwa.SignatureAlgorithm, key interface{}) error { // validateMLDSAKey enforces that an ML-DSA JWS algorithm matches the key's parameter set. // jwx also rejects mismatches at Sign/Verify; this fails earlier at NewJWS* construction. -func validateMLDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { - got, ok := mldsaParamsOf(key) +func validateMLDSAKey(alg jwa.SignatureAlgorithm, key interface{}, signing bool) error { + got, isPrivate, ok := mldsaKeyOf(key) if !ok { return fmt.Errorf("algorithm %s requires a crypto/mldsa key, got %T", alg, key) } + if signing && !isPrivate { + return fmt.Errorf("algorithm %s requires an ML-DSA private key for signing", alg) + } + if !signing && isPrivate { + return fmt.Errorf("algorithm %s requires an ML-DSA public key for verification", alg) + } var want mldsa.Parameters switch alg { case jwa.MLDSA44(): @@ -168,19 +203,19 @@ func validateMLDSAKey(alg jwa.SignatureAlgorithm, key interface{}) error { return nil } -func mldsaParamsOf(key interface{}) (mldsa.Parameters, bool) { +func mldsaKeyOf(key interface{}) (params mldsa.Parameters, isPrivate, ok bool) { switch k := key.(type) { case *mldsa.PrivateKey: if k == nil { - return mldsa.Parameters{}, false + return mldsa.Parameters{}, false, false } - return k.PublicKey().Parameters(), true + return k.PublicKey().Parameters(), true, true case *mldsa.PublicKey: if k == nil { - return mldsa.Parameters{}, false + return mldsa.Parameters{}, false, false } - return k.Parameters(), true + return k.Parameters(), false, true default: - return mldsa.Parameters{}, false + return mldsa.Parameters{}, false, false } } From 6e7ecf1c80c7c2a34ca8b783c05883837b81ed90 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Tue, 1 Sep 2026 09:56:45 +0300 Subject: [PATCH 7/9] fix: harden against malformed ECDSA keys and empty header values Reject nil ECDSA curves at JWS key validation and in raw sign/verify paths, and return errors instead of panicking when a header map entry has no values. Co-authored-by: Cursor --- crypto_test.go | 1 + ecdsa.go | 6 ++++++ ecdsa_test.go | 10 ++++++++++ jwskey.go | 3 +++ signatures.go | 6 ++++++ signatures_test.go | 8 ++++++++ 6 files changed, 34 insertions(+) diff --git a/crypto_test.go b/crypto_test.go index 56e8eab..74a543d 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -277,6 +277,7 @@ func TestNewJWSSigner(t *testing.T) { {name: "ecdsa curve match", alg: jwa.ES256(), key: p256}, {name: "ecdsa public key", alg: jwa.ES256(), key: &p256.PublicKey, wantErr: true}, {name: "ecdsa curve mismatch", alg: jwa.ES384(), key: p256, wantErr: true}, + {name: "ecdsa nil curve", alg: jwa.ES256(), key: ecdsa.PrivateKey{}, wantErr: true}, {name: "mldsa params match", alg: jwa.MLDSA44(), key: mldsa44}, {name: "mldsa public key", alg: jwa.MLDSA44(), key: mldsa44.Public().(*mldsa.PublicKey), wantErr: true}, {name: "mldsa params mismatch", alg: jwa.MLDSA65(), key: mldsa44, wantErr: true}, diff --git a/ecdsa.go b/ecdsa.go index cc69ccd..46375ac 100644 --- a/ecdsa.go +++ b/ecdsa.go @@ -13,6 +13,9 @@ func ecdsaSignRaw(rd io.Reader, priv *ecdsa.PrivateKey, hash []byte) ([]byte, er if priv == nil { return nil, fmt.Errorf("nil private key") } + if priv.Curve == nil { + return nil, fmt.Errorf("nil curve on private key") + } r, s, err := ecdsa.Sign(rd, priv, hash) if err != nil { return nil, err @@ -35,6 +38,9 @@ func ecdsaVerifyRaw(pub *ecdsa.PublicKey, hash []byte, sig []byte) (bool, error) if pub == nil { return false, fmt.Errorf("nil public key") } + if pub.Curve == nil { + return false, fmt.Errorf("signature verification failed") + } curve := pub.Params().Name lr, ls, err := sigComponentLen(curve) if err != nil { diff --git a/ecdsa_test.go b/ecdsa_test.go index 7906939..289e590 100644 --- a/ecdsa_test.go +++ b/ecdsa_test.go @@ -78,6 +78,16 @@ func Test_ecdsaVerifyRaw(t *testing.T) { want: false, wantErr: true, }, + { + name: "nil curve", + args: args{ + pub: &ecdsa.PublicKey{}, + hash: bytes.Repeat([]byte{88}, 32), + sig: make([]byte, 64), + }, + want: false, + wantErr: true, + }, { name: "bad curve", args: args{ diff --git a/jwskey.go b/jwskey.go index 078a4e3..aa3805c 100644 --- a/jwskey.go +++ b/jwskey.go @@ -99,6 +99,9 @@ func validateECDSAKey(alg jwa.SignatureAlgorithm, key interface{}, signing bool) if !signing && isPrivate { return fmt.Errorf("algorithm %s requires an ECDSA public key for verification", alg) } + if curve == nil { + return fmt.Errorf("algorithm %s: ECDSA key has nil curve", alg) + } var want elliptic.Curve switch alg { case jwa.ES256(): diff --git a/signatures.go b/signatures.go index e3cbf3f..2d46501 100644 --- a/signatures.go +++ b/signatures.go @@ -254,6 +254,9 @@ func (message *parsedMessage) getRawHeader(hdr string, trailer bool) ([]string, if !found { return nil, fmt.Errorf("header %s not found", hdr) } + if len(vv) == 0 { + return nil, fmt.Errorf("header %s has no values", hdr) + } return vv, nil } @@ -918,6 +921,9 @@ func applyPolicyCreated(psi *psiSignature, message parsedMessage, config VerifyC if len(dateHdr) > 1 { return fmt.Errorf("multiple Date headers") } + if len(dateHdr) == 0 { + return fmt.Errorf("empty Date header") + } date, err := http.ParseTime(dateHdr[0]) if err != nil { return fmt.Errorf("cannot parse Date header: %w", err) diff --git a/signatures_test.go b/signatures_test.go index 8825410..eb03f5a 100644 --- a/signatures_test.go +++ b/signatures_test.go @@ -915,6 +915,14 @@ func TestVerifyDateWithin(t *testing.T) { }) } +func TestGetRawHeaderEmptyValues(t *testing.T) { + msg := &parsedMessage{headers: http.Header{"x-custom": {}}} + _, err := msg.getRawHeader("x-custom", false) + if err == nil { + t.Fatal("expected error for header with no values") + } +} + // Same as TestCreated but using Message func TestMessageCreated(t *testing.T) { testOnceWithConfig := func(t *testing.T, createdTime int64, verifyConfig *VerifyConfig, wantSuccess bool) { From ccf8fd736a05a2fdd33c951482f7b02d1b95c5e4 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Tue, 1 Sep 2026 10:00:06 +0300 Subject: [PATCH 8/9] fix: guard nil client config and signer/verifier config misuse Default nil ClientConfig in NewClient and return errors from sign/verify paths instead of panicking when Signer or Verifier config is unset. Co-authored-by: Cursor --- client.go | 4 ++++ client_test.go | 6 ++++++ signatures.go | 15 +++++++++++++++ signatures_test.go | 16 ++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/client.go b/client.go index 53635c6..bfb6a3c 100644 --- a/client.go +++ b/client.go @@ -21,7 +21,11 @@ type Client struct { } // NewClient constructs a new client, with the flexibility of including a custom http.Client. +// config may be nil for a default configuration. func NewClient(client http.Client, config *ClientConfig) *Client { + if config == nil { + config = NewClientConfig() + } return &Client{config: *config, client: client} } diff --git a/client_test.go b/client_test.go index a49b194..e1f2e25 100644 --- a/client_test.go +++ b/client_test.go @@ -302,3 +302,9 @@ func TestClient_PostForm(t *testing.T) { }) } } + +func TestNewDefaultClientNilConfig(t *testing.T) { + c := NewDefaultClient(nil) + assert.NotNil(t, c) + assert.NoError(t, validateClient(c)) +} diff --git a/signatures.go b/signatures.go index 2d46501..0fe6027 100644 --- a/signatures.go +++ b/signatures.go @@ -417,6 +417,9 @@ func signRequestDebug(signatureName string, signer Signer, req *http.Request) (s if signatureName == "" { return "", "", "", fmt.Errorf("empty signature name") } + if signer.config == nil { + return "", "", "", fmt.Errorf("nil signer config") + } withTrailers, err := signer.fields.hasTrailerFields(false) if err != nil { return "", "", "", err @@ -443,6 +446,9 @@ func signResponseDebug(signatureName string, signer Signer, res *http.Response, if signatureName == "" { return "", "", "", fmt.Errorf("empty signature name") } + if signer.config == nil { + return "", "", "", fmt.Errorf("nil signer config") + } resWithTrailers, err := signer.fields.hasTrailerFields(false) if err != nil { return "", "", "", err @@ -480,6 +486,9 @@ func VerifyRequest(signatureName string, verifier Verifier, req *http.Request) e } func verifyRequestDebug(signatureName string, verifier Verifier, req *http.Request) (signatureBase string, err error) { + if verifier.config == nil { + return "", fmt.Errorf("nil verifier config") + } config := NewMessageConfig().WithRequest(req) if s := resolvedScheme(verifier.config.schemeFromRequest, req); s != "" { config = config.WithScheme(s) @@ -514,6 +523,9 @@ func verifyDebug(signatureName string, verifier Verifier, message *Message) (str if signatureName == "" { return "", nil, fmt.Errorf("empty signature name") } + if verifier.config == nil { + return "", nil, fmt.Errorf("nil verifier config") + } withTrailers, wantSigRaw, psiSig, err := extractSignatureFields( signatureName, &verifier, message.headers, message.trailers, message.body, verifier.config.maxBodySize) @@ -665,6 +677,9 @@ func VerifyResponse(signatureName string, verifier Verifier, res *http.Response, } func verifyResponseDebug(signatureName string, verifier Verifier, res *http.Response, req *http.Request) (signatureBase string, err error) { + if verifier.config == nil { + return "", fmt.Errorf("nil verifier config") + } config := NewMessageConfig() if s := resolvedScheme(verifier.config.schemeFromRequest, req); s != "" { config = config.WithScheme(s) diff --git a/signatures_test.go b/signatures_test.go index eb03f5a..fa42d8a 100644 --- a/signatures_test.go +++ b/signatures_test.go @@ -923,6 +923,22 @@ func TestGetRawHeaderEmptyValues(t *testing.T) { } } +func TestSignRequestNilSignerConfig(t *testing.T) { + _, _, err := SignRequest("sig1", Signer{fields: *NewFields()}, readRequest(httpreq1)) + if err == nil { + t.Fatal("expected error") + } + assert.Contains(t, err.Error(), "nil signer config") +} + +func TestVerifyRequestNilVerifierConfig(t *testing.T) { + err := VerifyRequest("sig1", Verifier{fields: *NewFields()}, readRequest(httpreq1)) + if err == nil { + t.Fatal("expected error") + } + assert.Contains(t, err.Error(), "nil verifier config") +} + // Same as TestCreated but using Message func TestMessageCreated(t *testing.T) { testOnceWithConfig := func(t *testing.T, createdTime int64, verifyConfig *VerifyConfig, wantSuccess bool) { From 03fa6bbd32f804634eadd048b9dce6526d4c04c8 Mon Sep 17 00:00:00 2001 From: Yaron Sheffer Date: Tue, 1 Sep 2026 19:42:56 +0300 Subject: [PATCH 9/9] docs: add v0.6.0 release notes draft and README upgrade guide Document jwx v4 migration, ML-DSA foreign JWS usage, and SignAlg/SetAllowedAlgs for v0.6.0 callers. Co-authored-by: Cursor --- README.md | 32 ++++++++++++++++- internal-docs/README.md | 1 + internal-docs/RELEASE-v0.6.0.md | 63 +++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 internal-docs/RELEASE-v0.6.0.md diff --git a/README.md b/README.md index 3cfe47b..fc44b4a 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,39 @@ in the [API reference](https://pkg.go.dev/github.com/yaronf/httpsign). serverText, _ := io.ReadAll(res.Body) _ = res.Body.Close() ``` + +### Upgrading from v0.5.x + +**v0.6.0** is a breaking release for foreign-JWS users and raises the Go floor to **1.27+**. + +| Caller | Change | +|--------|--------| +| Native algorithms only (RSA, ECDSA, Ed25519, HMAC) | Upgrade Go to 1.27+; no API changes. | +| `NewJWSSignerV3` / `NewJWSVerifierV3` | Use `NewJWSSigner` / `NewJWSVerifier` with `github.com/lestrrat-go/jwx/v4/jwa`. | +| `NewJWSSigner` / `NewJWSVerifier` (jwx v2) | Same: v4 import path; algorithms are functions (`jwa.ES256()`, not string constants). | + +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`. + +Full migration notes: [internal-docs/RELEASE-v0.6.0.md](internal-docs/RELEASE-v0.6.0.md) (maintainers: paste **Summary** into the GitHub release). + +### Foreign JWS and ML-DSA + +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`). + +**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). + +```go +priv, _ := mldsa.GenerateKey(mldsa.MLDSA65()) +pub := priv.Public().(*mldsa.PublicKey) +signer, _ := httpsign.NewJWSSigner(jwa.MLDSA65(), priv, + httpsign.NewSignConfig().SignAlg(false), fields) +verifier, _ := httpsign.NewJWSVerifier(jwa.MLDSA65(), pub, httpsign.NewVerifyConfig(), fields) +``` + +HMAC keys must be `[]byte` (minimum length per RFC 7518). + ### Notes and Missing Features * Requires **Go 1.27+**. -* Optional foreign JWS (including **ML-DSA** via `crypto/mldsa`) uses [`lestrrat-go/jwx/v4`](https://github.com/lestrrat-go/jwx) through `NewJWSSigner` / `NewJWSVerifier`. * The `Accept-Signature` header is unimplemented. * 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. * **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. diff --git a/internal-docs/README.md b/internal-docs/README.md index cb3736e..6bfe50d 100644 --- a/internal-docs/README.md +++ b/internal-docs/README.md @@ -5,6 +5,7 @@ This directory contains internal documentation for maintainers of the httpsign l ## Contents - **JWX.md** — Optional jwx / foreign-JWS: cut over to jwx v4.4.0+ on Go 1.27+ as **httpsign `v0.6.0`**, with **ML-DSA PQ signatures** as an explicit goal. Gate met 2026-08-26. +- **RELEASE-v0.6.0.md** — Draft GitHub release text and upgrade guide for **v0.6.0** (copy Summary into the release when tagging). ## Purpose diff --git a/internal-docs/RELEASE-v0.6.0.md b/internal-docs/RELEASE-v0.6.0.md new file mode 100644 index 0000000..eb76687 --- /dev/null +++ b/internal-docs/RELEASE-v0.6.0.md @@ -0,0 +1,63 @@ +# Release notes draft: httpsign v0.6.0 + +Copy the **Summary** section below into the GitHub release when tagging `v0.6.0`. + +--- + +## Summary + +**Breaking release:** requires **Go 1.27+** and replaces dual `jwx/v2` + `jwx/v3` foreign-JWS support with a single **`github.com/lestrrat-go/jwx/v4`** dependency (≥ v4.4.0). + +### Highlights + +- **One foreign-JWS API:** `NewJWSSigner` / `NewJWSVerifier` only (`NewJWSSignerV3`, `NewJWSVerifierV3`, and the old v2-typed overloads are removed). +- **Post-quantum (ML-DSA):** sign and verify HTTP messages with `crypto/mldsa` keys and `jwa.MLDSA44()` / `MLDSA65()` / `MLDSA87()` through the same constructors (Go 1.27 stdlib; no extra modules). +- **Constructor hardening:** foreign JWS keys are validated at `NewJWS*` time (algorithm family, ECDSA curve bind per RFC 7518, HMAC minimum length, private key for signers / public key for verifiers). `jwa.NoSignature()` is rejected. +- **Native algorithms unchanged** in API shape (HMAC-SHA256, RSA, RSA-PSS, P-256/P-384, Ed25519); only the Go toolchain floor moves to 1.27. + +### Upgrade from v0.5.x + +| You use | Action | +|---------|--------| +| **Native signers/verifiers only** | Bump Go to **1.27+** and upgrade httpsign. No API changes. | +| **`NewJWSSignerV3` / `NewJWSVerifierV3`** | Rename to `NewJWSSigner` / `NewJWSVerifier`; change `github.com/lestrrat-go/jwx/v3/jwa` → `.../jwx/v4/jwa`. | +| **`NewJWSSigner` / `NewJWSVerifier` (v2)** | Same as above: v4 import path and algorithm values (e.g. `jwa.ES256()`). | +| **Foreign JWS signing** | Keep `SignConfig.SignAlg(false)` — RFC 9421 has no `alg` parameter for arbitrary JWS algorithms. | +| **Foreign JWS verifying** | `VerifyConfig.SetAllowedAlgs` filters the HTTP **Signature** `alg` parameter if present; it does **not** select the JWS algorithm (that comes from `NewJWSVerifier`). | + +**jwx v4 algorithm values** are functions, not string constants: + +```go +import "github.com/lestrrat-go/jwx/v4/jwa" + +signer, err := httpsign.NewJWSSigner(jwa.ES256(), privKey, config.SignAlg(false), fields) +verifier, err := httpsign.NewJWSVerifier(jwa.ES256(), &privKey.PublicKey, verifyConfig, fields) +``` + +**ML-DSA example** (RFC 9421 does not register HTTP-sig algorithm names for ML-DSA; use foreign JWS with `SignAlg(false)`): + +```go +priv, _ := mldsa.GenerateKey(mldsa.MLDSA65()) +pub := priv.Public().(*mldsa.PublicKey) +config := httpsign.NewSignConfig().SignAlg(false) + +signer, _ := httpsign.NewJWSSigner(jwa.MLDSA65(), priv, config, fields) +verifier, _ := httpsign.NewJWSVerifier(jwa.MLDSA65(), pub, httpsign.NewVerifyConfig(), fields) +``` + +HMAC keys for foreign JWS must be `[]byte` (not `string`), at least 32/48/64 bytes for HS256/384/512 per RFC 7518. + +### Toolchain + +- **Go:** 1.27.0+ (`encoding/json/v2` in stdlib; no `GOEXPERIMENT=jsonv2`). +- **jwx:** v4.4.0+ only; v2 and v3 are no longer pulled transitively. + +### Upstream references + +- [jwx v4 MIGRATION.md](https://github.com/lestrrat-go/jwx/blob/v4.4.0/MIGRATION.md) +- [jwx v4 Changes-v4.md](https://github.com/lestrrat-go/jwx/blob/v4.4.0/Changes-v4.md) + +### Also in v0.6.0 (non-breaking behavior fixes) + +- `SetVerifyDateWithin` now correctly reads the HTTP `Date` header (lowercase key in parsed messages). +- Defensive checks for malformed ECDSA keys, empty header value lists, and nil client/signer/verifier config. \ No newline at end of file