Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ 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
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,23 @@ 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
```

### 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`.
- **`HandlerConfig` / `ClientConfig`** (`config.go`) — configures server-side and client-side HTTP wrappers.

### 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

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +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+**.
* 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.
Expand Down
4 changes: 4 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}

Expand Down
6 changes: 6 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
11 changes: 7 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -184,9 +185,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.
// 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
Expand Down
139 changes: 28 additions & 111 deletions crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -131,51 +126,24 @@ 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+). HMAC keys must be []byte (not string).
// 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")
}
if alg == jwa.NoSignature {
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 {
if err := validateJWSKeyAlg(alg, key, true); 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() {
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
}
Expand All @@ -190,21 +158,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":
Expand Down Expand Up @@ -369,51 +327,25 @@ 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+). 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: 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")
}
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 {
if err := validateJWSKeyAlg(alg, key, false); 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
}
Expand All @@ -428,31 +360,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 {
Expand Down
Loading