diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a492c2e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# See https://editorconfig.org + +# top-most EditorConfig file +root = true + +# Go files +[*._test.go] +# Preserve trailing whitespace in tests since some depend on it +trim_trailing_whitespace = false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59ad5f3..868f7e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,13 +3,15 @@ name: Test jobs: test: runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.24' cache: false - name: Test run: go test ./... diff --git a/README.md b/README.md index 596fca3..bf11fb1 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ in the [API reference](https://pkg.go.dev/github.com/yaronf/httpsign). * 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. +### Contributing +Contributions to this project are welcome, both as issues and pull requests. + [![Go Reference](https://pkg.go.dev/badge/github.com/yaronf/httpsign.svg)](https://pkg.go.dev/github.com/yaronf/httpsign) [![Test](https://github.com/yaronf/httpsign/actions/workflows/test.yml/badge.svg)](https://github.com/yaronf/httpsign/actions/workflows/test.yml) [![GoReportCard example](https://goreportcard.com/badge/github.com/yaronf/httpsign)](https://goreportcard.com/report/github.com/yaronf/httpsign) diff --git a/crypto.go b/crypto.go index 8b3cf02..4f797ce 100644 --- a/crypto.go +++ b/crypto.go @@ -12,8 +12,14 @@ import ( "crypto/sha512" "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" ) // Signer includes a cryptographic key (typically a private key) and configuration of what needs to be signed. @@ -28,7 +34,7 @@ type Signer struct { // NewHMACSHA256Signer returns a new Signer structure. Key must be at least 64 bytes long. // Config may be nil for a default configuration. func NewHMACSHA256Signer(key []byte, config *SignConfig, fields Fields) (*Signer, error) { - if key == nil || len(key) < 64 { + if len(key) < 64 { return nil, fmt.Errorf("key must be at least 64 bytes long") } if config == nil { @@ -125,9 +131,11 @@ 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 package. The particular key type for each algorithm +// 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. 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") @@ -135,6 +143,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 config == nil { + config = NewSignConfig() + } jwsSigner, err := jws.NewSigner(alg) if err != nil { return nil, err @@ -148,16 +159,52 @@ func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfi }, 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) + if err != nil { + return nil, err + } + return &Signer{ + key: key, + alg: "", + config: config, + fields: fields, + foreignSigner: jwsSigner, + }, nil +} + func (s Signer) sign(buff []byte) ([]byte, error) { if s.foreignSigner != nil { - switch signer := s.foreignSigner.(type) { - case jws.Signer: - { - return signer.Sign(buff, s.key) - } - default: - return nil, fmt.Errorf("expected jws.Signer, got %T", s.foreignSigner) + // 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 } + + return nil, fmt.Errorf("expected jws.Signer or Signer2 interface, got %T", s.foreignSigner) } switch s.alg { case "hmac-sha256": @@ -190,6 +237,9 @@ func (s Signer) sign(buff []byte) ([]byte, error) { return ecdsaSignRaw(rand.Reader, &key, hashed[:]) case "ed25519": key := s.key.(ed25519.PrivateKey) + if len(key) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("key must be %d bytes long", ed25519.PrivateKeySize) + } return ed25519.Sign(key, buff), nil default: return nil, fmt.Errorf("sign: unknown algorithm \"%s\"", s.alg) @@ -297,9 +347,11 @@ func NewEd25519Verifier(key ed25519.PublicKey, config *VerifyConfig, fields Fiel }, nil } -// NewJWSVerifier creates a generic verifier for JWS algorithms, using the go-jwx package. The particular key type for each algorithm +// 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. // 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") @@ -323,18 +375,60 @@ func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyC }, 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) + if err != nil { + return nil, err + } + return &Verifier{ + key: key, + alg: "", + config: config, + fields: fields, + foreignVerifier: verifier, + }, nil +} + func (v Verifier) verify(buff []byte, sig []byte) (bool, error) { if v.foreignVerifier != nil { - switch verifier := v.foreignVerifier.(type) { - case jws.Verifier: - err := verifier.Verify(buff, sig, v.key) + // 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 false, err } return true, nil - default: - return false, fmt.Errorf("expected jws.Verifier, got %T", v.foreignVerifier) } + + // 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 + } + 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 false, err + } + return true, nil + } + + return false, fmt.Errorf("expected jws.Verifier or Verifier2 interface, got %T", v.foreignVerifier) } switch v.alg { diff --git a/crypto_test.go b/crypto_test.go index 98492c9..70d95e4 100644 --- a/crypto_test.go +++ b/crypto_test.go @@ -1,14 +1,22 @@ package httpsign import ( + "crypto/ed25519" "crypto/rand" "crypto/rsa" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/stretchr/testify/assert" "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/stretchr/testify/assert" ) func TestNewHMACSHA256Signer(t *testing.T) { @@ -63,7 +71,7 @@ func TestNewHMACSHA256Signer(t *testing.T) { func TestSigner_sign(t *testing.T) { type fields struct { - key interface{} + key any alg string } type args struct { @@ -100,6 +108,18 @@ func TestSigner_sign(t *testing.T) { want: nil, wantErr: true, }, + { + name: "ed25519 key not 64 bytes", + fields: fields{ + key: ed25519.PrivateKey(strings.Repeat("a", 63)), + alg: "ed25519", + }, + args: args{ + buff: []byte("abc"), + }, + want: nil, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -149,13 +169,47 @@ func TestForeignSigner(t *testing.T) { } } +// Same as TestForeignSigner but using Message +func TestMessageForeignSigner(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 := NewJWSSigner(jwa.ES256, priv, config.SetKeyID("key1"), fields) + if err != nil { + t.Errorf("Failed to create JWS signer") + } + 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 := NewJWSVerifier(jwa.ES256, pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), fields) + if err != nil { + t.Errorf("could not generate 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 makeRSAPrivateKey() *rsa.PrivateKey { priv, _ := rsa.GenerateKey(rand.Reader, 2048) return priv } func TestNewRSASigner1(t *testing.T) { type args struct { - keyID string key *rsa.PrivateKey config *SignConfig fields Fields @@ -201,7 +255,7 @@ func TestNewRSASigner1(t *testing.T) { func TestNewJWSVerifier(t *testing.T) { type args struct { alg jwa.SignatureAlgorithm - key interface{} + key any keyID string config *VerifyConfig fields Fields @@ -292,3 +346,210 @@ func TestVerify(t *testing.T) { _, err = v.verify([]byte{1, 2, 3}, []byte{4, 5, 6}) 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) + } + + 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) + } +} + +// 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) + } + 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) + } + }) + } +} + +// 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/doc.go b/doc.go index 3fc0fea..3b8f14c 100644 --- a/doc.go +++ b/doc.go @@ -6,4 +6,5 @@ // WrapHandler installs a wrapper around a normal HTTP message handler. // Digest functionality (creation and validation of the Content-Digest header) is available automatically // through the Client and WrapHandler interfaces, otherwise it is available separately. +// Use Message and its Verify method if you need more flexibility such as in a non-HTTP context. package httpsign diff --git a/fuzz_test.go b/fuzz_test.go index 708ba51..738edfb 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -2,8 +2,11 @@ package httpsign import ( "encoding/base64" - "github.com/stretchr/testify/assert" + "net/http" + "net/url" "testing" + + "github.com/stretchr/testify/assert" ) var httpreq1pssNoSig = `POST /foo?param=Value&Pet=dog HTTP/1.1 @@ -55,6 +58,50 @@ func FuzzVerifyRequest(f *testing.F) { }) } +// Same as FuzzVerifyRequest but using Message +func FuzzMessageVerifyRequest(f *testing.F) { + type inputs struct { + req, sigInput, sig string + } + testcases := []inputs{ + {httpreq1pssNoSig, + "sig-b21=();created=1618884473;keyid=\"test-key-rsa-pss\";nonce=\"b3k2pp5k7z-50gnwp.yemd\"", + "sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==:", + }, + {httpreq1pssNoSig, + "sig-b21=(date);created=1618884473;keyid=\"test-key-rsa-pss\";nonce=\"xxxb3k5k7z-50gnwp.yemd\"", + "sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==:", + }, + {httpreq1pssNoSig, + "sig-b21=(some-field;tr);created=1618884473;keyid=\"test-key-rsa-pss\";nonce=\"xxxb3k5k7z-50gnwp.yemd\"", + "sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==:", + }, + {httpreq1pssNoSig, + "sig-b22=(some-field;tr;bs);created=1618884473;keyid=\"test-key-rsa-pss\";nonce=\"xxxb3k5k7z-50gnwp.yemd\"", + "sig-b22=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==:", + }, + } + for _, tc := range testcases { + f.Add(tc.req, tc.sigInput, tc.sig) // Use f.Add to provide a seed corpus + } + f.Fuzz(func(t *testing.T, reqString, sigInput, sig string) { + req := readRequest(reqString) + if req != nil { + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) + } + + sigName := "sig-b21" + verifier := makeRSAVerifier(f, "key1", *NewFields()) + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + if err != nil { + t.Errorf("Failed to create Message") + } + _, _ = msg.Verify(sigName, verifier) + // only report panics + }) +} + func FuzzSignAndVerifyHMAC(f *testing.F) { type inputs struct { req string @@ -83,3 +130,130 @@ func FuzzSignAndVerifyHMAC(f *testing.F) { } }) } + +// Same as FuzzSignAndVerifyHMAC but using Message +func FuzzMessageSignAndVerifyHMAC(f *testing.F) { + type inputs struct { + req string + } + testcases := []inputs{ + {httpreq1}, + } + for _, tc := range testcases { + f.Add(tc.req) + } + f.Fuzz(func(t *testing.T, reqString string) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475) + fields := Headers("@authority", "date", "content-type") + signatureName := "sig1" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signer, _ := NewHMACSHA256Signer(key, config.SetKeyID("test-shared-secret"), fields) + req := readRequest(reqString) + sigInput, sig, err := SignRequest(signatureName, *signer, req) + if err == nil { + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + if err != nil { + t.Errorf("Failed to create Message") + } + _, err = msg.Verify(signatureName, *verifier) + assert.NoError(t, err, "verification error") + } + }) +} + +func FuzzMessageVerify(f *testing.F) { + f.Add("GET", "https://example.com/path", "example.com", "https", 0, "", "", "", "", true, false) + f.Add("POST", "https://api.example.com", "api.example.com", "https", 0, "", "", "", "", false, true) + f.Add("", "", "", "", 200, "GET", "https://example.com", "example.com", "https", true, false) + f.Add("PUT", "", "", "http", 0, "", "", "", "", false, false) + f.Add("", "", "", "", 404, "", "", "", "", false, false) + f.Add("0", "%", "0", "0", 0, "", "", "", "", true, false) + + f.Fuzz(func(t *testing.T, method, urlStr, authority, scheme string, statusCode int, + assocMethod, assocURLStr, assocAuthority, assocScheme string, + hasHeaders, hasTrailers bool) { + + config := NewMessageConfig() + + if method != "" { + config = config.WithMethod(method) + } + if urlStr != "" { + u, err := url.Parse(urlStr) + if err == nil { + config = config.WithURL(u) + } + } + if authority != "" { + config = config.WithAuthority(authority) + } + if scheme != "" { + config = config.WithScheme(scheme) + } + + if statusCode > 0 { + config = config.WithStatusCode(statusCode) + } + + if hasHeaders { + headers := http.Header{ + "Content-Type": []string{"application/json"}, + "X-Test": []string{"fuzz"}, + } + config = config.WithHeaders(headers) + } + if hasTrailers { + trailers := http.Header{ + "X-Trailer": []string{"test"}, + } + config = config.WithTrailers(trailers) + } + + if statusCode > 0 && assocMethod != "" { + var assocURL *url.URL + if assocURLStr != "" { + assocURL, _ = url.Parse(assocURLStr) + } + assocHeaders := http.Header{"X-Assoc": []string{"test"}} + config = config.WithAssociatedRequest(assocMethod, assocURL, assocHeaders, assocAuthority, assocScheme) + } + + msg, err := NewMessage(config) + + if err == nil { + if msg.headers == nil && msg.method != "" { + t.Errorf("Request message created without headers") + } + if msg.headers == nil && msg.statusCode != nil { + t.Errorf("Response message created without headers") + } + + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + verifier, _ := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false), Fields{}) + + if msg.headers != nil { + msg.headers.Set("Signature-Input", `sig1=("@method");created=1618884473;keyid="test-key"`) + msg.headers.Set("Signature", `sig1=:test:`) + } + + _, _ = msg.Verify("sig1", *verifier) + } + + if err != nil { + hasRequest := method != "" + hasResponse := statusCode > 0 + + if !hasRequest && !hasResponse { + assert.Contains(t, err.Error(), "must have either method") + } else if hasRequest && hasResponse { + assert.Contains(t, err.Error(), "cannot have both request and response") + } else if (hasRequest || hasResponse) && !hasHeaders { + assert.Contains(t, err.Error(), "must have headers") + } + } + }) +} diff --git a/go.mod b/go.mod index 9a01bb2..bf6fe1f 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,35 @@ module github.com/yaronf/httpsign -go 1.20 +go 1.24.0 + +toolchain go1.24.1 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/stretchr/testify v1.10.0 + github.com/lestrrat-go/jwx/v3 v3.0.12 + github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // 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.2 // 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.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.28.0 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a9b3b31..d1d4d75 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo 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.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +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= @@ -14,34 +14,46 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN 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.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= 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= diff --git a/handler_test.go b/handler_test.go index dc8002e..5f9f2ce 100644 --- a/handler_test.go +++ b/handler_test.go @@ -3,13 +3,14 @@ package httpsign import ( "bytes" "fmt" - "github.com/stretchr/testify/assert" "io" "log" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func Test_WrapHandler(t *testing.T) { @@ -62,7 +63,7 @@ func TestWrapHandlerServerSigns(t *testing.T) { // Callback to let the server locate its signing key and configuration var signConfig *SignConfig if !earlyExpires { - signConfig = nil + signConfig = NewSignConfig() } else { signConfig = NewSignConfig().SetExpires(2000) } diff --git a/http2_test.go b/http2_test.go index 8984c0f..9371a35 100644 --- a/http2_test.go +++ b/http2_test.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "crypto/tls" - "github.com/andreyvit/diff" "io" "net/http" "net/http/httptest" @@ -12,6 +11,8 @@ import ( "strings" "testing" "text/template" + + "github.com/andreyvit/diff" ) var wantFields = `"kuku": my awesome header @@ -96,6 +97,61 @@ func testHTTP(t *testing.T, proto string) { simpleClient(t, proto, simpleHandler) } +func testMessageHTTP(t *testing.T, proto string) { + simpleHandler := func(w http.ResponseWriter, r *http.Request) { + reqProto := r.Proto + if reqProto != proto { + t.Errorf("expected %s, got %s", proto, reqProto) + } + var scheme string + if ts.TLS == nil { + scheme = "http" + } else { + scheme = "https" + } + sp := bytes.Split([]byte(ts.URL), []byte(":")) + portval, err := strconv.Atoi(string(sp[2])) + if err != nil { + t.Errorf("cannot parse server port number") + } + tpl, err := template.New("fields").Parse(wantFields) + if err != nil { + t.Errorf("could not parse template") + } + type inputs struct { + Port int + Scheme string + } + // Use the Template facility to create the list of expected signed fields + wf, err := execTemplate(*tpl, "fields", inputs{Port: portval, Scheme: scheme}) + if err != nil { + t.Errorf("execTemplate failed") + } + verifier, err := NewHMACSHA256Verifier(bytes.Repeat([]byte{0x03}, 64), + NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), + Headers("@query")) + if err != nil { + t.Errorf("could not create verifier") + } + msg, err := NewMessage(NewMessageConfig().WithRequest(r)) + if err != nil { + t.Errorf("could not create message") + } + sigInput, _, err := verifyDebug("sig1", *verifier, msg) + if err != nil { + t.Errorf("failed to verify request: sig input: %s\nerr: %v", sigInput, err) + } + + if sigInput != wf { + t.Errorf("unexpected fields: %s\n", diff.CharacterDiff(sigInput, wantFields)) + } + w.WriteHeader(200) + } + + // And run the client code... + simpleClient(t, proto, simpleHandler) +} + func simpleClient(t *testing.T, proto string, simpleHandler func(w http.ResponseWriter, r *http.Request)) { // Client code switch proto { @@ -153,8 +209,10 @@ func simpleClient(t *testing.T, proto string, simpleHandler func(w http.Response func TestHTTP11(t *testing.T) { testHTTP(t, "HTTP/1.1") + testMessageHTTP(t, "HTTP/1.1") } func TestHTTP20(t *testing.T) { testHTTP(t, "HTTP/2.0") + testMessageHTTP(t, "HTTP/2.0") } diff --git a/httpparse.go b/httpparse.go index c7e1232..5354700 100644 --- a/httpparse.go +++ b/httpparse.go @@ -22,42 +22,26 @@ func parseRequest(req *http.Request, withTrailers bool) (*parsedMessage, error) if req == nil { return nil, nil } - err := validateMessageHeaders(req.Header) - if err != nil { - return nil, err - } - if withTrailers { - _, err = duplicateBody(&req.Body) // read the entire body to populate the trailers - if err != nil { - return nil, fmt.Errorf("cannot duplicate request body: %w", err) - } - err = validateMessageHeaders(req.Trailer) - if err != nil { - return nil, fmt.Errorf("could not validate trailers: %w", err) - } + + scheme := "http" + if req.TLS != nil { + scheme = "https" } - // Query params are only obtained from the URL (i.e. not from the message body, when using application/x-www-form-urlencoded) - // So we are not vulnerable to the issue described in Sec. "Ambiguous Handling of Query Elements" of the draft. - values, err := url.ParseQuery(req.URL.RawQuery) - if err != nil { - return nil, fmt.Errorf("cannot parse query: %s", req.URL.RawQuery) - } - escaped := reEncodeQPs(values) - u := req.URL - if u.Host == "" { - u.Host = req.Host - } - if u.Scheme == "" { - if req.TLS == nil { - u.Scheme = "http" - } else { - u.Scheme = "https" - } + + msg := &Message{ + method: req.Method, + url: req.URL, + headers: req.Header, + trailers: req.Trailer, + body: &req.Body, + authority: req.Host, + scheme: scheme, } - return &parsedMessage{derived: generateReqDerivedComponents(req), url: u, headers: normalizeHeaderNames(req.Header), - trailers: normalizeHeaderNames(req.Trailer), qParams: escaped}, nil + + return parseMessage(msg, withTrailers) } +//lint:ignore ST1003 QPs is intentional abbreviation for Query Parameters func reEncodeQPs(values url.Values) url.Values { escaped := url.Values{} for key, v := range values { // Re-escape query parameters, both names and values @@ -82,23 +66,14 @@ func normalizeHeaderNames(header http.Header) http.Header { } func parseResponse(res *http.Response, withTrailers bool) (*parsedMessage, error) { - err := validateMessageHeaders(res.Header) - if err != nil { - return nil, err - } - if withTrailers { - _, err = duplicateBody(&res.Body) // read the entire body to populate the trailers - if err != nil { - return nil, fmt.Errorf("cannot duplicate request body: %w", err) - } - err = validateMessageHeaders(res.Trailer) - if err != nil { - return nil, fmt.Errorf("could not validate trailers: %w", err) - } + msg := &Message{ + statusCode: &res.StatusCode, + headers: res.Header, + trailers: res.Trailer, + body: &res.Body, } - return &parsedMessage{derived: generateResDerivedComponents(res), url: nil, - headers: normalizeHeaderNames(res.Header)}, nil + return parseMessage(msg, withTrailers) } func validateMessageHeaders(header http.Header) error { @@ -112,6 +87,9 @@ func validateMessageHeaders(header http.Header) error { } func foldFields(fields []string) string { + if len(fields) == 0 { + return "" + } ff := strings.TrimSpace(fields[0]) for i := 1; i < len(fields); i++ { ff += ", " + strings.TrimSpace(fields[i]) @@ -123,17 +101,14 @@ func derivedComponent(name, v string, components components) { components[name] = v } -func generateReqDerivedComponents(req *http.Request) components { - components := components{} - derivedComponent("@method", scMethod(req), components) - theURL := req.URL - derivedComponent("@target-uri", scTargetURI(theURL), components) - derivedComponent("@path", scPath(theURL), components) - derivedComponent("@authority", scAuthority(req), components) - derivedComponent("@scheme", scScheme(theURL), components) - derivedComponent("@request-target", scRequestTarget(theURL), components) - derivedComponent("@query", scQuery(theURL), components) - return components +func generateReqDerivedComponents(method string, u *url.URL, authority string, components components) { + derivedComponent("@method", method, components) + derivedComponent("@target-uri", scTargetURI(u), components) + derivedComponent("@path", scPath(u), components) + derivedComponent("@authority", authority, components) + derivedComponent("@scheme", scScheme(u), components) + derivedComponent("@request-target", scRequestTarget(u), components) + derivedComponent("@query", scQuery(u), components) } func scPath(theURL *url.URL) string { @@ -162,24 +137,81 @@ func scScheme(url *url.URL) string { return url.Scheme } -func scAuthority(req *http.Request) string { - return req.Host -} - func scTargetURI(url *url.URL) string { return url.String() } -func scMethod(req *http.Request) string { - return req.Method +func scStatus(statusCode int) string { + return strconv.Itoa(statusCode) } -func generateResDerivedComponents(res *http.Response) components { - components := components{} - derivedComponent("@status", scStatus(res), components) - return components -} +func parseMessage(msg *Message, withTrailers bool) (*parsedMessage, error) { + if msg == nil { + return nil, nil + } + + err := validateMessageHeaders(msg.headers) + if err != nil { + return nil, err + } + + if withTrailers { + if msg.body != nil { + _, err = duplicateBody(msg.body) + if err != nil { + return nil, fmt.Errorf("cannot duplicate message body: %w", err) + } + } + err = validateMessageHeaders(msg.trailers) + if err != nil { + return nil, fmt.Errorf("could not validate trailers: %w", err) + } + } + + derived := components{} + var u *url.URL + var qParams url.Values + + if msg.method != "" || msg.url != nil { + if msg.method == "" || msg.url == nil { + return nil, fmt.Errorf("invalid state: method or url without the other") + } + + u = msg.url + if u == nil { + u = &url.URL{Path: "/"} + } + if u.Host == "" && msg.authority != "" { + u.Host = msg.authority + } + if u.Scheme == "" { + if msg.scheme != "" { + u.Scheme = msg.scheme + } else { + u.Scheme = "http" + } + } + + if u.RawQuery != "" { + values, err := url.ParseQuery(u.RawQuery) + if err != nil { + return nil, fmt.Errorf("cannot parse query: %s", u.RawQuery) + } + qParams = reEncodeQPs(values) + } + + generateReqDerivedComponents(msg.method, u, msg.authority, derived) + } else if msg.statusCode != nil { + derivedComponent("@status", scStatus(*msg.statusCode), derived) + } else { + return nil, fmt.Errorf("invalid state: method and url, or status required") + } -func scStatus(res *http.Response) string { - return strconv.Itoa(res.StatusCode) + return &parsedMessage{ + derived: derived, + url: u, + headers: normalizeHeaderNames(msg.headers), + trailers: normalizeHeaderNames(msg.trailers), + qParams: qParams, + }, nil } diff --git a/internal-docs/JWX_V3_IMPLEMENTATION_SUMMARY.md b/internal-docs/JWX_V3_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..82f7d5e --- /dev/null +++ b/internal-docs/JWX_V3_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,235 @@ +# jwx v3 Implementation Summary + +## Status: ✅ COMPLETED + +Implementation of jwx v3 support alongside existing jwx v2 functionality has been successfully completed. + +## Implementation Date +October 31, 2025 + +## Approach Taken +**Option B: Backward Compatible Migration** - Separate V3 functions alongside existing v2 functions + +## Changes Made + +### 1. Dependencies (go.mod) +- ✅ Added `github.com/lestrrat-go/jwx/v3 v3.0.12` +- ✅ Kept `github.com/lestrrat-go/jwx/v2 v2.1.2` for backward compatibility +- ✅ Go version upgraded to 1.24.0 (automatic) +- ✅ Various dependency updates (automatic) + +### 2. Source Code (crypto.go) + +#### Imports +- Added jwx v3 imports with aliases: `jwav3`, `jwsv3` +- Kept jwx v2 imports without aliases: `jwa`, `jws` +- Clear comments indicating which version is used where + +#### New Functions +**NewJWSSignerV3()** +```go +func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) +``` +- Uses `jwsv3.SignerFor()` - **recommended non-deprecated API** +- Returns `Signer2` interface with parameter order: `Sign(key, payload)` (key first!) +- Returns same `*Signer` type as v2 version +- Handles `jwav3.NoSignature()` (function call, not constant) +- Compatible with existing signing infrastructure via interface adapters + +**NewJWSVerifierV3()** +```go +func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) +``` +- Uses `jwsv3.VerifierFor()` - **recommended non-deprecated API** +- Returns `Verifier2` interface with parameter order: `Verify(key, payload, sig)` (key first!) +- Returns same `*Verifier` type as v2 version +- Handles `jwav3.NoSignature()` (function call, not constant) +- Compatible with existing verification infrastructure via interface adapters + +#### Updated Internal Methods +**sign() method** +- Enhanced to handle both v2 (`jws.Signer`) and v3 (`Signer2`) interfaces +- Handles parameter order differences: + - v2: `Sign(payload, key)` + - v3: `Sign(key, payload)` - **parameter order swapped!** +- Uses interface type assertions to detect version +- Falls back to legacy interface for backward compatibility +- No changes to existing v2 behavior + +**verify() method** +- Enhanced to handle both v2 (`jws.Verifier`) and v3 (`Verifier2`) interfaces +- Handles parameter order differences: + - v2: `Verify(payload, sig, key)` + - v3: `Verify(key, payload, sig)` - **key moved to first position!** +- Uses interface type assertions to detect version +- Falls back to legacy interface for backward compatibility +- No changes to existing v2 behavior + +#### Updated Existing Functions +**NewJWSSigner()** (v2) +- Added documentation noting it uses jwx v2 +- Added note recommending `NewJWSSignerV3` for new code with jwx v3 +- No functional changes - complete backward compatibility + +**NewJWSVerifier()** (v2) +- Added documentation noting it uses jwx v2 +- Added note recommending `NewJWSVerifierV3` for new code with jwx v3 +- No functional changes - complete backward compatibility + +### 3. Tests (crypto_test.go) + +#### New Test Functions +1. **TestForeignSignerV3()** - Tests ES256 signing and verification with v3 +2. **TestMessageForeignSignerV3()** - Tests Message API with v3 +3. **TestNewJWSVerifierV3()** - Tests verifier creation with v3 (with subtests) +4. **TestCrossVersionCompatibility()** - Critical test for cross-version compatibility + - Subtest: `v2_sign_v3_verify` - Sign with v2, verify with v3 + - Subtest: `v3_sign_v2_verify` - Sign with v3, verify with v2 + +#### Test Results +- ✅ All existing v2 tests pass (backward compatibility verified) +- ✅ All new v3 tests pass +- ✅ Cross-compatibility tests pass (v2 ↔ v3 signatures are compatible) +- ✅ Full test suite passes: `go test ./...` + +## API Surface Changes + +### New Public Functions +- `NewJWSSignerV3(alg jwav3.SignatureAlgorithm, ...) (*Signer, error)` +- `NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, ...) (*Verifier, error)` + +### Existing Functions (Unchanged) +- `NewJWSSigner(alg jwa.SignatureAlgorithm, ...) (*Signer, error)` - ✅ Still works +- `NewJWSVerifier(alg jwa.SignatureAlgorithm, ...) (*Verifier, error)` - ✅ Still works +- All native algorithm functions (HMAC, RSA, ECDSA, Ed25519) - ✅ Unaffected + +### Breaking Changes +**None** - This is a backward compatible release + +## User Impact + +### Who Benefits +- Users who want to adopt jwx v3 for new code +- Users who need jwx v3 features +- Users who want to future-proof their code + +### Who is NOT Affected +- Users of existing `NewJWSSigner()` and `NewJWSVerifier()` functions +- Users of native algorithm functions (most users) +- Users who don't use JWS algorithms at all + +### Migration Path for Users +1. **Optional** - Users can continue using v2 functions indefinitely +2. **When Ready** - Users can migrate to V3 functions at their own pace +3. **Easy** - Just change function name and import: `jwa.ES256` → `jwav3.ES256()` +4. **Compatible** - Signatures remain compatible between v2 and v3 + +## Technical Notes + +### jwx v3 API Differences +1. **SignatureAlgorithm constants** - Changed from constants to functions + - v2: `jwa.ES256` (constant) + - v3: `jwav3.ES256()` (function call) + +2. **NewSigner() deprecated** - We use recommended API instead + - v3 recommends `SignerFor()` which returns `Signer2` interface + - **We now use `SignerFor()`** - non-deprecated API + - `Signer2.Sign(key, payload)` has **swapped parameter order** vs v2 + +3. **NewVerifier() deprecated** - We use recommended API instead + - v3 recommends `VerifierFor()` which returns `Verifier2` interface + - **We now use `VerifierFor()`** - non-deprecated API + - `Verifier2.Verify(key, payload, sig)` has **different parameter order** vs v2 + +4. **Interface incompatibilities** - Handled via adapters in sign()/verify() methods + - v2 Signer: `Sign(payload, key)` + - v3 Signer2: `Sign(key, payload)` - **parameters swapped** + - v2 Verifier: `Verify(payload, sig, key)` + - v3 Verifier2: `Verify(key, payload, sig)` - **key moved to first position** + - Our implementation detects interface types and adapts parameter order automatically + +### Future Considerations + +#### Deprecation Timeline +1. **Now (v1.3.0 estimate)**: Both v2 and v3 functions available +2. **Future (v1.4.0+)**: Mark v2 functions as deprecated in documentation +3. **Much Later (v2.0.0)**: Remove v2 functions in next major version + +#### Already Using Best Practices +✅ **Already implemented**: We use the recommended non-deprecated APIs +- `SignerFor()` instead of deprecated `NewSigner()` +- `VerifierFor()` instead of `NewVerifier()` +- Proper parameter order handling for `Signer2` and `Verifier2` interfaces +- No deprecated code paths in V3 functions + +## Dependencies + +### Production Dependencies +- `github.com/lestrrat-go/jwx/v2 v2.1.2` (existing) +- `github.com/lestrrat-go/jwx/v3 v3.0.12` (new) + +### Transitive Dependencies Added +- `github.com/lestrrat-go/option/v2 v2.0.0` +- Various other dependencies updated automatically + +### Dependency Size Impact +- Both v2 and v3 libraries are now dependencies (temporary) +- Will be reduced when v2 functions are removed in future major version + +## Validation + +### Code Quality +- ✅ No linter errors (except pre-existing warning) +- ✅ All tests pass +- ✅ Code coverage maintained +- ✅ No breaking changes + +### Compatibility +- ✅ v2 functions work identically +- ✅ v3 functions work correctly +- ✅ Cross-version signatures are compatible +- ✅ RFC 9421 compliance maintained + +### Documentation +- ✅ Function documentation updated +- ✅ Comments explain v2 vs v3 usage +- ✅ Research findings documented +- ✅ Implementation summary documented (this file) + +## Documentation Generated + +1. **JWX_V3_MIGRATION_PLAN.md** - Comprehensive migration plan (Option B selected) +2. **JWX_V3_RESEARCH_FINDINGS.md** - Research on jwx v3 API changes +3. **JWX_V3_IMPLEMENTATION_SUMMARY.md** - This file + +## Success Metrics + +✅ All success criteria met: +- All existing unit tests pass +- All new unit tests pass +- No performance regressions +- Signatures remain RFC 9421 compliant +- Cross-version compatibility verified +- Zero breaking changes +- Documentation complete + +## Recommendations + +### For Maintainers +1. Monitor jwx v3 updates for API changes +2. Consider deprecating v2 functions in 6-12 months +3. Plan for v2 function removal in next major version +4. Watch for `NewSigner()` deprecation in jwx v3 + +### For Users +1. New code should use `NewJWSSignerV3()` and `NewJWSVerifierV3()` +2. Existing code can continue using v2 functions +3. Migration is optional and low-risk +4. Signatures are fully compatible between versions + +## Conclusion + +The jwx v3 implementation is complete, tested, and production-ready. The backward-compatible approach ensures zero disruption to existing users while providing a clear path forward for jwx v3 adoption. + +**Status**: ✅ READY FOR RELEASE + diff --git a/internal-docs/JWX_V3_MIGRATION_PLAN.md b/internal-docs/JWX_V3_MIGRATION_PLAN.md new file mode 100644 index 0000000..ccda884 --- /dev/null +++ b/internal-docs/JWX_V3_MIGRATION_PLAN.md @@ -0,0 +1,704 @@ +# jwx v2 to v3 Migration Plan + +## Executive Summary + +This document outlines the plan for migrating the `httpsign` library from `jwx` v2 to v3. The jwx library is used for generic JWS (JSON Web Signature) algorithm support, providing extensibility for signature algorithms beyond those natively implemented in this library. + +**✅ BACKWARD COMPATIBLE MIGRATION**: This migration will maintain backward compatibility by keeping the existing v2-based functions and adding new v3-based functions. Users can migrate at their own pace, and existing code will continue to work without changes. + +## Quick Reference + +| Aspect | Details | +|--------|---------| +| **Breaking Change?** | ❌ NO - backward compatible migration | +| **Migration Strategy** | Separate functions - keep old, add new with V3 suffix | +| **Who is affected?** | Only users who want to use jwx v3 features (optional upgrade) | +| **Existing users impact** | ✅ ZERO - existing code continues to work | +| **Required version bump** | Minor version (e.g., v1.2.x → v1.3.0) | +| **Signature compatibility** | ✅ Compatible - both v2 and v3 generate valid signatures | +| **Migration effort (users)** | Optional - users can migrate when ready | +| **Migration effort (maintainers)** | Medium - maintain two code paths + testing both | +| **Deprecation timeline** | TBD - can deprecate v2 functions in future major release | + +## Current Usage Analysis + +### Dependencies +- **Current Version**: `github.com/lestrrat-go/jwx/v2 v2.1.2` +- **Target Version**: `github.com/lestrrat-go/jwx/v3` (latest stable) + +### Files Using jwx + +1. **crypto.go** (Lines 15-16) + - `github.com/lestrrat-go/jwx/v2/jwa` - For `SignatureAlgorithm` type + - `github.com/lestrrat-go/jwx/v2/jws` - For `NewSigner` and `NewVerifier` functions + +2. **crypto_test.go** (Lines 11-12) + - Same imports for testing JWS functionality + +### Functions Using jwx + +1. **`NewJWSSigner()`** (crypto.go:131-149) + - Takes `jwa.SignatureAlgorithm` as a parameter + - Calls `jws.NewSigner(alg)` to create a signer + - Returns a `Signer` with `foreignSigner` field set to `jws.Signer` + +2. **`NewJWSVerifier()`** (crypto.go:306-327) + - Takes `jwa.SignatureAlgorithm` as a parameter + - Calls `jws.NewVerifier(alg)` to create a verifier + - Returns a `Verifier` with `foreignVerifier` field set to `jws.Verifier` + +3. **`Signer.sign()`** (crypto.go:151-200) + - Uses type assertion to check for `jws.Signer` + - Calls `signer.Sign(buff, s.key)` method + +4. **`Verifier.verify()`** (crypto.go:329-382) + - Uses type assertion to check for `jws.Verifier` + - Calls `verifier.Verify(buff, sig, v.key)` method + +### Test Coverage + +1. **`TestForeignSigner()`** (crypto_test.go:136-164) + - Tests ES256 (ECDSA P-256) signing and verification + - Uses `jwa.ES256` constant + +2. **`TestMessageForeignSigner()`** (crypto_test.go:167-199) + - Similar test using Message API + +3. **`TestNewJWSVerifier()`** (crypto_test.go:250-326) + - Tests HS256 (HMAC SHA-256) verification + - Uses `jwa.SignatureAlgorithm("HS256")` and `jwa.NoSignature` + +## Backward Compatible Migration Strategy + +**✅ GOOD NEWS: This is NOT a breaking change. Existing code will continue to work.** + +### Approach: Dual Function Support + +We will maintain both jwx v2 and v3 functions side-by-side: + +**Existing functions (unchanged, using jwx v2):** +- `httpsign.NewJWSSigner()` - Uses `github.com/lestrrat-go/jwx/v2/jwa` +- `httpsign.NewJWSVerifier()` - Uses `github.com/lestrrat-go/jwx/v2/jwa` + +**New functions (added, using jwx v3):** +- `httpsign.NewJWSSignerV3()` - Uses `github.com/lestrrat-go/jwx/v3/jwa` +- `httpsign.NewJWSVerifierV3()` - Uses `github.com/lestrrat-go/jwx/v3/jwa` + +### Why This Approach? + +The `NewJWSSigner()` and `NewJWSVerifier()` functions expose `jwa.SignatureAlgorithm` in their signatures: + +```go +func NewJWSSigner(alg jwa.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) +func NewJWSVerifier(alg jwa.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) +``` + +Since these are **rarely used functions** (most users rely on native algorithms), forcing a breaking change would be disproportionate. The dual-function approach: + +1. ✅ Preserves backward compatibility +2. ✅ Allows gradual migration +3. ✅ No user disruption +4. ✅ Enables jwx v3 adoption when users are ready +5. ✅ Can be cleaned up in a future major version + +### Who is NOT Affected? (Everyone!) + +**All existing users can continue using their code without any changes.** This includes: + +Users of native algorithm functions: +- `NewHMACSHA256Signer()` / `NewHMACSHA256Verifier()` +- `NewRSASigner()` / `NewRSAVerifier()` +- `NewRSAPSSSigner()` / `NewRSAPSSVerifier()` +- `NewP256Signer()` / `NewP256Verifier()` +- `NewP384Signer()` / `NewP384Verifier()` +- `NewEd25519Signer()` / `NewEd25519Verifier()` +- `NewEd25519SignerFromSeed()` + +Users of JWS functions (jwx v2-based): +- `NewJWSSigner()` - **continues to work, unchanged** +- `NewJWSVerifier()` - **continues to work, unchanged** + +### Optional Migration for Users + +Users who want to adopt jwx v3 can optionally migrate to the new functions: + +#### 1. Update go.mod to add jwx v3 (keep v2 for now) +```bash +# Add v3 alongside v2 +go get github.com/lestrrat-go/jwx/v3@latest + +go mod tidy +``` + +#### 2. Update code to use new V3 functions +```go +// OLD - using jwx v2 (still works!) +import ( + jwav2 "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/yaronf/httpsign" +) + +signer, err := httpsign.NewJWSSigner(jwav2.ES256, privateKey, config, fields) + +// NEW - using jwx v3 (optional upgrade) +import ( + jwav3 "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/yaronf/httpsign" +) + +signer, err := httpsign.NewJWSSignerV3(jwav3.ES256, privateKey, config, fields) +``` + +#### 3. Eventually remove jwx v2 dependency (when ready) +```bash +# Once all code is migrated to V3 functions +go get github.com/lestrrat-go/jwx/v2@none +go mod tidy +``` + +### Example: Gradual Migration + +**Current code (no changes needed):** +```go +package main + +import ( + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/yaronf/httpsign" +) + +func main() { + signer, err := httpsign.NewJWSSigner( + jwa.ES256, + privateKey, + httpsign.NewSignConfig(), + httpsign.Headers("@method", "content-digest"), + ) + // ... use signer +} +``` + +**Optional upgrade to v3 (when user is ready):** +```go +package main + +import ( + "github.com/lestrrat-go/jwx/v3/jwa" // ← Upgrade to v3 + "github.com/yaronf/httpsign" +) + +func main() { + signer, err := httpsign.NewJWSSignerV3( // ← Use new V3 function + jwa.ES256, + privateKey, + httpsign.NewSignConfig(), + httpsign.Headers("@method", "content-digest"), + ) + // ... use signer - same behavior +} +``` + +### Communication Plan + +1. **Update README** with information about new V3 functions +2. **Update CHANGELOG** noting new functions added (non-breaking) +3. **Add documentation** explaining the difference between v2 and v3 functions +4. **Announce in release notes** - new jwx v3 support available +5. **Mark v2 functions as deprecated** (in a future release, with timeline) +6. **Plan removal** of v2 functions for next major version (e.g., v2.0.0) + +### Migration Approach: Option B (Chosen) + +**✅ SELECTED: Separate Functions for Backward Compatibility** + +Since `NewJWSSigner()` and `NewJWSVerifier()` are rarely used (most users rely on native algorithms), we will maintain backward compatibility by creating separate V3 functions. + +#### Implementation: +```go +// Existing functions (keep unchanged, using jwx v2) +func NewJWSSigner(alg jwav2.SignatureAlgorithm, ...) (*Signer, error) +func NewJWSVerifier(alg jwav2.SignatureAlgorithm, ...) (*Verifier, error) + +// New functions (add, using jwx v3) +func NewJWSSignerV3(alg jwav3.SignatureAlgorithm, ...) (*Signer, error) +func NewJWSVerifierV3(alg jwav3.SignatureAlgorithm, ...) (*Verifier, error) +``` + +#### Pros: +- ✅ No breaking changes for existing users +- ✅ Zero migration pressure - users migrate when ready +- ✅ Gradual adoption of jwx v3 +- ✅ Can deprecate v2 functions in future major version +- ✅ Proportional to usage (low usage = low impact approach) + +#### Cons: +- ⚠️ API bloat (4 functions instead of 2) +- ⚠️ Maintenance burden (must maintain both v2 and v3 code paths) +- ⚠️ Dependency bloat (httpsign depends on both jwx v2 and v3) +- ⚠️ Potential confusion about which function to use + +#### Deprecation Path: +1. **Now (v1.3.0)**: Add V3 functions, keep v2 functions working +2. **Later (v1.4.0)**: Mark v2 functions as deprecated with migration notice +3. **Future (v2.0.0)**: Remove v2 functions in next major version + +### Alternative Options Considered (Not Chosen) + +#### Option A: Major Version Bump (Not Chosen) +- Would force breaking change on all users +- Disproportionate impact for rarely-used functions +- ❌ Rejected due to high impact vs. low usage + +#### Option C: Build Tags (Not Chosen) +- Complex build and testing process +- Poor developer experience +- ❌ Rejected due to complexity + +## Migration Strategy + +### Phase 1: Research and Preparation + +- [x] Analyze current jwx v2 usage +- [ ] Review jwx v3 official migration guide: https://github.com/lestrrat-go/jwx/blob/develop/v3/Changes-v3.md +- [ ] Identify breaking changes that affect this codebase +- [ ] Check for deprecated APIs +- [ ] Review jwx v3 performance improvements and new features + +### Phase 2: Expected Breaking Changes + +Based on typical major version upgrades, expect the following potential changes: + +1. **Import Path Changes** + - `github.com/lestrrat-go/jwx/v2/jwa` → `github.com/lestrrat-go/jwx/v3/jwa` + - `github.com/lestrrat-go/jwx/v2/jws` → `github.com/lestrrat-go/jwx/v3/jws` + +2. **API Changes to Monitor** + - Constructor signatures (e.g., `jws.NewSigner`, `jws.NewVerifier`) + - Interface method signatures (e.g., `Sign()`, `Verify()`) + - Type names or structure changes + - Error handling patterns + - Constant/enum values (e.g., `jwa.SignatureAlgorithm`, `jwa.NoSignature`) + +3. **Potential New Features** + - Additional signature algorithms + - Performance optimizations + - Enhanced error messages + - Better API ergonomics + +### Phase 3: Implementation Steps + +#### Step 1: Add jwx v3 Dependency +```bash +# Add jwx v3 (keep v2 for backward compatibility) +go get github.com/lestrrat-go/jwx/v3@latest + +go mod tidy +``` + +#### Step 2: Update Import Statements in crypto.go + +**File to update:** `crypto.go` + +**Add v3 imports (keep v2 imports):** +```go +import ( + // ... existing imports ... + + // JWX v2 (existing, keep for backward compatibility) + jwav2 "github.com/lestrrat-go/jwx/v2/jwa" + jwsv2 "github.com/lestrrat-go/jwx/v2/jws" + + // JWX v3 (new, for V3 functions) + jwav3 "github.com/lestrrat-go/jwx/v3/jwa" + jwsv3 "github.com/lestrrat-go/jwx/v3/jws" +) +``` + +#### Step 3: Create New V3 Functions + +Add new functions alongside existing ones in `crypto.go`: + +**Add after existing `NewJWSSigner()`:** +```go +// NewJWSSigner creates a generic signer using JWX v2 (legacy, for backward compatibility) +// For new code, consider using NewJWSSignerV3() instead. +func NewJWSSigner(alg jwav2.SignatureAlgorithm, key interface{}, config *SignConfig, fields Fields) (*Signer, error) { + // ... existing implementation unchanged ... +} + +// NewJWSSignerV3 creates a generic signer using JWX v3 algorithms +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") + } + jwsSigner, err := jwsv3.NewSigner(alg) + if err != nil { + return nil, err + } + return &Signer{ + key: key, + alg: "", + config: config, + fields: fields, + foreignSigner: jwsSigner, + }, nil +} +``` + +**Add after existing `NewJWSVerifier()`:** +```go +// NewJWSVerifier creates a generic verifier using JWX v2 (legacy, for backward compatibility) +// For new code, consider using NewJWSVerifierV3() instead. +func NewJWSVerifier(alg jwav2.SignatureAlgorithm, key interface{}, config *VerifyConfig, fields Fields) (*Verifier, error) { + // ... existing implementation unchanged ... +} + +// NewJWSVerifierV3 creates a generic verifier using JWX v3 algorithms +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.NewVerifier(alg) + if err != nil { + return nil, err + } + return &Verifier{ + key: key, + alg: "", + config: config, + fields: fields, + foreignVerifier: verifier, + }, nil +} +``` + +#### Step 4: Update Internal Methods (if needed) + +The internal `sign()` and `verify()` methods should already handle both v2 and v3 jwx.Signer/Verifier interfaces since they use type assertion. Verify this works correctly: + +- `Signer.sign()` - Should work with both `jwsv2.Signer` and `jwsv3.Signer` +- `Verifier.verify()` - Should work with both `jwsv2.Verifier` and `jwsv3.Verifier` + +If type compatibility issues arise, may need to use interface adapters. + +#### Step 5: Add Tests for V3 Functions + +Add new tests in `crypto_test.go` for the V3 functions: + +```go +import ( + jwav2 "github.com/lestrrat-go/jwx/v2/jwa" + jwsv2 "github.com/lestrrat-go/jwx/v2/jws" + jwav3 "github.com/lestrrat-go/jwx/v3/jwa" + jwsv3 "github.com/lestrrat-go/jwx/v3/jws" +) + +// New test for V3 signer +func TestForeignSignerV3(t *testing.T) { + // Similar to TestForeignSigner but using NewJWSSignerV3/NewJWSVerifierV3 + // and jwav3.ES256 +} + +// New test for V3 verifier +func TestNewJWSVerifierV3(t *testing.T) { + // Similar to TestNewJWSVerifier but using jwav3 types +} +``` + +**Keep existing tests unchanged** to ensure backward compatibility. + +#### Step 6: Run All Tests + +Ensure all tests pass with both v2 and v3: +```bash +go test -v ./... +``` + +**Critical tests to verify:** + +**V2 Tests (existing, must still pass):** +- `TestForeignSigner` - ES256 signing/verification with v2 +- `TestMessageForeignSigner` - Message-based signing with v2 +- `TestNewJWSVerifier` - Verifier creation with v2 +- `TestVerify` - Verification logic + +**V3 Tests (new):** +- `TestForeignSignerV3` - ES256 signing/verification with v3 +- `TestMessageForeignSignerV3` - Message-based signing with v3 +- `TestNewJWSVerifierV3` - Verifier creation with v3 +- Cross-compatibility tests (sign with v2, verify with v3, and vice versa) + +#### Step 7: Cross-Version Compatibility Testing + +Verify that signatures generated with v2 can be verified with v3 and vice versa: +- Sign with v2 signer, verify with v3 verifier +- Sign with v3 signer, verify with v2 verifier +- Test with various algorithms: ES256, HS256, etc. + +### Phase 4: Validation and Testing + +1. **Unit Tests** + - Run full test suite: `go test ./...` + - Check test coverage: `go test -cover ./...` + - Run race detector: `go test -race ./...` + +2. **Integration Tests** + - Test with real HTTP requests + - Verify signature generation + - Verify signature validation + - Test error cases + +3. **Backward Compatibility Validation** + - ✅ **NOTE**: This is NOT a breaking change - existing functions continue to work + - Verify that existing v2 functions still work correctly + - Verify that new v3 functions work correctly + - Test cross-compatibility: v2 signatures verified by v3, and vice versa + - Ensure native algorithm functions remain unaffected + - Verify both v2 and v3 code paths work correctly + +4. **Performance Testing** + - Benchmark signing operations + - Benchmark verification operations + - Compare performance with v2 + +### Phase 5: Documentation Updates + +1. **Update go.mod** + - Reflect new jwx v3 dependency + - Update minimum Go version if required + +2. **Update Documentation** + - Update any references to jwx v2 + - Document any new features or improvements + - Update examples if API changed + - **Create MIGRATION.md** for end users with step-by-step upgrade instructions + +3. **CHANGELOG** + - Document the jwx upgrade as a **BREAKING CHANGE** + - Clearly state who is affected (users of `NewJWSSigner` and `NewJWSVerifier`) + - Provide migration instructions + - Mention benefits of the upgrade + +4. **User Communication** + - Create GitHub issue announcing the breaking change + - Consider GitHub Discussions post for Q&A + - Update README with migration notice + - Prepare release notes with clear migration guide + - Consider blog post or announcement if library has significant users + +## Risk Assessment + +### No Breaking Changes +**✅ LOW RISK**: Using the backward compatible approach (Option B), there are NO breaking changes for users. Existing code continues to work without modifications. + +### Low Risk Areas +- Adding new V3 functions alongside existing ones (additive, non-breaking) +- Existing v2 functions remain unchanged (zero risk to current users) +- Users of native algorithms are completely unaffected +- Optional migration - users can upgrade at their own pace + +### Medium Risk Areas +- **Dependency size**: httpsign will depend on both jwx v2 and v3 temporarily +- **Maintenance burden**: Must maintain and test both v2 and v3 code paths +- **API confusion**: Users might be unsure which function to use +- **API signature changes in jwx v3**: Could require code changes in V3 functions +- **Behavioral differences**: v2 and v3 might behave slightly differently +- **Error handling differences**: Error messages may differ between v2 and v3 +- **Type compatibility**: May need adapters if jwsv2.Signer and jwsv3.Signer interfaces differ + +### Medium-High Risk Areas +- **Cross-version compatibility**: Signatures generated with v2 must be verifiable by v3 (and vice versa) +- **Interface incompatibility**: If `jws.Signer`/`jws.Verifier` interfaces changed significantly in v3 +- **Algorithm constant changes**: If `jwa.ES256` or other constants changed names or values in v3 + +### Mitigation Strategies +1. **Extensive testing**: Test both v2 and v3 code paths thoroughly +2. **Cross-compatibility tests**: Verify v2 and v3 can interoperate +3. **Documentation**: Clear guidance on which function to use +4. **Deprecation timeline**: Plan for eventual removal of v2 functions + +## Rollback Plan + +Since this is a backward compatible approach, rollback is straightforward and low-risk: + +### If Critical Issues are Discovered with V3 Functions: + +**Option 1: Remove V3 functions (clean rollback)** + ```bash + # Remove jwx v3 dependency + go get github.com/lestrrat-go/jwx/v3@none + go mod tidy + ``` + - Delete `NewJWSSignerV3()` and `NewJWSVerifierV3()` functions + - Remove v3 import statements + - Existing v2 functions remain untouched + - No user impact (V3 functions were new, no one depends on them yet) + +**Option 2: Mark V3 functions as experimental** + - Add `// EXPERIMENTAL: This function is under development` to docs + - Keep them in codebase but discourage use + - Fix issues in next release + +**Option 3: Keep both, document issues** + - Document known issues with V3 functions + - Recommend users stick with v2 functions for now + - Fix issues incrementally + +### Document Issues +- Document any blocking issues found +- Report bugs to jwx repository if applicable +- Create plan for addressing issues in future release +- Communicate to users about any limitations + +## Success Criteria + +### Code Quality +- ✅ All existing unit tests pass (v2 functionality preserved) +- ✅ All new unit tests pass (v3 functionality works) +- ✅ All integration tests pass +- ✅ No performance regressions for v2 functions +- ✅ V3 functions have comparable or better performance +- ✅ No new linter warnings or errors +- ✅ Code coverage maintained or improved + +### Compatibility +- ✅ Existing v2 functions work identically (zero breaking changes) +- ✅ Signatures generated with v2 remain compatible with RFC 9421 +- ✅ Signatures generated with v3 remain compatible with RFC 9421 +- ✅ Cross-compatibility: v2 signatures can be verified by v3 verifiers +- ✅ Cross-compatibility: v3 signatures can be verified by v2 verifiers +- ✅ Native algorithm functions work identically (unaffected) + +### Documentation +- ✅ Documentation updated (README with V3 function info) +- ✅ CHANGELOG updated noting new functions added (non-breaking) +- ✅ API documentation clearly explains v2 vs v3 functions +- ✅ Clear guidance on when to use v2 vs v3 +- ✅ Deprecation timeline documented for v2 functions +- ✅ Examples provided for both v2 and v3 usage + +### Release Management +- ✅ Release notes clearly describe new V3 functions +- ✅ Appropriate semantic versioning (minor version bump: e.g., v1.2.x → v1.3.0) +- ✅ No breaking changes for existing users +- ✅ Clear migration path documented for users who want to upgrade +- ✅ Backward compatibility maintained + +## Timeline Estimate + +1. **Phase 1: Research** - 2-4 hours + - Review jwx v3 changes and migration guide + - Identify any API differences + - Plan implementation strategy for dual functions + +2. **Phase 2: Implementation** - 3-5 hours + - Add jwx v3 dependency alongside v2 + - Add aliased imports for both versions + - Create NewJWSSignerV3() function + - Create NewJWSVerifierV3() function + - Verify internal methods handle both v2 and v3 interfaces + - Add deprecation comments to v2 functions + +3. **Phase 3: Testing** - 5-10 hours + - Create tests for V3 functions (mirror existing v2 tests) + - Run full test suite for v2 functions (ensure unchanged) + - Run full test suite for v3 functions + - Cross-compatibility testing (v2 ↔ v3) + - Performance benchmarking (compare v2 vs v3) + - Integration testing with real HTTP requests + +4. **Phase 4: Documentation & Communication** - 2-4 hours + - Update README with V3 function information + - Write CHANGELOG entry (new functions added) + - Add API documentation for V3 functions + - Document when to use v2 vs v3 + - Document deprecation timeline for v2 functions + - Prepare release notes + - Update examples (show both v2 and v3 usage) + +**Total Estimated Time**: 12-23 hours (accounting for dual code path maintenance) + +**Note**: Ongoing maintenance cost of supporting both v2 and v3 until v2 functions are deprecated and removed. + +## Post-Migration Tasks + +### Immediate (After Release) +1. Monitor for issues reported by users (especially with V3 functions) +2. Track adoption of V3 functions vs continued use of v2 functions +3. Provide support for users migrating to V3 functions +4. Watch for any compatibility issues between v2 and v3 + +### Short-term (1-3 months) +1. Gather feedback on V3 function usage +2. Identify any issues with dual dependency (jwx v2 + v3) +3. Monitor dependency size impact +4. Evaluate jwx v3 performance improvements + +### Medium-term (6-12 months) +1. Add deprecation warnings to v2 functions (when V3 adoption is sufficient) +2. Update documentation to recommend V3 functions as default +3. Plan timeline for removing v2 functions +4. Consider if any jwx v3 exclusive features should be exposed + +### Long-term (Next Major Version) +1. Remove v2 functions (`NewJWSSigner`, `NewJWSVerifier`) +2. Remove jwx v2 dependency +3. Rename V3 functions (remove "V3" suffix) or keep for clarity +4. Clean up API surface + +## Additional Resources + +- jwx v3 Repository: https://github.com/lestrrat-go/jwx +- jwx v3 Migration Guide: https://github.com/lestrrat-go/jwx/blob/develop/v3/Changes-v3.md +- jwx v3 Documentation: https://pkg.go.dev/github.com/lestrrat-go/jwx/v3 +- RFC 9421 (HTTP Message Signatures): https://www.rfc-editor.org/rfc/rfc9421.html + +## Important Notes + +### For httpsign Maintainers +- The jwx library is only used for "foreign" JWS algorithm support +- Native algorithms (HMAC-SHA256, RSA, ECDSA P-256/P-384, Ed25519) do not depend on jwx +- **Backward compatible approach**: Keep v2 functions, add V3 functions +- Both jwx v2 and v3 will be dependencies temporarily +- Must maintain and test both v2 and v3 code paths +- Plan deprecation path for v2 functions in future major version +- Internal methods should handle both jwsv2 and jwsv3 interfaces + +### For httpsign Users +- **✅ NO BREAKING CHANGES** - existing code continues to work +- Current users of `NewJWSSigner()` and `NewJWSVerifier()` are NOT affected +- Users of native algorithm functions (NewRSASigner, NewP256Signer, etc.) are NOT affected +- New `NewJWSSignerV3()` and `NewJWSVerifierV3()` functions available for jwx v3 support +- Migration to V3 functions is **optional** - users can migrate when ready +- The migration is a minor version bump (e.g., v1.2.x → v1.3.0) +- Signatures remain fully compatible between v2 and v3 +- Most users (estimated 80-90%) who only use native algorithms see zero impact +- Users who want jwx v3 features can optionally adopt V3 functions + +### Trade-offs of This Approach +**Pros:** +- Zero breaking changes for users +- Gradual, optional migration path +- Users control when they upgrade +- Low risk rollback (just remove V3 functions if needed) + +**Cons:** +- API bloat (4 functions instead of 2) +- Maintenance burden (two code paths) +- Dependency bloat (both jwx v2 and v3) +- Must eventually clean up in major version + +### Future Cleanup +- v2 functions will be marked deprecated in ~6-12 months +- v2 functions will be removed in next major version (e.g., v2.0.0) +- At that point, V3 functions become the standard (or renamed without V3 suffix) + diff --git a/internal-docs/JWX_V3_RESEARCH_FINDINGS.md b/internal-docs/JWX_V3_RESEARCH_FINDINGS.md new file mode 100644 index 0000000..a47a009 --- /dev/null +++ b/internal-docs/JWX_V3_RESEARCH_FINDINGS.md @@ -0,0 +1,198 @@ +# jwx v3 Research Findings + +## Status: COMPLETED + +This document tracks research findings about jwx v3 API changes before implementing the migration. + +## Research Questions + +### 1. Does jwx v3 exist and is it stable? +- **Status**: ✅ CONFIRMED +- **Finding**: jwx v3 exists and is stable +- **Latest Version**: v3.0.12 (as of research date) +- **Version History**: v3.0.0 through v3.0.12 (12 patch releases) +- **Conclusion**: READY FOR PRODUCTION USE + +### 2. What are the actual API changes in jwx v3? +- **Status**: ✅ RESEARCHED + +#### jws.NewSigner() +- **v2 Signature**: `func NewSigner(alg jwa.SignatureAlgorithm) (Signer, error)` +- **v3 Signature**: `func NewSigner(alg jwa.SignatureAlgorithm) (Signer, error)` - **SAME** +- **⚠️ DEPRECATION**: v3 marks NewSigner as DEPRECATED, recommends `SignerFor()` instead +- **Migration Note**: Still works in v3 but may be removed in future versions + +#### jws.SignerFor() (NEW in v3) +- **Signature**: `func SignerFor(alg jwa.SignatureAlgorithm) (Signer2, error)` +- **Returns**: `Signer2` interface (new) instead of `Signer` (legacy) +- **Behavior**: Never fails, provides fallback signers +- **Recommended**: This is the preferred way to get signers in v3 + +#### jws.Signer vs jws.Signer2 +- **Signer (legacy)**: Type alias to `legacy.Signer` + - Method: `Sign(payload []byte, key any) ([]byte, error)` +- **Signer2 (new)**: New interface + - Method: `Sign(key any, payload []byte) ([]byte, error)` + - **⚠️ CRITICAL**: **Parameter order is SWAPPED!** key before payload + +#### jws.NewVerifier() +- **v2 Signature**: `func NewVerifier(alg jwa.SignatureAlgorithm) (Verifier, error)` +- **v3 Signature**: `func NewVerifier(alg jwa.SignatureAlgorithm) (Verifier, error)` - **SAME** +- **Status**: NOT DEPRECATED (still recommended in v3) +- **Migration Note**: No changes needed + +#### jws.Verifier +- **Type**: Alias to `legacy.Verifier` +- **Status**: Still used, no deprecation +- **Migration Note**: No changes needed + +### 3. Are there breaking changes in the JWS package? +- **Status**: ✅ ANALYZED +- **Breaking Changes**: YES, but gradual + - `NewSigner()` is deprecated (but still works with legacy interface) + - New `SignerFor()` returns `Signer2` with swapped parameter order + - `NewVerifier()` is NOT deprecated +- **Compatibility**: Old code using `NewSigner()` will still work +- **Migration Path**: Can use deprecated API initially, then migrate to `SignerFor()` + +### 4. Are v2 and v3 compatible in the same binary? +- **Status**: ✅ CONFIRMED COMPATIBLE +- **Finding**: Yes, v2 and v3 can coexist + - Different import paths: `github.com/lestrrat-go/jwx/v2` vs `github.com/lestrrat-go/jwx/v3` + - No symbol conflicts (different module versions) + - Aliased imports work correctly (e.g., `jwav2`, `jwav3`) +- **Conclusion**: Option B (dual functions) is technically feasible + +## Alternative Approach + +If jwx v3 doesn't exist yet or isn't stable, we have options: + +### Option 1: Wait for jwx v3 Release +- Monitor the jwx repository for v3 release +- Implement migration when v3 is stable +- Keep current implementation unchanged + +### Option 2: Implement Based on Assumptions +- Create the dual-function structure now +- When v3 is released, fill in the V3 function implementations +- Mark V3 functions as "EXPERIMENTAL - requires jwx v3" until ready + +### Option 3: Check jwx v3 Development Branch +- If v3 is in development, review the develop/v3 branch +- Document planned changes +- Prepare implementation based on upcoming changes + +## Next Steps + +1. **Verify jwx v3 Existence**: Check https://github.com/lestrrat-go/jwx + - Look for v3.x.x tags + - Check release notes + - Review branches for v3 development + +2. **If v3 Exists**: + - Download and examine the API + - Test compatibility with v2 + - Proceed with implementation + +3. **If v3 Doesn't Exist Yet**: + - Update migration plan with realistic timeline + - Consider implementing stub V3 functions + - Wait for official release + +## Manual Investigation Required + +Since automated web searches didn't provide specific technical details, manual investigation of the jwx repository is needed: + +```bash +# Check for v3 tags +git ls-remote --tags https://github.com/lestrrat-go/jwx.git | grep v3 + +# Or check go proxy +go list -m -versions github.com/lestrrat-go/jwx/v3 +``` + +## Decision Point + +**✅ UNBLOCKED**: All prerequisites verified: +1. ✅ jwx v3 exists and is available (v3.0.12) +2. ✅ jwx v3 API is documented (via go doc) +3. ✅ Breaking changes are understood (see above) + +## Summary and Recommendations + +### Key Findings +1. **jwx v3 is production-ready** - v3.0.12 with 12 patch releases +2. **Backward compatibility exists** - v2 and v3 can coexist in same binary +3. **NewSigner() is deprecated** but still works (uses legacy Signer interface) +4. **NewVerifier() is NOT deprecated** and unchanged +5. **New `SignerFor()` API** is preferred in v3 (returns Signer2 with swapped params) + +### Implementation Strategy + +#### Option A: Use Legacy NewSigner() (Simpler, Works But Deprecated) +```go +// V3 functions using deprecated but compatible API +signer, err := jwsv3.NewSigner(alg) // Deprecated but works +verifier, err := jwsv3.NewVerifier(alg) // Not deprecated, fine to use +``` +**Pros**: +- Minimal code changes +- Same interface as v2 +- Works with existing sign() and verify() methods +**Cons**: +- Uses deprecated API +- May break in future jwx releases + +#### Option B: Use New SignerFor() (Future-proof, More Complex) +```go +// V3 functions using new recommended API +signer2, err := jwsv3.SignerFor(alg) // Returns Signer2 interface +verifier, err := jwsv3.NewVerifier(alg) // Still use NewVerifier +``` +**Pros**: +- Uses recommended v3 API +- Future-proof +**Cons**: +- Requires adapter in sign() method (parameter order swapped) +- More complex implementation + +### Recommended Approach: **Option B - Use Non-Deprecated APIs** + +**Rationale**: +1. Avoids using deprecated `NewSigner()` API +2. Uses recommended `SignerFor()` and `VerifierFor()` APIs +3. Future-proof implementation +4. Handles parameter order differences with adapters +5. Professional implementation using best practices + +### Implementation Plan (COMPLETED) + +1. **Add jwx v3 dependency** alongside v2 ✅ +2. **Create NewJWSSignerV3()** using `jwsv3.SignerFor()` ✅ + - Returns `Signer2` interface + - Handles parameter order: `Sign(key, payload)` (swapped vs v2) +3. **Create NewJWSVerifierV3()** using `jwsv3.VerifierFor()` ✅ + - Returns `Verifier2` interface + - Handles parameter order: `Verify(key, payload, sig)` (key moved to first) +4. **Update sign()/verify() methods** ✅ + - Added support for `Signer2` interface + - Added support for `Verifier2` interface + - Adapts parameter order automatically + - Maintains backward compatibility with v2 interfaces +5. **Add comprehensive tests** ✅ + - Tests for V3 functions + - Cross-compatibility tests (v2 ↔ v3) + - All tests passing +6. **Document** implementation details ✅ + +### Implementation Complete + +✅ **FULLY IMPLEMENTED**: Using recommended non-deprecated jwx v3 APIs +- `SignerFor()` for creating signers (not deprecated `NewSigner()`) +- `VerifierFor()` for creating verifiers +- Proper parameter order handling for new interfaces +- All tests passing +- Zero deprecated code paths + +**Status**: ✅ COMPLETED - USING BEST PRACTICES + diff --git a/internal-docs/README.md b/internal-docs/README.md new file mode 100644 index 0000000..5e7e520 --- /dev/null +++ b/internal-docs/README.md @@ -0,0 +1,19 @@ +# Internal Documentation + +This directory contains internal documentation for maintainers of the httpsign library. + +## Contents + +- **JWX_V3_MIGRATION_PLAN.md** - Comprehensive plan for migrating from jwx v2 to v3, including implementation strategy, testing requirements, and timeline. + +## Purpose + +Internal documentation includes: +- Migration plans for dependencies +- Implementation strategies +- Technical decision records +- Maintenance guides +- Internal architecture notes + +This documentation is not meant for end users of the library. For user-facing documentation, see the main README.md. + diff --git a/message.go b/message.go new file mode 100644 index 0000000..3923fc8 --- /dev/null +++ b/message.go @@ -0,0 +1,217 @@ +package httpsign + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// MessageDetails aggregates the details of a signed message, for a given signature +type MessageDetails struct { + KeyID string + Alg string + Fields Fields + Created *time.Time + Expires *time.Time + Nonce *string + Tag *string +} + +// Message represents a parsed HTTP message ready for signature verification. +type Message struct { + headers http.Header + trailers http.Header + body *io.ReadCloser + + method string + url *url.URL + authority string + scheme string + statusCode *int + assocReq *Message +} + +// NewMessage constructs a new Message from the provided config. +func NewMessage(config *MessageConfig) (*Message, error) { + if config == nil { + config = NewMessageConfig() + } + + hasRequest := config.method != "" + hasResponse := config.statusCode != nil + + if !hasRequest && !hasResponse { + return nil, fmt.Errorf("message config must have either method (for request) or status code (for response)") + } + + if hasRequest && hasResponse { + return nil, fmt.Errorf("message config cannot have both request and response fields set") + } + + if hasRequest { + if config.headers == nil { + return nil, fmt.Errorf("request message must have headers") + } + } + + if hasResponse { + if config.headers == nil { + return nil, fmt.Errorf("response message must have headers") + } + } + + var assocReq *Message + if config.assocReq != nil { + method := config.assocReq.method + u := config.assocReq.url + headers := config.assocReq.headers + authority := config.assocReq.authority + scheme := config.assocReq.scheme + if method == "" || u == nil || headers == nil || authority == "" || scheme == "" { + return nil, fmt.Errorf("invalid associated request") + } + assocReq = &Message{ + method: method, + url: u, + headers: headers, + authority: authority, + scheme: scheme, + } + } + + return &Message{ + headers: config.headers, + trailers: config.trailers, + body: config.body, + method: config.method, + url: config.url, + authority: config.authority, + scheme: config.scheme, + statusCode: config.statusCode, + assocReq: assocReq, + }, nil +} + +// MessageConfig configures a Message for signature verification. +type MessageConfig struct { + method string + url *url.URL + headers http.Header + trailers http.Header + body *io.ReadCloser + authority string + scheme string + + statusCode *int + + assocReq *MessageConfig +} + +// NewMessageConfig returns a new MessageConfig. +func NewMessageConfig() *MessageConfig { + return &MessageConfig{} +} + +func (b *MessageConfig) WithMethod(method string) *MessageConfig { + b.method = method + return b +} + +func (b *MessageConfig) WithURL(u *url.URL) *MessageConfig { + b.url = u + return b +} + +func (b *MessageConfig) WithHeaders(headers http.Header) *MessageConfig { + b.headers = headers + return b +} + +func (b *MessageConfig) WithTrailers(trailers http.Header) *MessageConfig { + b.trailers = trailers + return b +} + +func (b *MessageConfig) WithBody(body *io.ReadCloser) *MessageConfig { + b.body = body + return b +} + +func (b *MessageConfig) WithAuthority(authority string) *MessageConfig { + b.authority = authority + return b +} + +func (b *MessageConfig) WithScheme(scheme string) *MessageConfig { + b.scheme = scheme + return b +} + +func (b *MessageConfig) WithStatusCode(statusCode int) *MessageConfig { + b.statusCode = &statusCode + return b +} + +func (b *MessageConfig) WithAssociatedRequest(method string, u *url.URL, headers http.Header, authority, scheme string) *MessageConfig { + b.assocReq = &MessageConfig{ + method: method, + url: u, + headers: headers, + authority: authority, + scheme: scheme, + } + return b +} + +func (b *MessageConfig) WithRequest(req *http.Request) *MessageConfig { + if req == nil { + return b + } + + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + + return b. + WithMethod(req.Method). + WithURL(req.URL). + WithHeaders(req.Header). + WithTrailers(req.Trailer). + WithBody(&req.Body). + WithAuthority(req.Host). + WithScheme(scheme) +} + +func (b *MessageConfig) WithResponse(res *http.Response, req *http.Request) *MessageConfig { + if res == nil { + return b + } + + b = b. + WithStatusCode(res.StatusCode). + WithHeaders(res.Header). + WithTrailers(res.Trailer). + WithBody(&res.Body) + + if req != nil { + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + b = b.WithAssociatedRequest(req.Method, req.URL, req.Header, req.Host, scheme) + } + + return b +} + +// Verify verifies a signature on this message. +func (m *Message) Verify(signatureName string, verifier Verifier) (*MessageDetails, error) { + _, psiSig, err := verifyDebug(signatureName, verifier, m) + if err != nil { + return nil, err + } + return signatureDetails(psiSig) +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..b586179 --- /dev/null +++ b/message_test.go @@ -0,0 +1,48 @@ +package httpsign_test + +import ( + "bufio" + "bytes" + "fmt" + "net/http" + "strings" + + "github.com/yaronf/httpsign" +) + +func ExampleMessage_Verify() { + config := httpsign.NewVerifyConfig().SetKeyID("my-shared-secret").SetVerifyCreated(false) // for testing only + verifier, _ := httpsign.NewHMACSHA256Verifier(bytes.Repeat([]byte{0x77}, 64), config, + httpsign.Headers("@authority", "Date", "@method")) + reqStr := `GET /foo HTTP/1.1 +Host: example.org +Date: Tue, 20 Apr 2021 02:07:55 GMT +Cache-Control: max-age=60 +Signature-Input: sig77=("@authority" "date" "@method");alg="hmac-sha256";keyid="my-shared-secret" +Signature: sig77=:3e9KqLP62NHfHY5OMG4036+U6tvBowZF35ALzTjpsf0=: + +` + req, _ := http.ReadRequest(bufio.NewReader(strings.NewReader(reqStr))) + + // Using WithRequest + msgWithRequest, _ := httpsign.NewMessage(httpsign.NewMessageConfig().WithRequest(req)) + _, err1 := msgWithRequest.Verify("sig77", *verifier) + + // Using constituent parts + msgWithConstituents, _ := httpsign.NewMessage(httpsign.NewMessageConfig(). + WithMethod(req.Method). + WithURL(req.URL). + WithHeaders(req.Header). + WithTrailers(req.Trailer). + WithBody(&req.Body). + WithAuthority(req.Host). + WithScheme(req.URL.Scheme)) + + _, err2 := msgWithConstituents.Verify("sig77", *verifier) + + fmt.Printf("WithRequest: %t\n", err1 == nil) + fmt.Printf("Constituents: %t", err2 == nil) + // Output: + // WithRequest: true + // Constituents: true +} diff --git a/signatures.go b/signatures.go index 4f96d08..da5bbb6 100644 --- a/signatures.go +++ b/signatures.go @@ -3,11 +3,12 @@ package httpsign import ( "errors" "fmt" - "github.com/dunglas/httpsfv" "io" "net/http" "strings" "time" + + "github.com/dunglas/httpsfv" ) func signMessage(config SignConfig, signatureName string, signer Signer, parsedMessage, parsedAssocMessage *parsedMessage, @@ -358,28 +359,12 @@ func VerifyRequest(signatureName string, verifier Verifier, req *http.Request) e } func verifyRequestDebug(signatureName string, verifier Verifier, req *http.Request) (signatureBase string, err error) { - if req == nil { - return "", fmt.Errorf("nil request") - } - if signatureName == "" { - return "", fmt.Errorf("empty signature name") - } - withTrailers, wantSigRaw, psiSig, err := extractSignatureFields(signatureName, &verifier, req.Header, req.Trailer, &req.Body) + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) if err != nil { return "", err } - parsedMessage, err := parseRequest(req, withTrailers) - if err != nil { - return "", err - } - return verifyMessage(*verifier.config, verifier, parsedMessage, nil, verifier.fields, - wantSigRaw, psiSig) -} - -// MessageDetails aggregates the details of a signed message, for a given signature -type MessageDetails struct { - KeyID, Alg string - Fields Fields + signatureBase, _, err = verifyDebug(signatureName, verifier, msg) + return } // RequestDetails parses a signed request and returns the key ID and optionally the algorithm used in the given signature. @@ -397,6 +382,46 @@ func RequestDetails(signatureName string, req *http.Request) (details *MessageDe return signatureDetails(psiSig) } +func verifyDebug(signatureName string, verifier Verifier, message *Message) (string, *psiSignature, error) { + if message == nil { + return "", nil, fmt.Errorf("nil message") + } + if signatureName == "" { + return "", nil, fmt.Errorf("empty signature name") + } + + withTrailers, wantSigRaw, psiSig, err := extractSignatureFields( + signatureName, &verifier, message.headers, message.trailers, message.body) + if err != nil { + return "", nil, err + } + + var parsedMsg *parsedMessage + var parsedAssoc *parsedMessage + + // Parse the main message + parsedMsg, err = parseMessage(message, withTrailers) + if err != nil { + return "", nil, err + } + + // If there's an associated request, parse that too + if assocMsg := message.assocReq; assocMsg != nil { + parsedAssoc, err = parseMessage(assocMsg, false) + if err != nil { + return "", nil, err + } + } + + signatureBase, err := verifyMessage(*verifier.config, verifier, parsedMsg, parsedAssoc, + verifier.fields, wantSigRaw, psiSig) + if err != nil { + return "", nil, err + } + + return signatureBase, psiSig, nil +} + // ResponseDetails parses a signed response and returns the key ID and optionally the algorithm used in the given signature. func ResponseDetails(signatureName string, res *http.Response) (details *MessageDetails, err error) { if res == nil { @@ -439,7 +464,8 @@ func ResponseSignatureNames(res *http.Response, withTrailers bool) ([]string, er } func messageSignatureNames(parsedMessage *parsedMessage, withTrailers bool) ([]string, error) { - //lint:ignore SA1008 the Header type expects canonicalized names, tough + // Note: parsedMessage.headers intentionally uses lowercase keys (see httpparse.go) + // Linter warning about non-canonical key is expected and can be ignored signatureField := parsedMessage.headers["signature"] dict, err := httpsfv.UnmarshalDictionary(signatureField) if err != nil { @@ -447,7 +473,8 @@ func messageSignatureNames(parsedMessage *parsedMessage, withTrailers bool) ([]s } names := dict.Names() if withTrailers { - //lint:ignore SA1008 the Header type expects canonicalized names, tough + // Note: parsedMessage.trailers intentionally uses lowercase keys (see httpparse.go) + // Linter warning about non-canonical key is expected and can be ignored signatureField := parsedMessage.trailers["signature"] dict, err := httpsfv.UnmarshalDictionary(signatureField) if err != nil { @@ -475,11 +502,28 @@ func signatureDetails(signature *psiSignature) (details *MessageDetails, err err return nil, fmt.Errorf("malformed \"alg\" parameter") } } - return &MessageDetails{ + details = &MessageDetails{ KeyID: keyID, Alg: alg, Fields: signature.fields, - }, nil + } + + if created, ok := signature.params["created"].(int64); ok { + t := time.Unix(created, 0) + details.Created = &t + } + if expires, ok := signature.params["expires"].(int64); ok { + t := time.Unix(expires, 0) + details.Expires = &t + } + if nonce, ok := signature.params["nonce"].(string); ok { + details.Nonce = &nonce + } + if tag, ok := signature.params["tag"].(string); ok { + details.Tag = &tag + } + + return details, nil } // VerifyResponse verifies a signed HTTP response. Returns an error if verification failed for any reason, otherwise nil. @@ -489,30 +533,12 @@ 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 res == nil { - return "", fmt.Errorf("nil response") - } - if signatureName == "" { - return "", fmt.Errorf("empty signature name") - } - resWithTrailers, wantSigRaw, psiSig, err := extractSignatureFields(signatureName, &verifier, res.Header, res.Trailer, &res.Body) + msg, err := NewMessage(NewMessageConfig().WithResponse(res, req)) if err != nil { return "", err } - parsedMessage, err := parseResponse(res, resWithTrailers) - if err != nil { - return "", err - } - // Read the associated request with trailers if the verifier requests its trailers, or there are signed trailer - // covered in the signature - reqWithTrailers := verifier.fields.hasTrailerFields(true) || psiSig.fields.hasTrailerFields(true) - parsedAssocMessage, err := parseRequest(req, reqWithTrailers) - if err != nil { - return "", err - } - signatureBase, err = verifyMessage(*verifier.config, verifier, parsedMessage, parsedAssocMessage, - verifier.fields, wantSigRaw, psiSig) - return signatureBase, err + signatureBase, _, err = verifyDebug(signatureName, verifier, msg) + return } func extractSignatureFields(signatureName string, verifier *Verifier, diff --git a/signatures_test.go b/signatures_test.go index 663476a..afd300b 100644 --- a/signatures_test.go +++ b/signatures_test.go @@ -14,11 +14,12 @@ import ( "encoding/base64" "encoding/pem" "fmt" - "github.com/stretchr/testify/assert" "net/http" "strings" "testing" "time" + + "github.com/stretchr/testify/assert" ) var httpreq1 = `POST /foo?param=value&pet=dog HTTP/1.1 @@ -685,6 +686,25 @@ func TestSignAndVerifyHMAC(t *testing.T) { assert.NoError(t, err, "verification error") } +// Same as TestSignAndVerifyHMAC but using Message +func TestMessageSignAndVerifyHMAC(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") + fields := Headers("@authority", "date", "content-type") + signatureName := "sig1" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signer, _ := NewHMACSHA256Signer(key, config, fields) + req := readRequest(httpreq1) + sigInput, sig, _ := SignRequest(signatureName, *signer, req) + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.NoError(t, err, "verification error") +} + func TestSignAndVerifyHMACNoHeader(t *testing.T) { config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") fields := Headers("@authority", "content-type") @@ -710,6 +730,34 @@ func TestSignAndVerifyHMACNoHeader(t *testing.T) { assert.Error(t, err, "verification should fail, header not found") } +// Same as TestSignAndVerifyHMACNoHeader but using Message +func TestMessageSignAndVerifyHMACNoHeader(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") + fields := Headers("@authority", "content-type") + signatureName := "sig1" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signer, _ := NewHMACSHA256Signer(key, config, fields) + req := readRequest(longReq1) + _, sig, err := SignRequest(signatureName, *signer, req) + assert.NoError(t, err, "failed to sign") + req.Header.Add("Signature", sig) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + err = VerifyRequest(signatureName, *verifier, req) + assert.Error(t, err, "verification should fail, header not found") + + req = readRequest(longReq1) + sigInput, _, err := SignRequest(signatureName, *signer, req) + assert.NoError(t, err, "failed to sign") + req.Header.Add("Signature-Input", sigInput) + verifier, err = NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.Error(t, err, "verification should fail, header not found") +} + func TestSignAndVerifyHMACBad(t *testing.T) { config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") fields := Headers("@authority", "date", "content-type") @@ -727,6 +775,26 @@ func TestSignAndVerifyHMACBad(t *testing.T) { assert.Error(t, err, "verification should have failed") } +// Same as TestSignAndVerifyHMACBad but using Message +func TestMessageSignAndVerifyHMACBad(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") + fields := Headers("@authority", "date", "content-type") + signatureName := "sig1" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signer, _ := NewHMACSHA256Signer(key, config, fields) + req := readRequest(httpreq1) + sigInput, sig, _ := SignRequest(signatureName, *signer, req) + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + badkey := append(key, byte(0x77)) + verifier, err := NewHMACSHA256Verifier(badkey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.Error(t, err, "verification should have failed") +} + func TestCreated(t *testing.T) { testOnceWithConfig := func(t *testing.T, createdTime int64, verifyConfig *VerifyConfig, wantSuccess bool) { fields := Headers("@status", "date", "content-type") @@ -792,6 +860,76 @@ func TestCreated(t *testing.T) { t.Run("verify logic requires to verify Created", testDateFail) } +// Same as TestCreated but using Message +func TestMessageCreated(t *testing.T) { + testOnceWithConfig := func(t *testing.T, createdTime int64, verifyConfig *VerifyConfig, wantSuccess bool) { + fields := Headers("@status", "date", "content-type") + signatureName := "sigres" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signConfig := NewSignConfig().SignCreated(true).setFakeCreated(createdTime).SetKeyID("test-shared-secret") + signer, _ := NewHMACSHA256Signer(key, signConfig, fields) + res := readResponse(httpres2) + nowStr := time.Now().UTC().Format(http.TimeFormat) + res.Header.Set("Date", nowStr) + sigInput, sig, _ := SignResponse(signatureName, *signer, res, nil) + + res2 := readResponse(httpres2) + res2.Header.Set("Date", nowStr) + res2.Header.Add("Signature", sig) + res2.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, verifyConfig, fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithResponse(res2, nil)) + if err != nil { + t.Errorf("could not create Message: %s", err) + } + _, err = msg.Verify(signatureName, *verifier) + + if wantSuccess && err != nil { + t.Errorf("verification error: %s", err) + } + if !wantSuccess && err == nil { + t.Errorf("expected verification to fail") + } + } + testOnce := func(t *testing.T, createdTime int64, wantSuccess bool) { + testOnceWithConfig(t, createdTime, nil, wantSuccess) + } + now := time.Now().Unix() // the window is in ms, but "created" granularity is in sec! + testInWindow := func(t *testing.T) { testOnce(t, now, true) } + testOlder := func(t *testing.T) { testOnce(t, now-20_000, false) } + testNewer := func(t *testing.T) { testOnce(t, now+3_000, false) } + testOldWindow1 := func(t *testing.T) { + testOnceWithConfig(t, now-20_000, NewVerifyConfig().SetNotOlderThan(19_000*time.Second), false) + } + testOldWindow2 := func(t *testing.T) { + testOnceWithConfig(t, now-20_000, NewVerifyConfig().SetNotOlderThan(21_000*time.Second), true) + } + testNewWindow1 := func(t *testing.T) { + testOnceWithConfig(t, now+15_000, NewVerifyConfig().SetNotNewerThan(16_000*time.Second), true) + } + testNewWindow2 := func(t *testing.T) { + testOnceWithConfig(t, now+15_000, NewVerifyConfig().SetNotNewerThan(14_000*time.Second), false) + } + testDate := func(t *testing.T) { + testOnceWithConfig(t, now, NewVerifyConfig().SetVerifyDateWithin(100*time.Millisecond), true) + } + testDateFail := func(t *testing.T) { + testOnceWithConfig(t, now, NewVerifyConfig().SetVerifyCreated(false).SetVerifyDateWithin(100*time.Millisecond), false) + } + t.Run("in window", testInWindow) + t.Run("older", testOlder) + t.Run("newer", testNewer) + t.Run("older, smaller than window", testOldWindow1) + t.Run("older, larger than window", testOldWindow2) + t.Run("newer, smaller than window", testNewWindow1) + t.Run("newer, larger than window", testNewWindow2) + t.Run("verify Date header within window", testDate) + t.Run("verify logic requires to verify Created", testDateFail) +} + func TestSignAndVerifyResponseHMAC(t *testing.T) { fields := Headers("@status", "date", "content-type") signatureName := "sigres" @@ -814,6 +952,33 @@ func TestSignAndVerifyResponseHMAC(t *testing.T) { } } +// Same as TestSignAndVerifyResponseHMAC but using Message +func TestMessageSignAndVerifyResponseHMAC(t *testing.T) { + fields := Headers("@status", "date", "content-type") + signatureName := "sigres" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + config := NewSignConfig().SetExpires(999).SetKeyID("test-shared-secret") // should have expired long ago (but will be ignored by verifier) + signer, _ := NewHMACSHA256Signer(key, config, fields) // default config + res := readResponse(httpres2) + sigInput, sig, _ := SignResponse(signatureName, *signer, res, nil) + + res2 := readResponse(httpres2) + res2.Header.Add("Signature", sig) + res2.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetRejectExpired(false).SetKeyID("test-shared-secret"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithResponse(res2, nil)) + if err != nil { + t.Errorf("could not create Message: %s", err) + } + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + func TestSignAndVerifyRSAPSS(t *testing.T) { config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-key-rsa-pss") fields := Headers("@authority", "date", "content-type") @@ -841,6 +1006,36 @@ func TestSignAndVerifyRSAPSS(t *testing.T) { } } +// Same as TestSignAndVerifyRSAPSS but using Message +func TestMessageSignAndVerifyRSAPSS(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-key-rsa-pss") + fields := Headers("@authority", "date", "content-type") + signatureName := "sig1" + prvKey, err := loadRSAPSSPrivateKey(rsaPSSPrvKey) + if err != nil { + t.Errorf("cannot read private key") + } + signer, _ := NewRSAPSSSigner(*prvKey, config, fields) + req := readRequest(httpreq1) + sigInput, sig, _ := SignRequest(signatureName, *signer, req) + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPSSPubKey) + if err != nil { + t.Errorf("cannot read public key: %v", err) + } + verifier, err := NewRSAPSSVerifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-rsa-pss"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + func TestSignAndVerifyRSA(t *testing.T) { config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-key-rsa") fields := Headers("@authority", "date", "content-type") @@ -868,6 +1063,36 @@ func TestSignAndVerifyRSA(t *testing.T) { } } +// Same as TestSignAndVerifyRSA but using Message +func TestMessageSignAndVerifyRSA(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-key-rsa") + fields := Headers("@authority", "date", "content-type") + signatureName := "sig1" + prvKey, err := parseRsaPrivateKeyFromPemStr(rsaPrvKey2) + if err != nil { + t.Errorf("cannot read private key") + } + signer, _ := NewRSASigner(*prvKey, config, fields) + req := readRequest(httpreq1) + sigInput, sig, _ := SignRequest(signatureName, *signer, req) + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPubKey2) + if err != nil { + t.Errorf("cannot read public key: %v", err) + } + verifier, err := NewRSAVerifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-rsa"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + func TestSignAndVerifyP256(t *testing.T) { config := NewSignConfig().setFakeCreated(1618884475).SetKeyID("test-key-p256") signatureName := "sig1" @@ -894,6 +1119,35 @@ func TestSignAndVerifyP256(t *testing.T) { } } +// Same as TestMessageSignAndVerifyP256 but using Message +func TestMessageSignAndVerifyP256(t *testing.T) { + config := NewSignConfig().setFakeCreated(1618884475).SetKeyID("test-key-p256") + signatureName := "sig1" + prvKey, pubKey, err := genP256KeyPair() + if err != nil { + t.Errorf("cannot generate P-256 keypair") + } + fields := *NewFields().AddHeader("@method").AddHeader("Date").AddHeader("Content-Type").AddQueryParam("pet") + signer, _ := NewP256Signer(*prvKey, config, fields) + 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 := NewP256Verifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-p256"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + func TestSignAndVerifyP384(t *testing.T) { config := NewSignConfig().setFakeCreated(1618884475).SetKeyID("test-key-p384") signatureName := "sig1" @@ -920,6 +1174,35 @@ func TestSignAndVerifyP384(t *testing.T) { } } +// Same as TestSignAndVerifyP384 but using Message +func TestMessageSignAndVerifyP384(t *testing.T) { + config := NewSignConfig().setFakeCreated(1618884475).SetKeyID("test-key-p384") + signatureName := "sig1" + prvKey, pubKey, err := genP384KeyPair() + if err != nil { + t.Errorf("cannot generate P-384 keypair") + } + fields := *NewFields().AddHeader("@method").AddHeader("Date").AddHeader("Content-Type").AddQueryParam("pet") + signer, _ := NewP384Signer(*prvKey, config, fields) + 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 := NewP384Verifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-p384"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + func TestSignAndVerifyEdDSA(t *testing.T) { pubKey1, prvKey1, err := ed25519.GenerateKey(nil) // Need some tweaking for RFC 8032 keys, see package doc if err != nil { @@ -964,20 +1247,67 @@ func signAndVerifyEdDSA(t *testing.T, signer *Signer, pubKey ed25519.PublicKey, } } -func TestSignResponse(t *testing.T) { - type args struct { - signatureName string - signer Signer - res *http.Response +// Same as TestSignAndVerifyEdDSA but using Message +func TestMessageSignAndVerifyEdDSA(t *testing.T) { + pubKey1, prvKey1, err := ed25519.GenerateKey(nil) // Need some tweaking for RFC 8032 keys, see package doc + if err != nil { + t.Errorf("cannot generate keypair: %s", err) } - tests := []struct { - name string - args args - want string - want1 string - wantErr bool - }{ - { + config := NewSignConfig().setFakeCreated(1618884475).SetKeyID("test-key-ed25519") + fields := *NewFields().AddHeader("@method").AddHeader("Date").AddHeader("Content-Type").AddQueryParam("pet") + signer1, _ := NewEd25519Signer(prvKey1, config, fields) + + messageSignAndVerifyEdDSA(t, signer1, pubKey1, fields) + + seed2 := make([]byte, ed25519.SeedSize) + _, err = rand.Read(seed2) + if err != nil { + t.Errorf("rand failed?") + } + config.SetKeyID("test-key-ed25519") + prvKey2 := ed25519.NewKeyFromSeed(seed2) + pubKey2 := prvKey2.Public().(ed25519.PublicKey) + + signer2, _ := NewEd25519SignerFromSeed(seed2, config, fields) + + messageSignAndVerifyEdDSA(t, signer2, pubKey2, fields) +} + +func messageSignAndVerifyEdDSA(t *testing.T, signer *Signer, pubKey ed25519.PublicKey, fields Fields) { + signatureName := "sig1" + 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 := NewEd25519Verifier(pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ed25519"), fields) + if err != nil { + t.Errorf("could not generate Verifier: %s", err) + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + if err != nil { + t.Errorf("verification error: %s", err) + } +} + +func TestSignResponse(t *testing.T) { + type args struct { + signatureName string + signer Signer + res *http.Response + } + tests := []struct { + name string + args args + want string + want1 string + wantErr bool + }{ + { name: "test response with HMAC", args: args{ signatureName: "sig1", @@ -1184,6 +1514,164 @@ func TestVerifyRequest(t *testing.T) { } } +// Same as TestVerifyRequest but using Message +func TestMessageVerifyRequest(t *testing.T) { + type args struct { + signatureName string + verifier Verifier + req *http.Request + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "test case B.2.1", + args: args{ + signatureName: "sig-b21", + verifier: makeRSAVerifier(t, "test-key-rsa-pss", *NewFields()), + req: readRequest(httpreq1pssMinimal), + }, + want: true, + wantErr: false, + }, + { + name: "test case B.2.2", + args: args{ + signatureName: "sig-b22", + verifier: (func() Verifier { + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPSSPubKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + verifier, _ := NewRSAPSSVerifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-rsa-pss"), *NewFields()) + return *verifier + })(), + req: readRequest(httpreq1pssSelective), + }, + want: true, + wantErr: false, + }, + { + name: "test case B.2.3", + args: args{ + signatureName: "sig-b23", + verifier: (func() Verifier { + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPSSPubKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + verifier, _ := NewRSAPSSVerifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-rsa-pss"), *NewFields()) + return *verifier + })(), + req: readRequest(httpreq1pssFull), + }, + want: true, + wantErr: false, + }, + { + name: "test case B.2.6", + args: args{ + signatureName: "sig-b26", + verifier: (func() Verifier { + prvKey, err := parseEdDSAPrivateKeyFromPemStr(ed25519PrvKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + pubKey := prvKey.Public().(ed25519.PublicKey) + verifier, _ := NewEd25519Verifier(pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ed25519"), *NewFields()) + return *verifier + })(), + req: readRequest(httpreq1ed25519), + }, + want: true, + wantErr: false, + }, + { + name: "test case B.3", // TLS-terminating proxy + args: args{ + signatureName: "ttrp", + verifier: (func() Verifier { + pubKey, _ := parseECPublicKeyFromPemStr(p256PubKey2) + verifier, _ := NewP256Verifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), *NewFields()) + return *verifier + })(), + req: readRequest(httpreqtlsproxy), + }, + want: true, + wantErr: false, + }, + { + name: "verify bad sig (not base64)", + args: args{ + signatureName: "sig1", + verifier: makeRSAVerifier(t, "test-key-rsa-pss", *NewFields()), + req: readRequest(httpreq1pssSelectiveBad), + }, + want: false, + wantErr: true, + }, + { + name: "missing fields", + args: args{ + signatureName: "sig1", + verifier: makeRSAVerifier(t, "test-key-rsa-pss", *NewFields().AddQueryParam("missing")), + req: readRequest(httpreq1pssMinimal), + }, + want: false, + wantErr: true, + }, + { + name: "bad keyID", + args: args{ + signatureName: "sig-b22", + verifier: (func() Verifier { + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPSSPubKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + verifier, _ := NewRSAPSSVerifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("bad-key-id"), *NewFields()) + return *verifier + })(), + req: readRequest(httpreq1pssSelective), + }, + want: false, + wantErr: true, + }, + { + name: "keyID not verified", // this is NOT a failure + args: args{ + signatureName: "sig-b22", + verifier: (func() Verifier { + pubKey, err := parseRsaPublicKeyFromPemStr(rsaPSSPubKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + verifier, _ := NewRSAPSSVerifier(*pubKey, NewVerifyConfig(). + SetVerifyCreated(false), *NewFields()) + return *verifier + })(), + req: readRequest(httpreq1pssSelective), + }, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := NewMessage(NewMessageConfig().WithRequest(tt.args.req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(tt.args.signatureName, tt.args.verifier) + if (err != nil) != tt.wantErr { + t.Errorf("VerifyRequest() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + type failer interface { Errorf(format string, args ...any) } @@ -1345,6 +1833,42 @@ func TestDictionary(t *testing.T) { } } +// Same as TestDictionary but using Message +func TestMessageDictionary(t *testing.T) { + priv, pub, err := genP256KeyPair() + if err != nil { + t.Errorf("failed to generate key") + } + res := readResponse(httpres2) + res.Header.Set("X-Dictionary", "a=1, b=2;x=1;y=2, c=(a b c)") + signer2, err := NewP256Signer(*priv, NewSignConfig().SetKeyID("key10"), + *NewFields().AddHeader("@status").AddDictHeader("x-dictionary", "a")) + if err != nil { + t.Errorf("Could not create signer") + } + sigInput2, sig2, err := SignResponse("sig2", *signer2, res, nil) + if err != nil { + t.Errorf("Could not sign response: %v", err) + } + res.Header.Add("Signature-Input", sigInput2) + res.Header.Add("Signature", sig2) + + // Client verifies response + verifier2, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key10"), + *NewFields().AddHeader("@status").AddDictHeader("x-dictionary", "a")) + if err != nil { + t.Errorf("Could not create verifier: %v", err) + } + msg, err := NewMessage(NewMessageConfig().WithResponse(res, nil)) + if err != nil { + t.Errorf("could not create Message: %s", err) + } + _, err = msg.Verify("sig2", *verifier2) + if err != nil { + t.Errorf("Could not verify response: %v", err) + } +} + func TestMultipleSignaturesOld(t *testing.T) { priv1, _, err := genP256KeyPair() // no pub, no verify if err != nil { @@ -1418,6 +1942,28 @@ func TestMultipleSignatures(t *testing.T) { assert.NoError(t, err, "proxy signature not verified") } +// Same as TestMultipleSignatures but using Message +func TestMessageMultipleSignatures(t *testing.T) { + msg, err := NewMessage(NewMessageConfig().WithRequest(readRequest(httpreq9))) + assert.NoError(t, err, "cannot create message") + pubKey1, err := parseECPublicKeyFromPemStr(p256PubKey2) + assert.NoError(t, err, "cannot parse ECC public key") + verifier1, err := NewP256Verifier(*pubKey1, NewVerifyConfig(). + SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), Headers("@method", "@authority", "@path", "content-digest", + "content-type", "content-length")) + assert.NoError(t, err, "cannot create verifier1") + _, _, err = verifyDebug("sig1", *verifier1, msg) + assert.Error(t, err, "sig1 cannot be verified, because the proxy modified the authority field") + + pubKey2, err := parseRsaPublicKey(rsaPubKey) + assert.NoError(t, err, "cannot parse RSA public key") + verifier2, err := NewRSAVerifier(*pubKey2, NewVerifyConfig(). + SetVerifyCreated(false).SetRejectExpired(false).SetKeyID("test-key-rsa"), *NewFields().AddDictHeader("Signature", "sig1").AddHeaders("@authority", "forwarded")) + assert.NoError(t, err, "cannot create verifier2") + _, _, err = verifyDebug("proxy_sig", *verifier2, msg) + assert.NoError(t, err, "proxy signature not verified") +} + func fold(vs []string) string { return strings.Join(vs, ",") } @@ -1631,13 +2177,55 @@ func TestVerifyResponse(t *testing.T) { } } -func TestOptionalSign(t *testing.T) { - req := readRequest(httpreq2) - f1 := NewFields().AddHeader("date").AddHeaderOptional("x-optional") - key1 := bytes.Repeat([]byte{0x55}, 64) - signer1, err := NewHMACSHA256Signer(key1, NewSignConfig().setFakeCreated(9999).SetKeyID("key1"), *f1) - assert.NoError(t, err, "Could not create signer") - signatureInput, _, signatureBase, err := signRequestDebug("sig1", *signer1, req) +// Same as TestVerifyResponse but using Message +func TestMessageVerifyResponse(t *testing.T) { + type args struct { + signatureName string + verifier Verifier + res *http.Response + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "test case B.2.4", + args: args{ + signatureName: "sig-b24", + verifier: (func() Verifier { + pubKey, err := parseECPublicKeyFromPemStr(p256PubKey2) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + verifier, _ := NewP256Verifier(*pubKey, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), *NewFields()) + return *verifier + })(), + res: readResponse(httpres4), + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := NewMessage(NewMessageConfig().WithResponse(tt.args.res, nil)) + if err != nil { + t.Errorf("could not create Message: %s", err) + } + if _, err = msg.Verify(tt.args.signatureName, tt.args.verifier); (err != nil) != tt.wantErr { + t.Errorf("VerifyResponse() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestOptionalSign(t *testing.T) { + req := readRequest(httpreq2) + f1 := NewFields().AddHeader("date").AddHeaderOptional("x-optional") + key1 := bytes.Repeat([]byte{0x55}, 64) + signer1, err := NewHMACSHA256Signer(key1, NewSignConfig().setFakeCreated(9999).SetKeyID("key1"), *f1) + assert.NoError(t, err, "Could not create signer") + signatureInput, _, signatureBase, err := signRequestDebug("sig1", *signer1, req) assert.NoError(t, err, "Should not fail with optional header absent") assert.Equal(t, "sig1=(\"date\");created=9999;alg=\"hmac-sha256\";keyid=\"key1\"", signatureInput) assert.Equal(t, "\"date\": Tue, 20 Apr 2021 02:07:55 GMT\n\"@signature-params\": (\"date\");created=9999;alg=\"hmac-sha256\";keyid=\"key1\"", signatureBase) @@ -1699,6 +2287,31 @@ func TestAssocMessage(t *testing.T) { assert.NoError(t, err, "Verification should succeed") } +// Same as TestAssocMessage but using Message +func TestMessageAssocRequest(t *testing.T) { + key1 := bytes.Repeat([]byte{0x66}, 64) + assocReq := readRequest(httpreq2) + res1 := readResponse(httpres2) + res1.Header.Set("X-Dictionary", "a=1, b=2;x=1;y=2, c=(a b c)") + f3 := NewFields().AddDictHeaderExt("x-dictionary", "a", true, false, false).AddDictHeaderExt("x-dictionary", "zz", true, false, false). + AddQueryParamExt("pet", false, true, false) + signer3, err := NewHMACSHA256Signer(key1, NewSignConfig().setFakeCreated(9999).SetKeyID("key1"), *f3) + assert.NoError(t, err, "Could not create signer") + signatureInput, signature, signatureBase, err := signResponseDebug("sig1", *signer3, res1, assocReq) + assert.NoError(t, err, "Should not fail with dict headers") + assert.Equal(t, "sig1=(\"x-dictionary\";key=\"a\" \"@query-param\";name=\"pet\";req);created=9999;alg=\"hmac-sha256\";keyid=\"key1\"", signatureInput) + assert.Equal(t, "\"x-dictionary\";key=\"a\": 1\n\"@query-param\";name=\"pet\";req: dog\n\"@query-param\";name=\"pet\";req: snake\n\"@signature-params\": (\"x-dictionary\";key=\"a\" \"@query-param\";name=\"pet\";req);created=9999;alg=\"hmac-sha256\";keyid=\"key1\"", signatureBase) + res1.Header.Add("Signature-Input", signatureInput) + res1.Header.Add("Signature", signature) + + verifier, err := NewHMACSHA256Verifier(key1, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), *f3) + assert.NoError(t, err, "Should create verifier") + msg, err := NewMessage(NewMessageConfig().WithResponse(res1, assocReq)) + assert.NoError(t, err, "Should create message") + _, err = msg.Verify("sig1", *verifier) + assert.NoError(t, err, "Verification should succeed") +} + var httpreq6 = `POST /foo?param=Value&Pet=dog HTTP/1.1 Host: example.com Date: Tue, 20 Apr 2021 02:07:55 GMT @@ -1774,6 +2387,27 @@ func TestRequestBinding(t *testing.T) { assert.NoError(t, err, "verify response") } +// Same as TestRequestBinding but using Message +func TestMessageRequestBinding(t *testing.T) { + req := readRequest(httpreq6) + contentDigest := req.Header.Values("Content-Digest") + err := ValidateContentDigestHeader(contentDigest, &req.Body, []string{DigestSha512}) + assert.NoError(t, err, "validate digest") + + res := readResponse(httpres6) + pubKey2, err := parseECPublicKeyFromPemStr(p256PubKey2) + assert.NoError(t, err, "read pub key") + fields2 := *NewFields() + verifier2, err := NewP256Verifier(*pubKey2, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), fields2) + assert.NoError(t, err, "create verifier") + msg, err := NewMessage(NewMessageConfig().WithResponse(res, req)) + if err != nil { + t.Errorf("could not create Message: %s", err) + } + _, err = msg.Verify("reqres", *verifier2) + assert.NoError(t, err, "verify response") +} + func TestOptionalVerify(t *testing.T) { req := readRequest(httpreq2) req.Header.Add("X-Opt1", "val1") @@ -1813,6 +2447,54 @@ func TestOptionalVerify(t *testing.T) { assert.NoError(t, err, "Should not fail: absent and not signed") } +// Same as TestOptionalVerify but using Message +func TestMessageOptionalVerify(t *testing.T) { + req := readRequest(httpreq2) + req.Header.Add("X-Opt1", "val1") + f1 := NewFields().AddHeader("date").AddHeaderOptional("x-opt1") + key1 := bytes.Repeat([]byte{0x66}, 64) + signer, err := NewHMACSHA256Signer(key1, NewSignConfig().setFakeCreated(8888).SetKeyID("key1"), *f1) + assert.NoError(t, err, "Could not create signer") + sigInput, signature, err := SignRequest("sig1", *signer, req) + assert.NoError(t, err, "Should not fail with optional header present") + req.Header.Add("Signature-Input", sigInput) + req.Header.Add("Signature", signature) + + verifier, err := NewHMACSHA256Verifier(key1, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key1"), *f1) + assert.NoError(t, err, "Could not create verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify("sig1", *verifier) + assert.NoError(t, err, "Should not fail: present and signed") + + req.Header.Del("X-Opt1") // header absent but included in covered components + msg, err = NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify("sig1", *verifier) + assert.Error(t, err, "Should fail: absent and signed") + + req = readRequest(httpreq2) // header present but not signed + req.Header.Add("X-Opt1", "val1") + f2 := NewFields().AddHeader("date") // without the optional header + signer, err = NewHMACSHA256Signer(key1, NewSignConfig().setFakeCreated(2222).SetKeyID("key1"), *f2) + assert.NoError(t, err, "Should not fail to create Signer") + sigInput, signature, err = SignRequest("sig1", *signer, req) + assert.NoError(t, err, "Should not fail with redundant header present") + req.Header.Add("Signature-Input", sigInput) + req.Header.Add("Signature", signature) + + msg, err = NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify("sig1", *verifier) + assert.Error(t, err, "Should fail: present and not signed") + + req.Header.Del("X-Opt1") + msg, err = NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify("sig1", *verifier) + assert.NoError(t, err, "Should not fail: absent and not signed") +} + func TestBinarySequence(t *testing.T) { priv, pub, err := genP256KeyPair() assert.NoError(t, err, "failed to generate key") @@ -1851,6 +2533,49 @@ func TestBinarySequence(t *testing.T) { assert.NoError(t, err, "could not verify response") } +// Same as TestBinarySequence but using Message +func TestMessageBinarySequence(t *testing.T) { + priv, pub, err := genP256KeyPair() + assert.NoError(t, err, "failed to generate key") + res := readResponse(httpres2) + res.Header.Add("Set-Cookie", "a=1, b=2;x=1;y=2, c=(a b c)") + res.Header.Add("Set-Cookie", "d=5, eee") + + // First signature try fails + signer1, err := NewP256Signer(*priv, NewSignConfig().SetKeyID("key20"), + *NewFields().AddHeader("@status").AddHeaderExt("set-cookie", false, false, false, false)) + assert.NoError(t, err, "could not create signer") + _, _, err = SignResponse("sig2", *signer1, res, nil) + assert.Error(t, err, "signature should have failed") + + signer2, err := NewP256Signer(*priv, NewSignConfig().setFakeCreated(1659563420).SetKeyID("key20"), + *NewFields().AddHeader("@status").AddHeaderExt("set-cookie", false, true, false, false)) + assert.NoError(t, err, "could not create signer") + sigInput, sig, sigBase, err := signResponseDebug("sig2", *signer2, res, nil) + assert.NoError(t, err, "could not sign response") + assert.Equal(t, "\"@status\": 200\n\"set-cookie\";bs: :YT0xLCBiPTI7eD0xO3k9MiwgYz0oYSBiIGMp:, :ZD01LCBlZWU=:\n\"@signature-params\": (\"@status\" \"set-cookie\";bs);created=1659563420;alg=\"ecdsa-p256-sha256\";keyid=\"key20\"", sigBase, "unexpected signature base") + res.Header.Add("Signature-Input", sigInput) + res.Header.Add("Signature", sig) + + // Client verifies response - should fail + verifier1, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key20"), + *NewFields().AddHeader("@status").AddHeaderExt("set-cookie", false, false, false, false)) + assert.NoError(t, err, "could not create verifier") + msg, err := NewMessage(NewMessageConfig().WithResponse(res, nil)) + assert.NoError(t, err, "could not create message") + _, err = msg.Verify("sig2", *verifier1) + assert.Error(t, err, "binary sequence verified as non-bs") + + // Client verifies response - should succeed + verifier2, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key20"), + *NewFields().AddHeader("@status").AddHeaderExt("set-cookie", false, true, false, false)) + assert.NoError(t, err, "could not create verifier") + msg, err = NewMessage(NewMessageConfig().WithResponse(res, nil)) + assert.NoError(t, err, "could not create message") + _, err = msg.Verify("sig2", *verifier2) + assert.NoError(t, err, "could not verify response") +} + func TestSignatureTag(t *testing.T) { priv, pub, err := genP256KeyPair() assert.NoError(t, err, "failed to generate key") @@ -1901,6 +2626,63 @@ func TestSignatureTag(t *testing.T) { assert.Error(t, err, "should have failed to verify response") } +// Same as TestSignatureTag but using Message +func TestMessageSignatureTag(t *testing.T) { + priv, pub, err := genP256KeyPair() + assert.NoError(t, err, "failed to generate key") + res := readResponse(httpres2) + + signer1, err := NewP256Signer(*priv, NewSignConfig().SetTag("ctx1").setFakeCreated(1660755826).SetKeyID("key21"), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create signer") + sigInput, sig, sigBase, err := signResponseDebug("sig2", *signer1, res, nil) + assert.NoError(t, err, "signature failed") + assert.Equal(t, "\"@status\": 200\n\"@signature-params\": (\"@status\");created=1660755826;alg=\"ecdsa-p256-sha256\";tag=\"ctx1\";keyid=\"key21\"", sigBase, "unexpected signature base") + res.Header.Add("Signature-Input", sigInput) + res.Header.Add("Signature", sig) + + // Signature should fail with malformed tag + signer2, err := NewP256Signer(*priv, NewSignConfig().SetTag("ctx1\x00").SetKeyID("key21"), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create signer") + _, _, _, err = signResponseDebug("sig2", *signer2, res, nil) + assert.Error(t, err, "signature should fail") + + // Signature should fail when the key ID is an empty string + signer3, err := NewP256Signer(*priv, NewSignConfig().SetKeyID(""), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create signer") + _, _, _, err = signResponseDebug("sig2", *signer3, res, nil) + assert.Error(t, err, "signature should fail") + + // Client verifies response - should succeed, no tag constraint + verifier1, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("key21"), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create verifier") + msg, err := NewMessage(NewMessageConfig().WithResponse(res, nil)) + assert.NoError(t, err, "could not create message") + _, err = msg.Verify("sig2", *verifier1) + assert.NoError(t, err, "failed to verify response") + + // Client verifies response - should succeed, correct tag + verifier2, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetAllowedTags([]string{"ctx3", "ctx2", "ctx1"}).SetKeyID("key21"), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create verifier") + msg, err = NewMessage(NewMessageConfig().WithResponse(res, nil)) + assert.NoError(t, err, "could not create message") + _, err = msg.Verify("sig2", *verifier2) + assert.NoError(t, err, "failed to verify response") + + // Client verifies response - should fail, incorrect tags + verifier3, err := NewP256Verifier(*pub, NewVerifyConfig().SetVerifyCreated(false).SetAllowedTags([]string{"ctx5", "ctx6", "ctx7"}).SetKeyID("key21"), + *NewFields().AddHeader("@status")) + assert.NoError(t, err, "could not create verifier") + msg, err = NewMessage(NewMessageConfig().WithResponse(res, nil)) + assert.NoError(t, err, "could not create message") + _, err = msg.Verify("sig2", *verifier3) + assert.Error(t, err, "should have failed to verify response") +} + var httpTransform1 = `GET /demo?name1=Value1&Name2=value2 HTTP/1.1 Host: example.org Date: Fri, 15 Jul 2022 14:24:55 GMT @@ -1988,6 +2770,36 @@ func TestTransformations(t *testing.T) { testOneTransformation(t, httpTransform6, false) } +func testMessageOneTransformation(t *testing.T, reqStr string, verifies bool) { + // Initial verification successful + prvKey, err := parseEdDSAPrivateKeyFromPemStr(ed25519PrvKey) + if err != nil { + t.Errorf("cannot parse public key: %v", err) + } + pubKey := prvKey.Public().(ed25519.PublicKey) + verifier, err := NewEd25519Verifier(pubKey, NewVerifyConfig().SetVerifyCreated(false), *NewFields()) + assert.NoError(t, err, "could not create verifier") + req := readRequest(reqStr) + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify("transform", *verifier) + if verifies { + assert.NoError(t, err, "failed to verify request") + } else { + assert.Error(t, err, "should fail to verify request") + } +} + +// Same as TestTransformations but using Message +func TestMessageTransformations(t *testing.T) { + testMessageOneTransformation(t, httpTransform1, true) + testMessageOneTransformation(t, httpTransform2, true) + testMessageOneTransformation(t, httpTransform3, true) + testMessageOneTransformation(t, httpTransform4, true) + testMessageOneTransformation(t, httpTransform5, false) + testMessageOneTransformation(t, httpTransform6, false) +} + var httpreq10 = `GET /parameters?var=this%20is%20a%20big%0Amultiline%20value&bar=with+plus+whitespace&fa%C3%A7ade%22%3A%20=something HTTP/1.1 Host: www.example.com Date: Tue, 20 Apr 2021 02:07:56 GMT @@ -2064,6 +2876,42 @@ func TestRequestBinding17(t *testing.T) { assert.NoError(t, err, "validate response digest") } +// Same as TestRequestBinding17 but using Message +func TestMessageRequestBinding17(t *testing.T) { + req := readRequest(httpreq11) + reqContentDigest := req.Header.Values("Content-Digest") + err := ValidateContentDigestHeader(reqContentDigest, &req.Body, []string{DigestSha512}) + assert.NoError(t, err, "validate request digest") + + res := readResponse(httpres9) + msg, err := NewMessage(NewMessageConfig().WithResponse(res, req)) + assert.NoError(t, err, "cannot create message") + pubKey2, err := parseECPublicKeyFromPemStr(p256PubKey2) + assert.NoError(t, err, "read pub key") + fields2 := *NewFields().AddHeaders("@status", "content-digest", "content-type"). + AddHeaderExt("@authority", false, false, true, false). + AddHeaderExt("@method", false, false, true, false). + AddHeaderExt("@path", false, false, true, false). + AddHeaderExt("content-digest", false, false, true, false) + verifier2, err := NewP256Verifier(*pubKey2, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), fields2) + assert.NoError(t, err, "create verifier") + sigBase, _, err := verifyDebug("reqres", *verifier2, msg) + expected := `"@status": 503 +"content-digest": sha-512=:0Y6iCBzGg5rZtoXS95Ijz03mslf6KAMCloESHObfwnHJDbkkWWQz6PhhU9kxsTbARtY2PTBOzq24uJFpHsMuAg==: +"content-type": application/json +"@authority";req: example.com +"@method";req: POST +"@path";req: /foo +"content-digest";req: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +"@signature-params": ("@status" "content-digest" "content-type" "@authority";req "@method";req "@path";req "content-digest";req);created=1618884479;keyid="test-key-ecc-p256"` + assert.NoError(t, err, "verify response") + assert.Equal(t, expected, sigBase, "Incorrect signature base for response") + + responseContentDigest := res.Header.Values("Content-Digest") + err = ValidateContentDigestHeader(responseContentDigest, &res.Body, []string{DigestSha512}) + assert.NoError(t, err, "validate response digest") +} + var httpreq12 = `POST /foo?param=Value&Pet=dog HTTP/1.1 Host: origin.host.internal.example Date: Tue, 20 Apr 2021 02:07:56 GMT @@ -2115,6 +2963,34 @@ func TestMultipleSignatures17(t *testing.T) { assert.NoError(t, err, "sig1 should verify for the original message that the proxy received") } +// Same as TestMultipleSignatures17 but using Message +func TestMessageMultipleSignatures17(t *testing.T) { + msg, err := NewMessage(NewMessageConfig().WithRequest(readRequest(httpreq12))) + assert.NoError(t, err, "cannot create message") + pubKey1, err := parseECPublicKeyFromPemStr(p256PubKey2) + assert.NoError(t, err, "cannot parse ECC public key") + verifier1, err := NewP256Verifier(*pubKey1, NewVerifyConfig(). + SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), Headers("@method", "@authority", "@path", "content-digest", + "content-type", "content-length")) + assert.NoError(t, err, "cannot create verifier1") + _, _, err = verifyDebug("sig1", *verifier1, msg) + assert.Error(t, err, "sig1 cannot be verified, because the proxy modified the authority field") + + pubKey2, err := parseRsaPublicKey(rsaPubKey) + assert.NoError(t, err, "cannot parse RSA public key") + verifier2, err := NewRSAVerifier(*pubKey2, NewVerifyConfig(). + SetVerifyCreated(false).SetRejectExpired(false), *NewFields().AddHeaders("@authority", "forwarded")) + assert.NoError(t, err, "cannot create verifier2") + _, _, err = verifyDebug("proxy_sig", *verifier2, msg) + assert.NoError(t, err, "proxy signature not verified") + + msg, err = NewMessage(NewMessageConfig().WithRequest(readRequest(httpreq13))) + assert.NoError(t, err, "cannot create message") + sigBase, _, err := verifyDebug("sig1", *verifier1, msg) + assert.NotEmpty(t, sigBase) + assert.NoError(t, err, "sig1 should verify for the original message that the proxy received") +} + var httpreq14 = `POST /foo?param=Value&Pet=dog HTTP/1.1 Host: example.com Date: Tue, 20 Apr 2021 02:07:55 GMT @@ -2176,3 +3052,44 @@ func TestRequestBindingSignedResponse17(t *testing.T) { err = ValidateContentDigestHeader(responseContentDigest, &res.Body, []string{DigestSha512}) assert.NoError(t, err, "validate response digest") } + +// Same as TestRequestBindingSignedResponse17 but using Message +func TestMessageRequestBindingSignedResponse17(t *testing.T) { + req := readRequest(httpreq14) + reqContentDigest := req.Header.Values("Content-Digest") + err := ValidateContentDigestHeader(reqContentDigest, &req.Body, []string{DigestSha512}) + assert.NoError(t, err, "validate request digest") + + res := readResponse(httpres10) + msg, err := NewMessage(NewMessageConfig().WithResponse(res, req)) + assert.NoError(t, err, "cannot create message") + pubKey2, err := parseECPublicKeyFromPemStr(p256PubKey2) + assert.NoError(t, err, "read pub key") + fields2 := *NewFields().AddHeaders("@status", "content-digest", "content-type"). + AddHeaderExt("@authority", false, false, true, false). + AddHeaderExt("@method", false, false, true, false). + AddHeaderExt("@path", false, false, true, false). + AddHeaderExt("@query", false, false, true, false). + AddHeaderExt("content-digest", false, false, true, false) + verifier2, err := NewP256Verifier(*pubKey2, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-key-ecc-p256"), fields2) + assert.NoError(t, err, "create verifier") + sigBase, _, err := verifyDebug("reqres", *verifier2, msg) + expected := `"@status": 503 +"content-digest": sha-512=:0Y6iCBzGg5rZtoXS95Ijz03mslf6KAMCloESHObfwnHJDbkkWWQz6PhhU9kxsTbARtY2PTBOzq24uJFpHsMuAg==: +"content-type": application/json +"@authority";req: example.com +"@method";req: POST +"@path";req: /foo +"@query";req: ?param=Value&Pet=dog +"content-digest";req: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +"content-type";req: application/json +"content-length";req: 18 +"@signature-params": ("@status" "content-digest" "content-type" "@authority";req "@method";req "@path";req "@query";req "content-digest";req "content-type";req "content-length";req);created=1618884479;keyid="test-key-ecc-p256"` + + assert.NoError(t, err, "verify response") + assert.Equal(t, expected, sigBase, "Incorrect signature base for response") + + responseContentDigest := res.Header.Values("Content-Digest") + err = ValidateContentDigestHeader(responseContentDigest, &res.Body, []string{DigestSha512}) + assert.NoError(t, err, "validate response digest") +} diff --git a/signaturesex_test.go b/signaturesex_test.go index ab6df42..578b4c8 100644 --- a/signaturesex_test.go +++ b/signaturesex_test.go @@ -4,9 +4,10 @@ import ( "bufio" "bytes" "fmt" - "github.com/yaronf/httpsign" "net/http" "strings" + + "github.com/yaronf/httpsign" ) func ExampleSignRequest() { diff --git a/trailer_test.go b/trailer_test.go index efbc100..2945333 100644 --- a/trailer_test.go +++ b/trailer_test.go @@ -4,12 +4,13 @@ import ( "bytes" "encoding/base64" "fmt" - "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "net/url" "strings" "testing" + + "github.com/stretchr/testify/assert" ) var rawPost1 = `POST /foo HTTP/1.1 @@ -173,3 +174,50 @@ func TestTrailer_SigFields(t *testing.T) { err = VerifyRequest(signatureName, *verifier, req2) assert.Error(t, err, "verification error") } + +// Same as TestTrailer_SigFields but using Message +func TestMessageTrailer_SigFields(t *testing.T) { + config := NewSignConfig().SignAlg(false).setFakeCreated(1618884475).SetKeyID("test-shared-secret") + fields := Headers("@authority", "@method", "content-type") + signatureName := "sig1" + key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==") + signer, _ := NewHMACSHA256Signer(key, config, fields) + req := readRequest(rawPost2) + sigInput, sig, err := SignRequest(signatureName, *signer, req) + assert.NoError(t, err, "signature failed") + // Add signature correctly + signedMessage := rawPost2 + "Signature: " + sig + "\n" + "Signature-Input: " + sigInput + "\n\n" + signedMessage = strings.Replace(signedMessage, "Trailer: Expires, Hdr", "Trailer: Expires, Hdr, Signature, Signature-Input", + 1) + req2 := readRequestChunked(signedMessage) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err := NewMessage(NewMessageConfig().WithRequest(req2)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.NoError(t, err, "verification error") + + // Missing Signature-Input + signedMessage = rawPost2 + "Signature: " + sig + "\n\n" + signedMessage = strings.Replace(signedMessage, "Trailer: Expires, Hdr", "Trailer: Expires, Hdr, Signature, Signature-Input", + 1) + req2 = readRequestChunked(signedMessage) + verifier, err = NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err = NewMessage(NewMessageConfig().WithRequest(req2)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.Error(t, err, "verification error") + + // Missing Signature + signedMessage = rawPost2 + "Signature-Input: " + sigInput + "\n\n" + signedMessage = strings.Replace(signedMessage, "Trailer: Expires, Hdr", "Trailer: Expires, Hdr, Signature, Signature-Input", + 1) + req2 = readRequestChunked(signedMessage) + verifier, err = NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + assert.NoError(t, err, "could not generate Verifier") + msg, err = NewMessage(NewMessageConfig().WithRequest(req2)) + assert.NoError(t, err, "could not create Message") + _, err = msg.Verify(signatureName, *verifier) + assert.Error(t, err, "verification error") +}