Skip to content

Commit 6e9ca53

Browse files
yaronfcursoragent
andcommitted
feat: locate signatures by tag via MessageDetails
Add MessageDetails.Label and Request/Response DetailsByTag (strict) and DetailsListByTag (lenient) helpers with sentinel errors, example, and v0.6.1 release notes. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1f298b0 commit 6e9ca53

6 files changed

Lines changed: 310 additions & 6 deletions

File tree

internal-docs/RELEASE-v0.6.1.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Copy the **Summary** section below into the GitHub release when tagging `v0.6.1`
1515
- **`JWSAlgAllowlist`:** both verify constructors take an allowlist (`nil` skips policy for tests/lazy use). Prefer a non-nil allowlist when `keyid` can select among keys.
1616
- **`NewJWSSigner`:** nil config defaults to `SignAlg(false)`; configs with `SignAlg(true)` are rejected.
1717
- **`Fields.AddRequestComponent`:** convenience for required associated-request components (`;req`), equivalent to `AddHeaderExt(name, false, false, true, false)`.
18+
- **Details by tag:** `RequestDetailsByTag` / `ResponseDetailsByTag` (strict) and `*DetailsListByTag` (lenient) locate signatures by the `tag` parameter; `MessageDetails.Label` holds the dictionary member name for `Verify*`. Sentinels: `ErrSignatureTagNotFound` / `ErrSignatureTagAmbiguous`.
1819
- **jwx** bump to **≥ v4.5.0** (if not already on the release branch).
1920

2021
### Upgrade from v0.6.0
@@ -26,6 +27,7 @@ Copy the **Summary** section below into the GitHub release when tagging `v0.6.1`
2627
| **`NewJWSSigner`** | Omit config or keep `SignAlg(false)`; `SignAlg(true)` now errors. |
2728
| **HTTP `SetAllowedAlgs`** | Unchanged — Signature-Input `alg` only, not JWS `jwa`. |
2829
| **Response `;req` fields** | Prefer `AddRequestComponent("@method")` (etc.) over `AddHeaderExt(..., false, false, true, false)`. |
30+
| **Locate signature by `tag`** | Prefer `RequestDetailsByTag` / `ResponseDetailsByTag`, then `Verify*` on `details.Label` (see below). |
2931

3032
```go
3133
allowed, _ := httpsign.NewJWSAlgAllowlist(jwa.ES256(), jwa.MLDSA65())
@@ -34,6 +36,17 @@ verifier, err := httpsign.NewJWSVerifier(allowed, pubKey, verifyConfig, fields)
3436
verifier, err := httpsign.NewJWSVerifierWithAlg(allowed, jwa.RS256(), rsaPub, verifyConfig, fields)
3537
```
3638

39+
**Details by tag** (when the dictionary label is not semantic):
40+
41+
```go
42+
details, err := httpsign.RequestDetailsByTag(req, "app")
43+
if err != nil {
44+
return err // includes ErrSignatureTagNotFound / ErrSignatureTagAmbiguous
45+
}
46+
// use details.KeyID to obtain verifier, then:
47+
err = httpsign.VerifyRequest(details.Label, *verifier, req)
48+
```
49+
3750
**ML-DSA:**
3851

