Skip to content

Commit 74a08ce

Browse files
authored
Merge pull request #6 from lestrrat-go/validate-expires
Refactor, add validation for expires
2 parents 8eb0a73 + 7ac5d40 commit 74a08ce

File tree

7 files changed

+327
-51
lines changed

7 files changed

+327
-51
lines changed

htmsig.go

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"net/http"
1010
"strings"
11+
"time"
1112

1213
"github.com/lestrrat-go/dsig"
1314
"github.com/lestrrat-go/htmsig/component"
@@ -35,6 +36,7 @@ type KeyResolver interface {
3536
ResolveKey(keyID string) (any, error)
3637
}
3738

39+
3840
// SignRequest signs an HTTP request using the provided headers.
3941
// Request information must be provided in context using component.WithRequestInfo.
4042
func SignRequest(ctx context.Context, headers http.Header, inputValue *input.Value, key any) error {
@@ -311,20 +313,31 @@ func determineAlgorithmFromKey(key any) (string, error) {
311313

312314
// VerifyRequest verifies HTTP request signatures using the provided headers.
313315
// Request information must be provided in context using component.WithRequestInfo.
314-
func VerifyRequest(ctx context.Context, headers http.Header, keyOrResolver any) error {
316+
func VerifyRequest(ctx context.Context, headers http.Header, keyOrResolver any, options ...VerifyOption) error {
315317
ctx = component.WithMode(ctx, component.ModeRequest)
316-
return verifyWithContext(ctx, headers, keyOrResolver)
318+
return verifyWithContext(ctx, headers, keyOrResolver, options...)
317319
}
318320

319321
// VerifyResponse verifies HTTP response signatures using the provided headers.
320322
// Response information must be provided in context using component.WithResponseInfo.
321-
func VerifyResponse(ctx context.Context, headers http.Header, keyOrResolver any) error {
323+
func VerifyResponse(ctx context.Context, headers http.Header, keyOrResolver any, options ...VerifyOption) error {
322324
ctx = component.WithMode(ctx, component.ModeResponse)
323-
return verifyWithContext(ctx, headers, keyOrResolver)
325+
return verifyWithContext(ctx, headers, keyOrResolver, options...)
324326
}
325327

326328
// verifyWithContext performs the actual verification using the prepared context and headers.
327-
func verifyWithContext(ctx context.Context, hdr http.Header, keyOrResolver any) error {
329+
func verifyWithContext(ctx context.Context, hdr http.Header, keyOrResolver any, options ...VerifyOption) error {
330+
// Process options
331+
var validateExpires bool
332+
var clock Clock = SystemClock{}
333+
for _, opt := range options {
334+
switch opt.Ident() {
335+
case identValidateExpires{}:
336+
validateExpires = opt.Value().(bool)
337+
case identClock{}:
338+
clock = opt.Value().(Clock)
339+
}
340+
}
328341
// Step 1: Parse Signature and Signature-Input fields (RFC 9421 Section 3.2, step 1)
329342
signatureInputHeader := hdr.Get(SignatureInputHeader)
330343
if signatureInputHeader == "" {
@@ -365,6 +378,13 @@ func verifyWithContext(ctx context.Context, hdr http.Header, keyOrResolver any)
365378
return fmt.Errorf("htmsig.Verify: failed to resolve key for label %q: %w", label, err)
366379
}
367380

381+
// Validate signature expiration if configured
382+
if validateExpires {
383+
if err := validateSignatureExpiration(clock, def); err != nil {
384+
return fmt.Errorf("htmsig.Verify: signature expired for label %q: %w", label, err)
385+
}
386+
}
387+
368388
// Step 3: Extract the signature value (RFC 9421 Section 3.2, step 3)
369389
// The signature must be a byte sequence (RFC 9421 Section 3.2)
370390
var signatureBytes []byte
@@ -400,6 +420,24 @@ func verifyWithContext(ctx context.Context, hdr http.Header, keyOrResolver any)
400420
return nil
401421
}
402422

423+
// validateSignatureExpiration validates if a signature has expired based on its expires parameter
424+
func validateSignatureExpiration(clock Clock, def *input.Definition) error {
425+
// Check if the signature has an expires parameter
426+
expiresTimestamp, hasExpires := def.Expires()
427+
if !hasExpires {
428+
return nil // No expiration time set, signature doesn't expire
429+
}
430+
431+
// Check if the signature has expired
432+
now := clock.Now()
433+
expiresTime := time.Unix(expiresTimestamp, 0)
434+
if now.After(expiresTime) {
435+
return fmt.Errorf("signature expired at %v (current time: %v)", expiresTime, now)
436+
}
437+
438+
return nil
439+
}
440+
403441
// resolveKey resolves the cryptographic key for a signature definition
404442
// keyOrResolver can be either a raw key or a KeyResolver
405443
func resolveKey(keyOrResolver any, def *input.Definition) (any, error) {

htmsig_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ package htmsig_test
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
57
"net/http"
68
"net/http/httptest"
79
"net/url"
810
"strings"
911
"testing"
12+
"time"
1013

14+
"github.com/lestrrat-go/htmsig"
1115
"github.com/lestrrat-go/htmsig/component"
16+
"github.com/lestrrat-go/htmsig/input"
17+
htmsighttp "github.com/lestrrat-go/htmsig/http"
1218
"github.com/stretchr/testify/require"
1319
)
1420

@@ -476,3 +482,104 @@ func TestServerSideTargetURIVsClientSide(t *testing.T) {
476482
require.Equal(t, expectedTargetURI, serverSideValue, "Server-side should match RFC")
477483
require.Equal(t, clientSideValue, serverSideValue, "Server-side and client-side should match")
478484
}
485+
486+
func TestSignatureExpirationChecking(t *testing.T) {
487+
// Generate RSA key for testing
488+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
489+
require.NoError(t, err)
490+
491+
// Create an HTTP request
492+
req, err := http.NewRequest("POST", "https://example.com/test", nil)
493+
require.NoError(t, err)
494+
req.Header.Set("Content-Type", "application/json")
495+
req.Header.Set("Date", "Tue, 20 Apr 2021 02:07:55 GMT")
496+
497+
// Use a fixed time for deterministic testing
498+
fixedTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
499+
staticClock := htmsighttp.FixedClock(fixedTime)
500+
501+
// Test cases
502+
tests := []struct {
503+
name string
504+
expiresOffset time.Duration // offset from now to set expiration
505+
validateExpires bool // whether to enable expiration validation
506+
expectError bool // whether verification should fail
507+
}{
508+
{
509+
name: "Valid signature without expiration",
510+
expiresOffset: 0, // no expiration set
511+
validateExpires: true,
512+
expectError: false,
513+
},
514+
{
515+
name: "Valid signature with future expiration",
516+
expiresOffset: time.Hour, // expires in 1 hour from fixed time
517+
validateExpires: true,
518+
expectError: false,
519+
},
520+
{
521+
name: "Expired signature with validation enabled",
522+
expiresOffset: -time.Hour, // expired 1 hour before fixed time
523+
validateExpires: true,
524+
expectError: true,
525+
},
526+
{
527+
name: "Expired signature with validation disabled",
528+
expiresOffset: -time.Hour, // expired 1 hour before fixed time
529+
validateExpires: false,
530+
expectError: false,
531+
},
532+
}
533+
534+
for _, tt := range tests {
535+
t.Run(tt.name, func(t *testing.T) {
536+
// Create signature definition
537+
builder := input.NewDefinitionBuilder().
538+
Label("test-sig").
539+
Components(
540+
component.Method(),
541+
component.TargetURI(),
542+
component.New("content-type"),
543+
component.New("date"),
544+
).
545+
KeyID("test-key-id")
546+
547+
// Set expiration if specified
548+
if tt.expiresOffset != 0 {
549+
expiresTime := fixedTime.Add(tt.expiresOffset)
550+
builder = builder.ExpiresTime(expiresTime)
551+
}
552+
553+
def, err := builder.Build()
554+
require.NoError(t, err)
555+
556+
// Create input value and sign the request
557+
inputValue := input.NewValueBuilder().AddDefinition(def).MustBuild()
558+
ctx := component.WithRequestInfoFromHTTP(context.Background(), req)
559+
err = htmsig.SignRequest(ctx, req.Header, inputValue, privateKey)
560+
require.NoError(t, err)
561+
562+
// Create key resolver - use StaticKeyResolver since we only have one key
563+
keyResolver := htmsighttp.StaticKeyResolver(&privateKey.PublicKey)
564+
565+
// Create verification options
566+
var options []htmsig.VerifyOption
567+
if tt.validateExpires {
568+
options = append(options, htmsig.WithValidateExpires(true))
569+
}
570+
options = append(options, htmsig.WithClock(staticClock))
571+
572+
// Verify the signature
573+
ctx = component.WithRequestInfoFromHTTP(context.Background(), req)
574+
err = htmsig.VerifyRequest(ctx, req.Header, keyResolver, options...)
575+
576+
if tt.expectError {
577+
require.Error(t, err, "Expected verification to fail for expired signature")
578+
require.Contains(t, err.Error(), "signature expired", "Error should mention expiration")
579+
} else {
580+
require.NoError(t, err, "Expected verification to succeed")
581+
}
582+
})
583+
}
584+
}
585+

http/context.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ package http
22

33
import (
44
"context"
5-
"net/http"
65
)
76

87
// Context key types for storing values in request context
98
type verificationErrorKey struct{}
109
type signingErrorKey struct{}
11-
type contextKey string
12-
13-
const errorContextKey contextKey = "htmsig_error"
1410

1511
// WithVerificationError adds a verification error to the context.
1612
func WithVerificationError(ctx context.Context, err error) context.Context {
@@ -37,12 +33,3 @@ func SigningErrorFromContext(ctx context.Context) error {
3733
}
3834
return nil
3935
}
40-
41-
// GetError retrieves the verification error from the request context.
42-
// This can be used by custom error handlers to access the specific error.
43-
func GetError(r *http.Request) error {
44-
if err, ok := r.Context().Value(errorContextKey).(error); ok {
45-
return err
46-
}
47-
return nil
48-
}

http/http_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package http_test
22

33
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
47
"fmt"
58
"net/http"
69
"net/http/httptest"
@@ -9,6 +12,7 @@ import (
912

1013
"github.com/lestrrat-go/htmsig"
1114
"github.com/lestrrat-go/htmsig/component"
15+
"github.com/lestrrat-go/htmsig/input"
1216
htmsighttp "github.com/lestrrat-go/htmsig/http"
1317
"github.com/stretchr/testify/require"
1418
)
@@ -147,3 +151,52 @@ func TestRoundTrip(t *testing.T) {
147151
sig := res.Header.Get(htmsig.SignatureHeader)
148152
require.NotEmpty(t, sig, `response should have a signature header`)
149153
}
154+
155+
func TestHTTPVerifierExpiration(t *testing.T) {
156+
// Generate RSA key for testing
157+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
158+
require.NoError(t, err)
159+
160+
// Create expired signature
161+
req, err := http.NewRequest("GET", "https://example.com/test", nil)
162+
require.NoError(t, err)
163+
req.Header.Set("Date", "Tue, 20 Apr 2021 02:07:55 GMT")
164+
165+
// Use a fixed time for deterministic testing
166+
fixedTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
167+
staticClock := htmsighttp.FixedClock(fixedTime)
168+
169+
// Sign with expiration 1 hour in the past (relative to fixed time)
170+
def, err := input.NewDefinitionBuilder().
171+
Label("test-sig").
172+
Components(component.Method(), component.TargetURI()).
173+
KeyID("test-key").
174+
ExpiresTime(fixedTime.Add(-time.Hour)).
175+
Build()
176+
require.NoError(t, err)
177+
178+
inputValue := input.NewValueBuilder().AddDefinition(def).MustBuild()
179+
ctx := component.WithRequestInfoFromHTTP(context.Background(), req)
180+
err = htmsig.SignRequest(ctx, req.Header, inputValue, privateKey)
181+
require.NoError(t, err)
182+
183+
keyResolver := htmsighttp.StaticKeyResolver(&privateKey.PublicKey)
184+
185+
t.Run("HTTP verifier with expiration validation disabled", func(t *testing.T) {
186+
verifier := htmsighttp.NewVerifier(keyResolver,
187+
htmsighttp.WithValidateExpires(false),
188+
htmsighttp.WithClock(staticClock))
189+
err := verifier.VerifyRequest(context.Background(), req)
190+
require.NoError(t, err, "Should succeed when expiration validation is disabled")
191+
})
192+
193+
t.Run("HTTP verifier with expiration validation enabled", func(t *testing.T) {
194+
verifier := htmsighttp.NewVerifier(keyResolver,
195+
htmsighttp.WithValidateExpires(true),
196+
htmsighttp.WithClock(staticClock))
197+
err := verifier.VerifyRequest(context.Background(), req)
198+
require.Error(t, err, "Should fail when expiration validation is enabled")
199+
require.Contains(t, err.Error(), "signature expired")
200+
})
201+
}
202+

http/options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ type identVerifierErrorHandler struct{}
2727

2828
func (identVerifierErrorHandler) String() string { return "WithVerifierErrorHandler" }
2929

30+
type identValidateExpires struct{}
31+
32+
func (identValidateExpires) String() string { return "WithValidateExpires" }
33+
3034
type identTransport struct{}
3135

3236
func (identTransport) String() string { return "WithTransport" }
@@ -106,6 +110,12 @@ func WithVerifierErrorHandler(handler http.Handler) VerifierOption {
106110
return option.New(identVerifierErrorHandler{}, handler)
107111
}
108112

113+
// WithValidateExpires configures whether to validate signature expiration times.
114+
// When enabled, signatures with expired 'expires' parameters will be rejected.
115+
func WithValidateExpires(validate bool) VerifierOption {
116+
return option.New(identValidateExpires{}, validate)
117+
}
118+
109119
// TransportOption configures a SigningTransport.
110120
type TransportOption = option.Interface
111121

0 commit comments

Comments
 (0)