diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f485b4c..a0aff53 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,3 +15,25 @@ jobs: cache: false - name: Test run: go test ./... + + fuzz: + runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: '1.27' + cache: false + - name: Fuzz smoke + # Short per-target budget (~75s total). See internal-docs/FUZZ.md. + run: | + set -euo pipefail + for target in FuzzVerifyRequest FuzzVerifyViaMessage FuzzSignAndVerifyHMAC FuzzHMACViaMessage FuzzNewMessage; do + echo "::group::fuzz $target" + go test -run='^$' -fuzz="$target" -fuzztime=15s . + echo "::endgroup::" + done diff --git a/CLAUDE.md b/CLAUDE.md index 2e6b529..e4afe30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Optional foreign JWS uses `lestrrat-go/jwx/v4` via `NewJWSSigner` / `NewJWSVerif ### Testing conventions - `signatures_test.go` contains the full RFC 9421 test vector suite (134 KB) — do not modify without understanding the spec. -- `fuzz_test.go` has fuzz entry points; seed corpus lives in `testdata/fuzz/`. +- `fuzz_test.go` has fuzz entry points (`FuzzVerifyRequest`, `FuzzVerifyViaMessage`, `FuzzSignAndVerifyHMAC`, `FuzzHMACViaMessage`, `FuzzNewMessage`); see [internal-docs/FUZZ.md](internal-docs/FUZZ.md). Seed corpus: `f.Add` plus optional `testdata/fuzz/`. - `http2_test.go` and `trailer_test.go` cover HTTP/2 and trailer-header edge cases. - Tests use `github.com/stretchr/testify` assertions and `github.com/andreyvit/diff` for readable diffs. diff --git a/fuzz_test.go b/fuzz_test.go index 79c94f4..17cb565 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -5,8 +5,6 @@ import ( "net/http" "net/url" "testing" - - "github.com/stretchr/testify/assert" ) var httpreq1pssNoSig = `POST /foo?param=Value&Pet=dog HTTP/1.1 @@ -19,159 +17,227 @@ Content-Length: 18 {"hello": "world"} ` +var httpreqTrailers = `POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Trailer: Expires +Content-Length: 18 + +{"hello": "world"} +Expires: Wed, 9 Nov 2022 07:28:00 GMT +` + +// sharedHMACKeyB64 is the RFC 9421 test shared secret (base64). +const sharedHMACKeyB64 = "uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==" + +func fuzzHMACKey() []byte { + key, _ := base64.StdEncoding.DecodeString(sharedHMACKeyB64) + return key +} + +// FuzzVerifyRequest mutates Signature-Input / Signature against a fixed verifier. +// Panic-oriented: expected verify/parse failures are ignored. func FuzzVerifyRequest(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==:", + `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==:`, + }, + {httpreqTrailers, + `sig-b21=("expires";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-b21=("content-type";bs);created=1618884473;keyid="test-key-rsa-pss"`, + `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==:", + `sig-b21=("content-type";sf);created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, {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==:", + `sig-b21=("@query-param";name="Pet");created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, {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==:", + `sig-b21=("content-digest");created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, + // Malformed SFV / truncated dictionaries + {httpreq1pssNoSig, `sig-b21=(`, `sig-b21=:AAAA:`}, + {httpreq1pssNoSig, `sig-b21=("@method";created=1`, `sig-b21=:AAAA:`}, + {httpreq1pssNoSig, `not-a-dict`, `sig-b21=:not-b64:`}, + {httpreq1pssNoSig, `sig-b21=("@method");created=abc;keyid="x"`, `sig-b21=:AAAA:`}, + {httpreq1pssNoSig, `sig-b21=("@method" "date");alg="rsa-pss-sha512";created=1618884473;keyid="test-key-rsa-pss";tag="t";nonce="n"`, `sig-b21=:AAAA:`}, + {httpreq1pssNoSig, `sig-b21=("@method");created=1618884473;keyid="test-key-rsa-pss",sig2=("@authority");created=1`, `sig-b21=:AAAA:,sig2=:BBBB:`}, } for _, tc := range testcases { - f.Add(tc.req, tc.sigInput, tc.sig) // Use f.Add to provide a seed corpus + f.Add(tc.req, tc.sigInput, tc.sig) } + verifier := makeRSAVerifier(f, "key1", *NewFields()) 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) + if req == nil { + return } - - sigName := "sig-b21" - verifier := makeRSAVerifier(f, "key1", *NewFields()) - _ = VerifyRequest(sigName, verifier, req) - // only report panics + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) + _ = VerifyRequest("sig-b21", verifier, req) }) } -// Same as FuzzVerifyRequest but using Message -func FuzzMessageVerifyRequest(f *testing.F) { +// FuzzVerifyViaMessage is the Message.Verify twin of FuzzVerifyRequest. +// Kept separately: Message construction and header maps are a distinct panic surface from net/http helpers. +func FuzzVerifyViaMessage(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==:", + `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==:", + `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==:`, + }, + {httpreqTrailers, + `sig-b21=("expires";tr);created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, {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==:", + `sig-b21=("content-type";bs);created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, {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==:", + `sig-b21=("@query-param";name="Pet");created=1618884473;keyid="test-key-rsa-pss"`, + `sig-b21=:AAAA:`, }, + {httpreq1pssNoSig, `sig-b21=(`, `sig-b21=:AAAA:`}, + {httpreq1pssNoSig, `sig-b21=("@method");created=abc`, `sig-b21=:not-b64:`}, } for _, tc := range testcases { - f.Add(tc.req, tc.sigInput, tc.sig) // Use f.Add to provide a seed corpus + f.Add(tc.req, tc.sigInput, tc.sig) } + verifier := makeRSAVerifier(f, "key1", *NewFields()) 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) + if req == nil { + return } - - sigName := "sig-b21" - verifier := makeRSAVerifier(f, "key1", *NewFields()) + req.Header.Set("Signature-Input", sigInput) + req.Header.Set("Signature", sig) msg, err := NewMessage(NewMessageConfig().WithRequest(req)) if err != nil { - return // invalid request from fuzz input; only report panics + return } - _, _ = msg.Verify(sigName, verifier) + _, _ = msg.Verify("sig-b21", verifier) }) } +// FuzzSignAndVerifyHMAC round-trips SignRequest → VerifyRequest. +// Asserts only after a successful SignRequest (nil / parse / sign errors return early). func FuzzSignAndVerifyHMAC(f *testing.F) { - type inputs struct { - req string - } - testcases := []inputs{ - {httpreq1}, - } - for _, tc := range testcases { - f.Add(tc.req) + for _, req := range []string{httpreq1, httpreq1pssNoSig, httpreqTrailers} { + f.Add(req) } + key := fuzzHMACKey() f.Fuzz(func(t *testing.T, reqString string) { + req := readRequest(reqString) + if req == nil { + return + } + if digests := req.Header.Values("Content-Digest"); len(digests) > 0 && req.Body != nil { + _ = ValidateContentDigestHeader(digests, &req.Body, []string{DigestSha256, DigestSha512}) + } 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") - err = VerifyRequest(signatureName, *verifier, req) - assert.NoError(t, err, "verification error") + if req.Header.Get("Content-Digest") != "" { + fields = Headers("@authority", "date", "content-type", "content-digest") + } + signer, err := NewHMACSHA256Signer(key, config.SetKeyID("test-shared-secret"), fields) + if err != nil || signer == nil { + return + } + sigInput, sig, err := SignRequest("sig1", *signer, req) + if err != nil { + return + } + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + if err != nil || verifier == nil { + return + } + if err := VerifyRequest("sig1", *verifier, req); err != nil { + t.Fatalf("round-trip verify failed after successful sign: %v", err) } }) } -// 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) +// FuzzHMACViaMessage is the Message.Verify twin of FuzzSignAndVerifyHMAC (same keep rationale as FuzzVerifyViaMessage). +func FuzzHMACViaMessage(f *testing.F) { + for _, req := range []string{httpreq1, httpreq1pssNoSig, httpreqTrailers} { + f.Add(req) } + key := fuzzHMACKey() f.Fuzz(func(t *testing.T, reqString string) { + req := readRequest(reqString) + if req == nil { + return + } + if digests := req.Header.Values("Content-Digest"); len(digests) > 0 && req.Body != nil { + _ = ValidateContentDigestHeader(digests, &req.Body, []string{DigestSha256, DigestSha512}) + } 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 { - return - } - _, err = msg.Verify(signatureName, *verifier) - assert.NoError(t, err, "verification error") + if req.Header.Get("Content-Digest") != "" { + fields = Headers("@authority", "date", "content-type", "content-digest") + } + signer, err := NewHMACSHA256Signer(key, config.SetKeyID("test-shared-secret"), fields) + if err != nil || signer == nil { + return + } + sigInput, sig, err := SignRequest("sig1", *signer, req) + if err != nil { + return + } + req.Header.Add("Signature", sig) + req.Header.Add("Signature-Input", sigInput) + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false).SetKeyID("test-shared-secret"), fields) + if err != nil || verifier == nil { + return + } + msg, err := NewMessage(NewMessageConfig().WithRequest(req)) + if err != nil { + return + } + if _, err := msg.Verify("sig1", *verifier); err != nil { + t.Fatalf("round-trip Message.Verify failed after successful sign: %v", err) } }) } -func FuzzMessageVerify(f *testing.F) { +// FuzzNewMessage exercises MessageConfig / NewMessage (and a smoke Verify), including response + associated-request shapes. +func FuzzNewMessage(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.Add("", "", "", "", 200, "POST", "https://example.com/x?q=1", "example.com", "https", true, true) + f.Add("PATCH", "https://example.com/a?b=c", "example.com", "https", 0, "", "", "", "", true, true) + key := fuzzHMACKey() f.Fuzz(func(t *testing.T, method, urlStr, authority, scheme string, statusCode int, assocMethod, assocURLStr, assocAuthority, assocScheme string, hasHeaders, hasTrailers bool) { @@ -200,14 +266,18 @@ func FuzzMessageVerify(f *testing.F) { if hasHeaders { headers := http.Header{ - "Content-Type": []string{"application/json"}, - "X-Test": []string{"fuzz"}, + "Content-Type": []string{"application/json"}, + "X-Test": []string{"fuzz"}, + "Content-Digest": []string{"sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}, + "Signature-Input": []string{`sig1=("@method");created=1618884473;keyid="test-key"`}, + "Signature": []string{`sig1=:AAAA:`}, } config = config.WithHeaders(headers) } if hasTrailers { trailers := http.Header{ "X-Trailer": []string{"test"}, + "Expires": []string{"Wed, 9 Nov 2022 07:28:00 GMT"}, } config = config.WithTrailers(trailers) } @@ -222,37 +292,21 @@ func FuzzMessageVerify(f *testing.F) { } 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 + // Invalid configs are expected; only panics are interesting. + return + } + if msg.headers == nil && msg.method != "" { + t.Fatalf("request message created without headers") + } + if msg.headers == nil && msg.statusCode != nil { + t.Fatalf("response message created without headers") + } - 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") - } + verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false), Fields{}) + if err != nil || verifier == nil { + return } + _, _ = msg.Verify("sig1", *verifier) }) } diff --git a/internal-docs/FUZZ.md b/internal-docs/FUZZ.md new file mode 100644 index 0000000..dcfd387 --- /dev/null +++ b/internal-docs/FUZZ.md @@ -0,0 +1,142 @@ +# Fuzz testing playbook + +Maintainer notes for the Go native fuzz suite in [`fuzz_test.go`](../fuzz_test.go). + +## Targets + +Names must not be substrings of each other: `go test -fuzz=` is a **regexp** and must match exactly one function. + +| Target | Role | +|--------|------| +| `FuzzVerifyRequest` | Panic-oriented verify of mutated `Signature-Input` / `Signature` via `VerifyRequest` | +| `FuzzVerifyViaMessage` | Same inputs through `NewMessage` + `Message.Verify` | +| `FuzzSignAndVerifyHMAC` | HMAC sign → verify round-trip; fails only if verify fails after a successful sign | +| `FuzzHMACViaMessage` | Same round-trip with `Message.Verify` | +| `FuzzNewMessage` | `MessageConfig` / `NewMessage` (request, response, associated request, trailers) | + +Message twins are **kept on purpose**: they exercise Message construction and header maps as a separate panic surface from the `net/http` helpers. + +Foreign JWS / ML-DSA is **out of scope** for this suite (separate crypto surface; less likely to find interesting bugs than SFV / Signature-Input parsing). Note as a follow-on if needed. + +## Local commands + +Seed-only (no mutation), useful for coverage: + +```bash +go test -run=FuzzVerifyRequest -coverprofile=cov-verify.out . +go test -run=FuzzSignAndVerifyHMAC -coverprofile=cov-hmac.out . +go tool cover -func=cov-verify.out +go tool cover -func=cov-hmac.out +``` + +Mutating fuzz (fixed budget): + +```bash +go test -run='^$' -fuzz=FuzzVerifyRequest -fuzztime=30s . +go test -run='^$' -fuzz=FuzzVerifyViaMessage -fuzztime=30s . +go test -run='^$' -fuzz=FuzzSignAndVerifyHMAC -fuzztime=30s . +go test -run='^$' -fuzz=FuzzHMACViaMessage -fuzztime=30s . +go test -run='^$' -fuzz=FuzzNewMessage -fuzztime=30s . +``` + +CI uses a shorter per-target budget (`-fuzztime=15s`); see `.github/workflows/test.yml`. + +## Interpreting metrics + +From each fuzz run, note: + +- **execs/sec** — throughput (machine-dependent). +- **new interesting / total** — corpus growth. Steady growth early is healthy; a long plateau with high execs/sec usually means the harness is stable, not that coverage is complete. +- **crash / FAIL** — treat as a bug (or a harness false positive: asserts on expected errors). + +### Coverage focus + +Interpret coverprofiles primarily on the surface the fuzz inputs hit: + +- `signatures.go`, `httpparse.go`, `fields.go`, `digest.go` +- `message.go` for Message targets + +Package-wide `%` is optional secondary context only. It is diluted by client/handler wrappers, algorithm constructors, RFC vector tests, and JWS glue that these harnesses do not aim to exercise. + +## Corpus layout + +- Committed seeds: `f.Add(...)` in `fuzz_test.go`, plus optional files under `testdata/fuzz//`. +- Go’s fuzz cache (interesting inputs found while fuzzing) lives under the module cache / `$GOCACHE`; it is **not** the same as `testdata/fuzz/`. +- Prefer committing **minimized** corpus files that improve seed coverage on the SFV / parse / digest surface. Do not bulk-commit huge cache dumps. +- `.gitignore` ignores local `testdata/fuzz/FuzzSignAndVerifyHMAC/` noise; keep intentional seeds for other targets tracked. + +## Baseline (2026-09-04, ~30s each, 8 workers) + +| Target | Execs (~30s) | New interesting (total) | Crash | +|--------|--------------|-------------------------|-------| +| `FuzzVerifyRequest` | ~723k | 207 (212) | no | +| `FuzzVerifyViaMessage` | ~880k | 239 (243) | no | +| `FuzzSignAndVerifyHMAC` | ~1.0M | 193 (194) | no | +| `FuzzHMACViaMessage` | ~1.0M | 149 (150) | no | +| `FuzzNewMessage` | ~664k | 280 (286) | no | + +Seed coverage (no mutation; `f.Add` + committed `testdata` only), rough mean of per-function statement % on the focus files: + +| Profile | signatures | httpparse | fields | digest | message | package | +|---------|------------|-----------|--------|--------|---------|---------| +| `FuzzVerifyRequest` (pre-seed expand) | ~25% | ~66% | ~12% | ~11% | ~66% | 17.5% | +| `FuzzVerifyRequest` (after SFV seeds) | ~25% | ~66% | ~18% | ~11% | ~66% | 18.5% | +| `FuzzSignAndVerifyHMAC` (after digest-bearing seeds + `ValidateContentDigestHeader`) | ~44% | ~74% | ~40% | `ValidateContentDigestHeader` ~71%, `validateSchemes` ~80% | ~66% | 30.8% | + +Gaps that seeds now push harder: truncated/malformed SFV, `;tr` / `;bs` / `;sf`, `@query-param`, `content-digest`, trailers, response + associated-request configs. + +## Soak (2026-09-05, SFV-weighted ~8h fuzz-time) + +Budgets: `FuzzVerifyRequest` 2h, `FuzzVerifyViaMessage` 2h, `FuzzNewMessage` 90m, `FuzzSignAndVerifyHMAC` 75m, `FuzzHMACViaMessage` 75m. Host sleep paused progress overnight; after sleep was disabled, remaining fuzz-time finished on schedule (~22:30 local). + +| Target | Result | Execs | New interesting (total) | +|--------|--------|------:|-------------------------| +| `FuzzVerifyRequest` | PASS | 220M | 866 (1121) | +| `FuzzVerifyViaMessage` | PASS | 201M | 826 (1118) | +| `FuzzNewMessage` | PASS | 151M | 315 (612) | +| `FuzzSignAndVerifyHMAC` | PASS | 158M | 471 (676) | +| `FuzzHMACViaMessage` | PASS | 144M | 461 (629) | + +**Crashes:** none. + +Interesting-input growth continued through the long runs (especially verify / HMAC), with plateaus late in each budget — expected, not a signal to stop early on a short quiet window. + +### Post-soak corpus coverage + +`go test -fuzz` does not write a coverprofile while mutating. Coverage after a soak is measured by **replaying** the cached corpus: + +1. Interesting inputs live under `$GOCACHE/fuzz/github.com/yaronf/httpsign//` (not under `testdata/fuzz/` unless copied). +2. Stage those files into `testdata/fuzz//` temporarily (hardlinks are fine). +3. `go test -run='^FuzzXxx$' -coverprofile=... .` then exercises the soak corpus as ordinary seeds. +4. Remove the staged files afterward; do **not** bulk-commit the cache dump. + +2026-09-05 replay (cache entry counts ≈ soak “total interesting”), mean per-function % on focus files / package total: + +| Target | Cache entries | signatures | httpparse | fields | digest | message | package | +|--------|--------------:|-----------:|----------:|-------:|-------:|--------:|--------:| +| `FuzzVerifyRequest` | ~1108 | 26.7% | 67.9% | 19.6% | 10.7% | 65.5% | 21.2% | +| `FuzzVerifyViaMessage` | ~1111 | 21.7% | 67.9% | 19.6% | 10.7% | 70.8% | 20.7% | +| `FuzzSignAndVerifyHMAC` | ~673 | 44.5% | 76.7% | 39.5% | 41.8% | 65.5% | 33.4% | +| `FuzzHMACViaMessage` | ~626 | 42.0% | 76.5% | 39.5% | 41.8% | 70.8% | 34.1% | +| `FuzzNewMessage` | ~604 | 23.5% | 74.7% | 21.3% | 0.0% | 76.5% | 22.2% | + +Compared with seed-only baselines, package totals rose a few points (e.g. verify ~18.5%→21.2%, HMAC ~30.8%→33–34%). Most soak “interesting” finds refine edges already near existing coverage rather than opening large new statement regions — still valuable for crash hunting. + +## Harness conventions + +- **Panic-oriented** (`FuzzVerifyRequest`, `FuzzVerifyViaMessage`): discard expected verify/setup errors; return early on nil/`NewMessage` failure; never `t.Error` on bad signatures. +- **Round-trip** (HMAC targets): return early on nil request, signer/verifier setup failure, or `SignRequest` error; `t.Fatalf` only if verify fails after a successful sign. +- **FuzzNewMessage**: invalid configs return; structural invariants (headers present for request/response) may fail the input. + +## CI + +The `fuzz` job in `.github/workflows/test.yml` runs each target with `-run='^$' -fuzztime=15s` sequentially on Go 1.27. It fails on crash or failing corpus. This is a **smoke**, not a long soak. + +For occasional long soaks (hours), use the weighted budgets in the soak section above; keep the machine from sleeping so wall clock ≈ fuzz-time. Optional follow-on: a scheduled nightly job with a larger `-fuzztime`. + +## Checklist (after library changes that touch parse/sign/verify) + +1. `go test -list 'Fuzz' .` — five names, no substring collisions. +2. `go test -run='^Fuzz' .` — seeds pass. +3. Optional: 30s fuzz on the targets you touched. +4. If you add corpus files, re-check seed cover on the focus files. diff --git a/internal-docs/README.md b/internal-docs/README.md index 6bfe50d..dbf387f 100644 --- a/internal-docs/README.md +++ b/internal-docs/README.md @@ -4,6 +4,7 @@ This directory contains internal documentation for maintainers of the httpsign l ## Contents +- **FUZZ.md** — Fuzz suite playbook: targets, local/CI commands, how to read metrics and corpus layout. - **JWX.md** — Optional jwx / foreign-JWS: cut over to jwx v4.4.0+ on Go 1.27+ as **httpsign `v0.6.0`**, with **ML-DSA PQ signatures** as an explicit goal. Gate met 2026-08-26. - **RELEASE-v0.6.0.md** — Draft GitHub release text and upgrade guide for **v0.6.0** (copy Summary into the release when tagging).