Skip to content

Commit 240e2b0

Browse files
committed
Add custom Signature-Input parameters (RFC 9421)
SignConfig.AddCustomParam appends non-standard parameters with int64, string, or bool values; reserved names and duplicates fail at sign time. Names must match RFC 8941 key syntax; wrap httpsfv.ErrInvalidKeyFormat with clearer context. MessageDetails exposes CustomParams after verification. Tests cover round-trip, validation, and verifier config for stable clocks. Closes #19. Made-with: Cursor
1 parent 6dbaa5c commit 240e2b0

4 files changed

Lines changed: 161 additions & 8 deletions

File tree

config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import (
88
"time"
99
)
1010

11+
// customParam holds a single custom signature parameter name/value pair.
12+
type customParam struct {
13+
name string
14+
value interface{} // must be int64, string, or bool
15+
}
16+
1117
// SignConfig contains additional configuration for the signer.
1218
type SignConfig struct {
1319
signAlg bool
@@ -20,6 +26,7 @@ type SignConfig struct {
2026
keyID *string
2127
maxBodySize int64
2228
schemeFromRequest func(*http.Request) string
29+
customParams []customParam
2330
}
2431

2532
// NewSignConfig generates a default configuration.
@@ -109,6 +116,17 @@ func (c *SignConfig) SetSchemeFromRequest(f func(*http.Request) string) *SignCon
109116
return c
110117
}
111118

119+
// AddCustomParam adds a custom (non-standard) parameter to the signature.
120+
// The name must not conflict with reserved parameter names (created, expires, nonce, alg, tag, keyid).
121+
// Names must conform to the RFC 8941 "key" syntax used for Structured Field parameters: start with
122+
// a lowercase letter (a–z) or '*', and contain only lowercase letters, digits, and '_', '-', '.', '*'.
123+
// The value must be int64, string, or bool.
124+
// Errors (reserved name, duplicate name, unsupported type, invalid name syntax) are reported at sign time.
125+
func (c *SignConfig) AddCustomParam(name string, value interface{}) *SignConfig {
126+
c.customParams = append(c.customParams, customParam{name, value})
127+
return c
128+
}
129+
112130
// VerifyConfig contains additional configuration for the verifier.
113131
type VerifyConfig struct {
114132
verifyCreated bool

message.go

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

1111
// MessageDetails aggregates the details of a signed message, for a given signature
1212
type MessageDetails struct {
13-
KeyID *string // nil when keyid parameter is absent (RFC 9421 does not require it)
14-
Alg string
15-
Fields Fields
16-
Created *time.Time
17-
Expires *time.Time
18-
Nonce *string
19-
Tag *string
13+
KeyID *string // nil when keyid parameter is absent (RFC 9421 does not require it)
14+
Alg string
15+
Fields Fields
16+
Created *time.Time
17+
Expires *time.Time
18+
Nonce *string
19+
Tag *string
20+
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.
2023
}
2124

2225
// Message represents a parsed HTTP message ready for signature verification.

signatures.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import (
1111
"github.com/dunglas/httpsfv"
1212
)
1313

14+
// reservedSigParams lists the RFC 9421 standard signature parameter names.
15+
// Custom parameters must not use these names.
16+
var reservedSigParams = map[string]bool{
17+
"created": true,
18+
"expires": true,
19+
"nonce": true,
20+
"alg": true,
21+
"tag": true,
22+
"keyid": true,
23+
}
24+
1425
func signMessage(config SignConfig, signatureName string, signer Signer, parsedMessage, parsedAssocMessage *parsedMessage,
1526
fields Fields) (signatureInput, signature, signatureBase string, err error) {
1627
filtered := filterOptionalFields(fields, parsedMessage, parsedAssocMessage)
@@ -311,7 +322,31 @@ func generateSigParams(config *SignConfig, alg string, foreignSigner interface{}
311322
}
312323
p.Add("keyid", *config.keyID)
313324
}
314-
return fields.asSignatureInput(p)
325+
seen := map[string]bool{}
326+
for _, cp := range config.customParams {
327+
if reservedSigParams[cp.name] {
328+
return "", fmt.Errorf("custom param name %q conflicts with reserved parameter", cp.name)
329+
}
330+
if seen[cp.name] {
331+
return "", fmt.Errorf("duplicate custom param name %q", cp.name)
332+
}
333+
seen[cp.name] = true
334+
switch v := cp.value.(type) {
335+
case int64:
336+
p.Add(cp.name, v)
337+
case string:
338+
p.Add(cp.name, v)
339+
case bool:
340+
p.Add(cp.name, v)
341+
default:
342+
return "", fmt.Errorf("custom param %q: value must be int64, string, or bool", cp.name)
343+
}
344+
}
345+
s, err := fields.asSignatureInput(p)
346+
if err != nil && errors.Is(err, httpsfv.ErrInvalidKeyFormat) {
347+
return "", fmt.Errorf("invalid custom signature parameter name (RFC 8941 key syntax): %w", err)
348+
}
349+
return s, err
315350
}
316351

317352
// SignRequest signs an HTTP request. Returns the Signature-Input and the Signature header values.
@@ -545,6 +580,16 @@ func signatureDetails(signature *psiSignature) (details *MessageDetails, err err
545580
details.Tag = &tag
546581
}
547582