3952
```go

message.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
// MessageDetails aggregates the details of a signed message, for a given signature
1212
type MessageDetails struct {
13+
Label string // Signature dictionary member name (the label in Signature / Signature-Input)
1314
KeyID *string // nil when keyid parameter is absent (RFC 9421 does not require it)
1415
Alg string
1516
Fields Fields
@@ -18,8 +19,8 @@ type MessageDetails struct {
1819
Nonce *string
1920
Tag *string
2021
CustomParams map[string]interface{} // non-standard parameters; values are int64, string, or bool. Nil if none present.
21-
// Note: when populated via RequestDetails/ResponseDetails, the message has not been cryptographically
22-
// verified — treat CustomParams (and all other fields) as untrusted until verified.
22+
// Note: when populated via RequestDetails/ResponseDetails (or *ByTag), the message has not been
23+
// cryptographically verified — treat CustomParams (and all other fields) as untrusted until verified.
2324
}
2425

2526
// Message represents a parsed HTTP message ready for signature verification.
@@ -223,5 +224,5 @@ func (m *Message) Verify(signatureName string, verifier Verifier) (*MessageDetai
223224
if err != nil {
224225
return nil, err
225226
}
226-
return signatureDetails(psiSig)
227+
return signatureDetails(signatureName, psiSig)
227228
}

signatures.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ func RequestDetails(signatureName string, req *http.Request) (details *MessageDe
513513
if err != nil {
514514
return nil, fmt.Errorf("could not extract signature: %w", err)
515515
}
516-
return signatureDetails(psiSig)
516+
return signatureDetails(signatureName, psiSig)
517517
}
518518

519519
func verifyDebug(signatureName string, verifier Verifier, message *Message) (string, *psiSignature, error) {
@@ -571,7 +571,7 @@ func ResponseDetails(signatureName string, res *http.Response) (details *Message
571571
if err != nil {
572572
return nil, fmt.Errorf("could not extract signature: %w", err)
573573
}
574-
return signatureDetails(psiSig)
574+
return signatureDetails(signatureName, psiSig)
575575
}
576576

577577
// RequestSignatureNames returns the list of signature names present in a request (empty list if none found).
@@ -619,7 +619,7 @@ func messageSignatureNames(parsedMessage *parsedMessage, withTrailers bool) ([]s
619619
return names, nil
620620
}
621621

622-
func signatureDetails(signature *psiSignature) (details *MessageDetails, err error) {
622+
func signatureDetails(label string, signature *psiSignature) (details *MessageDetails, err error) {
623623
var keyID *string
624624
if keyIDParam, ok := signature.params["keyid"]; ok {
625625
k, ok := keyIDParam.(string)
@@ -637,6 +637,7 @@ func signatureDetails(signature *psiSignature) (details *MessageDetails, err err
637637
}
638638
}
639639
details = &MessageDetails{
640+
Label: label,
640641
KeyID: keyID,
641642
Alg: alg,
642643
Fields: signature.fields,

signaturesex_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package httpsign_test
33
import (
44
"bufio"
55
"bytes"
6+
"crypto/ecdsa"
7+
"crypto/elliptic"
8+
"crypto/rand"
69
"fmt"
710
"net/http"
811
"strings"
@@ -45,3 +48,31 @@ Signature: sig77=:3e9KqLP62NHfHY5OMG4036+U6tvBowZF35ALzTjpsf0=:
4548
fmt.Printf("verified: %t", err == nil)
4649
// Output: verified: true
4750
}
51+
52+
func ExampleRequestDetailsByTag() {
53+
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
54+
fields := httpsign.Headers("@authority", "Date", "@method")
55+
reqStr := `GET /foo HTTP/1.1
56+
Host: example.org
57+
Date: Tue, 20 Apr 2021 02:07:55 GMT
58+
59+
`
60+
req, _ := http.ReadRequest(bufio.NewReader(strings.NewReader(reqStr)))
61+
62+
signer, _ := httpsign.NewP256Signer(*priv,
63+
httpsign.NewSignConfig().SignCreated(false).SetTag("app").SetKeyID("key1"), fields)
64+
sigInput, sig, _ := httpsign.SignRequest("sig1", *signer, req)
65+
req.Header.Add("Signature-Input", sigInput)
66+
req.Header.Add("Signature", sig)
67+
68+
details, err := httpsign.RequestDetailsByTag(req, "app")
69+
if err != nil {
70+
fmt.Printf("details: %v\n", err)
71+
return
72+
}
73+
verifier, _ := httpsign.NewP256Verifier(priv.PublicKey,
74+
httpsign.NewVerifyConfig().SetKeyID("key1").SetVerifyCreated(false), fields)
75+
err = httpsign.VerifyRequest(details.Label, *verifier, req)
76+
fmt.Printf("label=%s tag=%s verified=%t", details.Label, *details.Tag, err == nil)
77+
// Output: label=sig1 tag=app verified=true
78+
}

signtag.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package httpsign
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
)
8+
9+
// ErrSignatureTagNotFound is returned by *DetailsByTag when no signature
10+
// carries the requested tag parameter.
11+
var ErrSignatureTagNotFound = errors.New("no signature with the given tag")
12+
13+
// ErrSignatureTagAmbiguous is returned by *DetailsByTag when more than one
14+
// signature carries the requested tag parameter.
15+
var ErrSignatureTagAmbiguous = errors.New("multiple signatures with the given tag")
16+
17+
// RequestDetailsByTag returns MessageDetails for the unique Signature whose
18+
// Signature-Input tag equals tag. Matching uses parsed headers only (not trailers)
19+
// and does not cryptographically verify. Returns ErrSignatureTagNotFound or
20+
// ErrSignatureTagAmbiguous when the match count is not exactly one.
21+
// Use details.Label with VerifyRequest after obtaining a Verifier (e.g. via details.KeyID).
22+
func RequestDetailsByTag(req *http.Request, tag string) (*MessageDetails, error) {
23+
if req == nil {
24+
return nil, fmt.Errorf("nil request")
25+
}
26+
names, err := RequestSignatureNames(req, false)
27+
if err != nil {
28+
return nil, err
29+
}
30+
return detailsByTag(names, tag, func(name string) (*MessageDetails, error) {
31+
return RequestDetails(name, req)
32+
})
33+
}
34+
35+
// ResponseDetailsByTag returns MessageDetails for the unique Signature whose
36+
// Signature-Input tag equals tag. Matching uses parsed headers only (not trailers)
37+
// and does not cryptographically verify. Returns ErrSignatureTagNotFound or
38+
// ErrSignatureTagAmbiguous when the match count is not exactly one.
39+
// Use details.Label with VerifyResponse after obtaining a Verifier (e.g. via details.KeyID).
40+
func ResponseDetailsByTag(res *http.Response, tag string) (*MessageDetails, error) {
41+
if res == nil {
42+
return nil, fmt.Errorf("nil response")
43+
}
44+
names, err := ResponseSignatureNames(res, false)
45+
if err != nil {
46+
return nil, err
47+
}
48+
return detailsByTag(names, tag, func(name string) (*MessageDetails, error) {
49+
return ResponseDetails(name, res)
50+
})
51+
}
52+
53+
// RequestDetailsListByTag returns MessageDetails for every Signature whose
54+
// Signature-Input tag equals tag (possibly empty). Matching uses parsed headers
55+
// only (not trailers) and does not cryptographically verify. Each result has Label set.
56+
func RequestDetailsListByTag(req *http.Request, tag string) ([]*MessageDetails, error) {
57+
if req == nil {
58+
return nil, fmt.Errorf("nil request")
59+
}
60+
names, err := RequestSignatureNames(req, false)
61+
if err != nil {
62+
return nil, err
63+
}
64+
return detailsListByTag(names, tag, func(name string) (*MessageDetails, error) {
65+
return RequestDetails(name, req)
66+
})
67+
}
68+
69+
// ResponseDetailsListByTag returns MessageDetails for every Signature whose
70+
// Signature-Input tag equals tag (possibly empty). Matching uses parsed headers
71+
// only (not trailers) and does not cryptographically verify. Each result has Label set.
72+
func ResponseDetailsListByTag(res *http.Response, tag string) ([]*MessageDetails, error) {
73+
if res == nil {
74+
return nil, fmt.Errorf("nil response")
75+
}
76+
names, err := ResponseSignatureNames(res, false)
77+
if err != nil {
78+
return nil, err
79+
}
80+
return detailsListByTag(names, tag, func(name string) (*MessageDetails, error) {
81+
return ResponseDetails(name, res)
82+
})
83+
}
84+
85+
func detailsListByTag(names []string, tag string, detailsFn func(string) (*MessageDetails, error)) ([]*MessageDetails, error) {
86+
var found []*MessageDetails
87+
for _, name := range names {
88+
details, err := detailsFn(name)
89+
if err != nil {
90+
return nil, fmt.Errorf("details for %q: %w", name, err)
91+
}
92+
if details.Tag != nil && *details.Tag == tag {
93+
found = append(found, details)
94+
}
95+
}
96+
return found, nil
97+
}
98+
99+
func detailsByTag(names []string, tag string, detailsFn func(string) (*MessageDetails, error)) (*MessageDetails, error) {
100+
var match *MessageDetails
101+
n := 0
102+
for _, name := range names {
103+
details, err := detailsFn(name)
104+
if err != nil {
105+
return nil, fmt.Errorf("details for %q: %w", name, err)
106+
}
107+
if details.Tag == nil || *details.Tag != tag {
108+
continue
109+
}
110+
n++
111+
if n > 1 {
112+
return nil, fmt.Errorf("%w: %q", ErrSignatureTagAmbiguous, tag)
113+
}
114+
match = details
115+
}
116+
if n == 0 {
117+
return nil, fmt.Errorf("%w: %q", ErrSignatureTagNotFound, tag)
118+
}
119+
return match, nil
120+
}

signtag_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package httpsign
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestRequestDetailsByTag(t *testing.T) {
12+
priv, _, err := genP256KeyPair()
13+
require.NoError(t, err)
14+
fields := *NewFields().AddHeader("@method")
15+
req := readRequest(httpreq1)
16+
17+
signerA, err := NewP256Signer(*priv, NewSignConfig().SetTag("app").setFakeCreated(1618884475).SetKeyID("k1"), fields)
18+
require.NoError(t, err)
19+
inA, sigA, err := SignRequest("sig-a", *signerA, req)
20+
require.NoError(t, err)
21+
req.Header.Add("Signature-Input", inA)
22+
req.Header.Add("Signature", sigA)
23+
24+
signerB, err := NewP256Signer(*priv, NewSignConfig().SetTag("other").setFakeCreated(1618884475).SetKeyID("k1"), fields)
25+
require.NoError(t, err)
26+
inB, sigB, err := SignRequest("sig-b", *signerB, req)
27+
require.NoError(t, err)
28+
req.Header.Add("Signature-Input", inB)
29+
req.Header.Add("Signature", sigB)
30+
31+
t.Run("unique", func(t *testing.T) {
32+
details, err := RequestDetailsByTag(req, "app")
33+
require.NoError(t, err)
34+
assert.Equal(t, "sig-a", details.Label)
35+
require.NotNil(t, details.Tag)
36+
assert.Equal(t, "app", *details.Tag)
37+
})
38+
39+
t.Run("list", func(t *testing.T) {
40+
list, err := RequestDetailsListByTag(req, "other")
41+
require.NoError(t, err)
42+
require.Len(t, list, 1)
43+
assert.Equal(t, "sig-b", list[0].Label)
44+
list, err = RequestDetailsListByTag(req, "missing")
45+
require.NoError(t, err)
46+
assert.Empty(t, list)
47+
})
48+
49+
t.Run("not found", func(t *testing.T) {
50+
_, err := RequestDetailsByTag(req, "nope")
51+
require.Error(t, err)
52+
assert.True(t, errors.Is(err, ErrSignatureTagNotFound))
53+
})
54+
55+
t.Run("ambiguous", func(t *testing.T) {
56+
signerC, err := NewP256Signer(*priv, NewSignConfig().SetTag("app").setFakeCreated(1618884475).SetKeyID("k1"), fields)
57+
require.NoError(t, err)
58+
inC, sigC, err := SignRequest("sig-c", *signerC, req)
59+
require.NoError(t, err)
60+
req.Header.Add("Signature-Input", inC)
61+
req.Header.Add("Signature", sigC)
62+
63+
_, err = RequestDetailsByTag(req, "app")
64+
require.Error(t, err)
65+
assert.True(t, errors.Is(err, ErrSignatureTagAmbiguous))
66+
67+
list, err := RequestDetailsListByTag(req, "app")
68+
require.NoError(t, err)
69+
require.Len(t, list, 2)
70+
labels := []string{list[0].Label, list[1].Label}
71+
assert.ElementsMatch(t, []string{"sig-a", "sig-c"}, labels)
72+
})
73+
74+
t.Run("nil request", func(t *testing.T) {
75+
_, err := RequestDetailsByTag(nil, "app")
76+
require.Error(t, err)
77+
_, err = RequestDetailsListByTag(nil, "app")
78+
require.Error(t, err)
79+
})
80+
}
81+
82+
func TestResponseDetailsByTag(t *testing.T) {
83+
priv, _, err := genP256KeyPair()
84+
require.NoError(t, err)
85+
fields := *NewFields().AddHeader("@status")
86+
res := readResponse(httpres2)
87+
88+
signerA, err := NewP256Signer(*priv, NewSignConfig().SetTag("ctx1").setFakeCreated(1660755826).SetKeyID("k1"), fields)
89+
require.NoError(t, err)
90+
inA, sigA, err := SignResponse("sig1", *signerA, res, nil)
91+
require.NoError(t, err)
92+
res.Header.Add("Signature-Input", inA)
93+
res.Header.Add("Signature", sigA)
94+
95+
signerB, err := NewP256Signer(*priv, NewSignConfig().SetTag("ctx2").setFakeCreated(1660755826).SetKeyID("k1"), fields)
96+
require.NoError(t, err)
97+
inB, sigB, err := SignResponse("sig2", *signerB, res, nil)
98+
require.NoError(t, err)
99+
res.Header.Add("Signature-Input", inB)
100+
res.Header.Add("Signature", sigB)
101+
102+
details, err := ResponseDetailsByTag(res, "ctx2")
103+
require.NoError(t, err)
104+
assert.Equal(t, "sig2", details.Label)
105+
require.NotNil(t, details.Tag)
106+
assert.Equal(t, "ctx2", *details.Tag)
107+
108+
list, err := ResponseDetailsListByTag(res, "ctx1")
109+
require.NoError(t, err)
110+
require.Len(t, list, 1)
111+
assert.Equal(t, "sig1", list[0].Label)
112+
113+
_, err = ResponseDetailsByTag(res, "missing")
114+
require.Error(t, err)
115+
assert.True(t, errors.Is(err, ErrSignatureTagNotFound))
116+
117+
_, err = ResponseDetailsByTag(nil, "ctx1")
118+
require.Error(t, err)
119+
_, err = ResponseDetailsListByTag(nil, "ctx1")
120+
require.Error(t, err)
121+
}
122+
123+
func TestRequestDetailsSetsLabel(t *testing.T) {
124+
priv, _, err := genP256KeyPair()
125+
require.NoError(t, err)
126+
fields := *NewFields().AddHeader("@method")
127+
req := readRequest(httpreq1)
128+
signer, err := NewP256Signer(*priv, NewSignConfig().SetTag("app").setFakeCreated(1618884475).SetKeyID("k1"), fields)
129+
require.NoError(t, err)
130+
in, sig, err := SignRequest("mysig", *signer, req)
131+
require.NoError(t, err)
132+
req.Header.Add("Signature-Input", in)
133+
req.Header.Add("Signature", sig)
134+
135+
details, err := RequestDetails("mysig", req)
136+
require.NoError(t, err)
137+
assert.Equal(t, "mysig", details.Label)
138+
}

0 commit comments

Comments
 (0)