583+
custom := map[string]interface{}{}
584+
for name, val := range signature.params {
585+
if !reservedSigParams[name] {
586+
custom[name] = val
587+
}
588+
}
589+
if len(custom) > 0 {
590+
details.CustomParams = custom
591+
}
592+
548593
return details, nil
549594
}
550595

signatures_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3344,6 +3344,93 @@ func TestRequestBindingSignedResponse17(t *testing.T) {
33443344
assert.NoError(t, err, "validate response digest")
33453345
}
33463346

3347+
// makeHMACSignerVerifier builds a signer/verifier pair for signVerifyResponseCustomParams.
3348+
// The verifier uses SetVerifyCreated(false) so round-trips do not flake on clock skew
3349+
// (the signer still emits created by default; verification skips the created window check).
3350+
func makeHMACSignerVerifier(t *testing.T, signConfig *SignConfig, fields Fields) (*Signer, *Verifier) {
3351+
t.Helper()
3352+
key, _ := base64.StdEncoding.DecodeString("uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==")
3353+
signer, err := NewHMACSHA256Signer(key, signConfig, fields)
3354+
assert.NoError(t, err)
3355+
verifier, err := NewHMACSHA256Verifier(key, NewVerifyConfig().SetVerifyCreated(false), fields)
3356+
assert.NoError(t, err)
3357+
return signer, verifier
3358+
}
3359+
3360+
// Helper function for the next block of tests
3361+
func signVerifyResponseCustomParams(t *testing.T, signConfig *SignConfig) (*MessageDetails, error) {
3362+
t.Helper()
3363+
fields := Headers("@status", "date", "content-type")
3364+
signer, verifier := makeHMACSignerVerifier(t, signConfig, fields)
3365+
res := readResponse(httpres2)
3366+
sigInput, sig, err := SignResponse("sig1", *signer, res, nil)
3367+
if err != nil {
3368+
return nil, err
3369+
}
3370+
res2 := readResponse(httpres2)
3371+
res2.Header.Add("Signature", sig)
3372+
res2.Header.Add("Signature-Input", sigInput)
3373+
msg, err := NewMessage(NewMessageConfig().WithResponse(res2, nil))
3374+
assert.NoError(t, err)
3375+
return msg.Verify("sig1", *verifier)
3376+
}
3377+
3378+
func TestCustomParamStringRoundTrip(t *testing.T) {
3379+
config := NewSignConfig().AddCustomParam("x-foo", "bar")
3380+
details, err := signVerifyResponseCustomParams(t, config)
3381+
assert.NoError(t, err)
3382+
assert.Equal(t, map[string]interface{}{"x-foo": "bar"}, details.CustomParams)
3383+
}
3384+
3385+
func TestCustomParamInt64RoundTrip(t *testing.T) {
3386+
config := NewSignConfig().AddCustomParam("x-num", int64(42))
3387+
details, err := signVerifyResponseCustomParams(t, config)
3388+
assert.NoError(t, err)
3389+
assert.Equal(t, map[string]interface{}{"x-num": int64(42)}, details.CustomParams)
3390+
}
3391+
3392+
func TestCustomParamBoolRoundTrip(t *testing.T) {
3393+
config := NewSignConfig().AddCustomParam("x-flag", true)
3394+
details, err := signVerifyResponseCustomParams(t, config)
3395+
assert.NoError(t, err)
3396+
assert.Equal(t, map[string]interface{}{"x-flag": true}, details.CustomParams)
3397+
}
3398+
3399+
func TestMultipleCustomParams(t *testing.T) {
3400+
config := NewSignConfig().
3401+
AddCustomParam("x-foo", "bar").
3402+
AddCustomParam("x-num", int64(99))
3403+
details, err := signVerifyResponseCustomParams(t, config)
3404+
assert.NoError(t, err)
3405+
assert.Equal(t, map[string]interface{}{"x-foo": "bar", "x-num": int64(99)}, details.CustomParams)
3406+
}
3407+
3408+
func TestCustomParamReservedNameRejected(t *testing.T) {
3409+
config := NewSignConfig().AddCustomParam("created", "oops")
3410+
_, err := signVerifyResponseCustomParams(t, config)
3411+
assert.Error(t, err)
3412+
}
3413+
3414+
func TestCustomParamDuplicateNameRejected(t *testing.T) {
3415+
config := NewSignConfig().
3416+
AddCustomParam("x-foo", "first").
3417+
AddCustomParam("x-foo", "second")
3418+
_, err := signVerifyResponseCustomParams(t, config)
3419+
assert.Error(t, err)
3420+
}
3421+
3422+
func TestCustomParamInvalidType(t *testing.T) {
3423+
config := NewSignConfig().AddCustomParam("x-foo", 3.14)
3424+
_, err := signVerifyResponseCustomParams(t, config)
3425+
assert.Error(t, err)
3426+
}
3427+
3428+
func TestCustomParamInvalidName(t *testing.T) {
3429+
config := NewSignConfig().AddCustomParam("INVALID", "val")
3430+
_, err := signVerifyResponseCustomParams(t, config)
3431+
assert.Error(t, err)
3432+
}
3433+
33473434
// Same as TestRequestBindingSignedResponse17 but using Message
33483435
func TestMessageRequestBindingSignedResponse17(t *testing.T) {
33493436
req := readRequest(httpreq14)

0 commit comments

Comments
 (0)