diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6c7d82a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is an implementation of HTTP Message Signatures per RFC 9421. The library provides functionality for signing and verifying HTTP requests and responses using various cryptographic algorithms. + +## Development Commands + +### Testing +- `go test` - Run all tests +- `go test ./...` - Run tests recursively for all packages +- `go test -v` - Run tests with verbose output +- `go test -run TestName` - Run specific test +- `go test -fuzz FuzzName` - Run fuzz tests (see testdata/fuzz/ for existing fuzz test data) + +### Building and Linting +- `go build` - Build the package +- `go vet` - Run go vet for static analysis +- `go fmt ./...` - Format all Go files +- `go mod tidy` - Clean up module dependencies +- `go mod verify` - Verify module integrity + +### Coverage +- `go test -cover` - Run tests with coverage +- `go test -coverprofile=coverage.out && go tool cover -html=coverage.out` - Generate HTML coverage report + +## Architecture Overview + +### Core Components + +1. **Signing Pipeline** (`sign.go`, `signatures.go`) + - `Signer` struct orchestrates request/response signing + - `SigningProfile` defines what fields and metadata to include + - `SigningKey` holds cryptographic material and metadata + - Supports asymmetric (RSA, ECDSA, Ed25519) and symmetric (HMAC) algorithms + +2. **Accept-Signature Support** (`accept.go`) + - `AcceptSignature` struct for parsing Accept-Signature headers + - `ParseAcceptSignature()` function for client-server signature negotiation + - Support for signature profile requirements from clients + +3. **Verification Pipeline** (`verify.go`) + - `Verifier` struct handles signature verification + - `VerifyProfile` enforces security requirements beyond basic signature validation + - `KeyFetcher` interface for key retrieval by keyID or other metadata + - Time-based validation for created/expires metadata with configurable tolerances + +4. **Core Engine** (`base.go`) + - `calculateSignatureBase()` generates the canonical string for signing/verification + - `componentID` represents signature components (headers or derived values like @method, @target-uri) + - `httpMessage` abstraction handles both requests and responses uniformly + +5. **HTTP Integration** (`http.go`) + - `NewHTTPClient()` creates signing/verifying HTTP clients + - `NewHandler()` wraps http.Handler for automatic request verification + - `transport` type implements http.RoundTripper for transparent signing/verification + +6. **Content Integrity** (`digest.go`) + - Automatic Content-Digest header calculation when signing + - Support for SHA-256 and SHA-512 digests + - Body preservation during digest calculation + +### Key Utilities + +- **keyman/** - In-memory key storage implementation +- **keyutil/** - PEM key file reading utilities with support for various formats +- **sigtest/** - Test helpers for signature testing + +### Error Handling + +The library uses a structured error system (`sigerrors.go`) with specific error types: +- `ErrNoSigMissingSignature` - Missing signature headers +- `ErrSigVerification` - Signature verification failures +- `ErrSigProfile` - Profile validation failures +- `ErrSigKeyFetch` - Key retrieval failures + +### Algorithm Support + +**Asymmetric:** +- RSA-PSS-SHA512 (`rsa-pss-sha512`) +- RSA v1.5 SHA256 (`rsa-v1_5-sha256`) +- ECDSA P-256 SHA256 (`ecdsa-p256-sha256`) +- ECDSA P-384 SHA384 (`ecdsa-p384-sha384`) +- Ed25519 (`ed25519`) + +**Symmetric:** +- HMAC-SHA256 (`hmac-sha256`) + +### Security Defaults + +`DefaultVerifyProfile` provides secure defaults: +- Requires Content-Digest, @method, @target-uri fields +- Mandates 'created' and 'keyid' metadata +- 5-minute signature validity window +- Prohibits algorithm metadata (must be derived from key) +- Allows only secure algorithms (ECDSA, Ed25519, HMAC) + +## Testing Approach + +The codebase uses standard Go testing with: +- RFC test vectors in `testdata/` directory +- Fuzz testing for signature parsing +- Round-trip tests validating sign→verify cycles +- Comprehensive algorithm coverage + +## Important Implementation Notes + +- Signature base calculation follows RFC 9421 exactly +- Multi-value headers are not yet supported (will return error) +- Content-Digest is automatically calculated when included in signature fields +- Request body is preserved during signing/verification by reading and reconstructing +- Context keys are used to pass verification results through HTTP middleware diff --git a/fz_test.go b/fz_test.go index ed69fcb..9e5335d 100644 --- a/fz_test.go +++ b/fz_test.go @@ -3,8 +3,12 @@ package httpsig import ( "bufio" "bytes" + "errors" + "fmt" "net/http" + "net/http/httptest" "os" + "strings" "testing" "github.com/remitly-oss/httpsig-go/sigtest" @@ -158,6 +162,14 @@ func FuzzExtractSignatures(f *testing.F) { SignatureHeader: "sig-b24=(\"@status\" \"content-type\" \"content-digest\" \"content-length\");created=1618884473;keyid=\"test-key-ecc-p256\"", SignatureInputHeader: "sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==:", }, + {"sig1=:dGVzdA==:", "sig1=(\"@method\", \"@target-uri\");created=1618884473"}, + {"sig1=:invalid-base64:", "sig1=(\"@method\");created=1618884473"}, + {"sig1", "sig1=()"}, + {"sig1=:dGVzdA==:, sig2=:dGVzdA==:", "sig1=(\"@method\"), sig2=(\"@path\")"}, + {"sig1=invalid", "sig1=(\"@method\")"}, + {"sig1=:dGVzdA==:", "sig1=invalid"}, + {"=:dGVzdA==:", "=(\"@method\")"}, + {"sig1=:dGVzdA==:", "sig1=("}, } for _, tc := range testcases { @@ -198,3 +210,71 @@ func FuzzExtractSignatures(f *testing.F) { } }) } + +// FuzzComponentIDParsing tests component identifier parsing edge cases +func FuzzComponentIDParsing(f *testing.F) { + testcases := []struct { + name string + parameters map[string]string + }{ + {"@method", nil}, + {"@target-uri", nil}, + {"@query-param", map[string]string{"name": "test"}}, + {"content-digest", nil}, + {"", nil}, + {"@invalid-component", nil}, + {"header-with-unicode-😀", nil}, + {"@query-param", map[string]string{"name": ""}}, + {"@query-param", map[string]string{"": "value"}}, + } + + for _, tc := range testcases { + params := "" + for k, v := range tc.parameters { + params += fmt.Sprintf(";%s=%s", k, v) + } + f.Add(tc.name, params) + } + + f.Fuzz(func(t *testing.T, name, paramStr string) { + // Create a SignedField with fuzzed input + field := SignedField{Name: name} + + // Parse parameters string into map + if paramStr != "" { + field.Parameters = make(map[string]any) + pairs := strings.Split(paramStr, ";") + for _, pair := range pairs { + if kv := strings.SplitN(pair, "=", 2); len(kv) == 2 { + field.Parameters[kv[0]] = kv[1] + } + } + } + + // Convert to componentID - should not panic + cID := field.componentID() + + // Try to get signature name and value + _, err := cID.signatureName() + if err != nil { + var sigErr *SignatureError + if !errors.As(err, &sigErr) { + t.Errorf("Expected SignatureError, got: %T", err) + } + } + + req := httptest.NewRequest("GET", + "http://example.com/test?param=value", nil) + req.Header.Set("Content-Digest", + "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:") + msg := httpMessage{Req: req} + + _, err = cID.signatureValue(msg) + if err != nil { + var sigErr *SignatureError + if !errors.As(err, &sigErr) { + t.Errorf("Expected SignatureError, got: %T", err) + } + } + }) +} diff --git a/roundtrip_test.go b/roundtrip_test.go index 52c3ee4..5f35c5c 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -42,7 +42,10 @@ func TestRoundTrip(t *testing.T) { PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), }, }), - Profile: createVerifyProfile("tst-rsa-pss"), + Profile: httpsig.VerifyProfile{ + SignatureLabel: "tst-rsa-pss", + AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_RSA_PSS_SHA512}, + }, }, { Name: "RSA-v15", @@ -62,7 +65,10 @@ func TestRoundTrip(t *testing.T) { PubKey: keyutil.MustReadPublicKeyFile("testdata/key-rsa-v15.pub"), }, }), - Profile: createVerifyProfile("tst-rsa-pss"), + Profile: httpsig.VerifyProfile{ + SignatureLabel: "tst-rsa-pss", + AllowedAlgorithms: []httpsig.Algorithm{httpsig.Algo_RSA_v1_5_sha256}, + }, }, { Name: "HMAC_SHA256", diff --git a/spec_test.go b/spec_test.go index 788d4d6..4cdfb5b 100644 --- a/spec_test.go +++ b/spec_test.go @@ -84,7 +84,7 @@ func TestSpecVerify(t *testing.T) { for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { if tc.Skip { - t.Skip(fmt.Sprintf("Skipping test %s", tc.Name)) + t.Skipf("Skipping test %s", tc.Name) } hrrtxt, err := os.Open(fmt.Sprintf("testdata/%s", tc.SignedRequestOrResonseFile)) @@ -96,7 +96,8 @@ func TestSpecVerify(t *testing.T) { requiredKeyID: tc.Key.KeyID, key: tc.Key, }, VerifyProfile{ - SignatureLabel: fmt.Sprintf("sig-%s", tc.Name), + SignatureLabel: fmt.Sprintf("sig-%s", tc.Name), + DisableTimeEnforcement: true, }) var verifyErr error diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..defd4a8 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,885 @@ +package httpsig + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +func TestValidateProfile(t *testing.T) { + testcases := []struct { + Name string + Sig extractedSignature + Profile VerifyProfile + KeySpecAlgo Algorithm + Expected ErrCode // Expected ErrCode if an error. Empty string if expecting no error + ExpectedMsg string + }{ + // Signature Label Validation Tests + { + Name: "ValidSignatureLabel", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{{Name: "content-digest"}, {Name: "@method"}, {Name: "@target-uri"}}, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1755206251), + }, + }, + }, + }, + Profile: VerifyProfile{ + SignatureLabel: "sig1", + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256}, + RequiredFields: Fields("content-digest", "@method", "@target-uri"), + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + nowTime: func() time.Time { + return time.Unix(int64(1755206251), 0) + }, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "InvalidSignatureLabel", + Sig: extractedSignature{ + Label: "sig2", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + SignatureLabel: "sig1", + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature label 'sig2' does not match required label 'sig1'", + }, + { + Name: "EmptySignatureLabelInProfile", + Sig: extractedSignature{ + Label: "sig2", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + SignatureLabel: "", // Empty means any label is acceptable + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + + // Algorithm Validation Tests + { + Name: "ValidAlgorithm", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256, Algo_ED25519}, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "InvalidAlgorithm", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256, Algo_ED25519}, + }, + KeySpecAlgo: Algo_RSA_PSS_SHA512, + Expected: ErrSigProfile, + ExpectedMsg: "Algorithm 'rsa-pss-sha512' is not in allowed algorithms list", + }, + { + Name: "NoAllowedAlgorithmsRestriction", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + AllowedAlgorithms: []Algorithm{}, // Empty means any algorithm is acceptable + }, + KeySpecAlgo: Algo_RSA_PSS_SHA512, + Expected: ErrCode(""), + }, + + // Required Fields Validation Tests + { + Name: "ValidRequiredFields", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + {Name: "@method"}, + {Name: "@target-uri"}, + {Name: "authorization"}, // Extra field is OK + }, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredFields: Fields("content-digest", "@method", "@target-uri"), + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "MissingRequiredField", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + {Name: "@method"}, + // Missing @target-uri + }, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredFields: Fields("content-digest", "@method", "@target-uri"), + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature missing required field '@target-uri'", + }, + { + Name: "CaseInsensitiveFieldMatching", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, // lowercase in signature + {Name: "authorization"}, // lowercase in signature + }, + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredFields: Fields("Content-Digest", "Authorization"), // Mixed case in profile + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "NoRequiredFields", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, // Empty components is OK when no fields required + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredFields: []SignedField{}, // No fields required + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + + // Required Metadata Validation Tests + { + Name: "ValidRequiredMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaKeyID, MetaNonce}, // Extra metadata is OK + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1755206251), + }, + }, + }, + }, + Profile: VerifyProfile{ + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + nowTime: func() time.Time { + return time.Unix(int64(1755206251), 0) + }, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "MissingRequiredMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, // Missing MetaKeyID + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature missing required meta parameter 'keyid'", + }, + + // Disallowed Metadata Validation Tests + { + Name: "DisallowedMetaAlgorithm", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{{Name: "content-digest"}, {Name: "@method"}, {Name: "@target-uri"}}, + MetadataParams: []Metadata{MetaCreated, MetaKeyID, MetaAlgorithm}, // This should be disallowed + MetadataValues: nil, + }, + }, + Profile: DefaultVerifyProfile, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature contains disallowed meta parameter 'alg'", + }, + { + Name: "NoDisallowedMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, // Only allowed metadata + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1755206251), + }, + }, + }, + }, + Profile: VerifyProfile{ + DisallowedMetadata: []Metadata{MetaAlgorithm}, + nowTime: func() time.Time { + return time.Unix(int64(1755206251), 0) + }, + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + + // Complex Validation Tests (Multiple Rules) + { + Name: "ComplexValidSignature", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + {Name: "@method"}, + {Name: "@target-uri"}, + }, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1755206251), + }, + }, + }, + }, + Profile: VerifyProfile{ + SignatureLabel: "sig1", + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256, Algo_ED25519}, + RequiredFields: Fields("content-digest", "@method", "@target-uri"), + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + DisallowedMetadata: []Metadata{MetaAlgorithm}, + nowTime: func() time.Time { + return time.Unix(int64(1755206251), 0) + }, + }, + KeySpecAlgo: Algo_ED25519, + Expected: ErrCode(""), + }, + { + Name: "ComplexInvalidSignature_MultipleFailures", + Sig: extractedSignature{ + Label: "sig2", // Wrong label + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + // Missing @method and @target-uri + }, + MetadataParams: []Metadata{MetaAlgorithm}, // Disallowed metadata, missing required + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + SignatureLabel: "sig1", + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256}, + RequiredFields: Fields("content-digest", "@method", "@target-uri"), + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + DisallowedMetadata: []Metadata{MetaAlgorithm}, + }, + KeySpecAlgo: Algo_RSA_PSS_SHA512, // Not allowed algorithm + Expected: ErrSigProfile, + ExpectedMsg: "Signature label 'sig2' does not match required label 'sig1'", // First error encountered + }, + + // DefaultVerifyProfile Tests + { + Name: "DefaultVerifyProfile_Valid", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + {Name: "@method"}, + {Name: "@target-uri"}, + }, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: time.Now().Unix(), + }, + }, + }, + }, + Profile: DefaultVerifyProfile, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrCode(""), + }, + { + Name: "DefaultVerifyProfile_InvalidAlgorithm", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + {Name: "@method"}, + {Name: "@target-uri"}, + }, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, + MetadataValues: nil, + }, + }, + Profile: DefaultVerifyProfile, + KeySpecAlgo: Algo_RSA_v1_5_sha256, // Not in DefaultVerifyProfile allowed algorithms + Expected: ErrSigProfile, + ExpectedMsg: "Algorithm 'rsa-v1_5-sha256' is not in allowed algorithms list", + }, + { + Name: "DefaultVerifyProfile_MissingRequiredFields", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{ + {Name: "content-digest"}, + // Missing @method and @target-uri required by DefaultVerifyProfile + }, + MetadataParams: []Metadata{MetaCreated, MetaKeyID}, + MetadataValues: nil, + }, + }, + Profile: DefaultVerifyProfile, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature missing required field '@method'", + }, + + // Edge Cases + { + Name: "EmptySignatureComponents", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, // No components + MetadataParams: []Metadata{}, + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredFields: Fields("content-digest"), // But we require some fields + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature missing required field 'content-digest'", + }, + { + Name: "EmptyMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, // No metadata + MetadataValues: nil, + }, + }, + Profile: VerifyProfile{ + RequiredMetadata: []Metadata{MetaCreated}, // But we require some metadata + }, + KeySpecAlgo: Algo_ECDSA_P256_SHA256, + Expected: ErrSigProfile, + ExpectedMsg: "Signature missing required meta parameter 'created'", + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + err := tc.Profile.validate(tc.Sig, tc.KeySpecAlgo) + if tc.Expected == ErrCode("") { + sigtest.Diff(t, nil, err, "Diff") + return + } + var sigErr *SignatureError + if errors.As(err, &sigErr) { + sigtest.Diff(t, tc.Expected, sigErr.Code, "Unexpected error code") + if tc.ExpectedMsg != "" && err != nil { + if !strings.Contains(sigErr.Message, tc.ExpectedMsg) { + t.Errorf("Expected error message to contain '%s', got: %s", tc.ExpectedMsg, err.Error()) + } + } + } else { + t.Fatal("Error was not type *SignatureError") + } + + }) + } +} + +func TestValidateTiming(t *testing.T) { + now := time.Now() + + testcases := []struct { + Name string + Sig extractedSignature + Profile VerifyProfile + Expected ErrCode // Expected ErrCode if an error. Empty string if expecting no error + ExpectedMsg string + }{ + // DisableTimeEnforcement Tests + { + Name: "TimeEnforcementDisabled_ExpiredSignature", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-2 * time.Hour).Unix()), // Very old + MetaExpires: int64(now.Add(-1 * time.Hour).Unix()), // Expired + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableTimeEnforcement: true, // Should ignore all timing issues + }, + Expected: ErrCode(""), + }, + { + Name: "TimeEnforcementEnabled_SameSignature", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-2 * time.Hour).Unix()), + MetaExpires: int64(now.Add(-1 * time.Hour).Unix()), + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableTimeEnforcement: false, + CreatedValidDuration: time.Minute * 5, // Only 5 minutes allowed + }, + Expected: ErrSigProfile, + ExpectedMsg: "is older than allowed duration", + }, + + // Created Time Validation Tests + { + Name: "ValidCreatedTime", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-2 * time.Minute).Unix()), // 2 minutes ago + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, // Allow 5 minutes + }, + Expected: ErrCode(""), + }, + { + Name: "CreatedTimeTooOld", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-10 * time.Minute).Unix()), // 10 minutes ago + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, // Only allow 5 minutes + }, + Expected: ErrSigProfile, + ExpectedMsg: "is older than allowed duration", + }, + { + Name: "CreatedTimeInFuture", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(5 * time.Minute).Unix()), // 5 minutes in future + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 10, + }, + Expected: ErrSigProfile, + ExpectedMsg: "is too far in the future", + }, + { + Name: "CreatedTimeSlightlyInFuture_Allowed", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(30 * time.Second).Unix()), // 30 seconds in future (clock skew) + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 10, + }, + Expected: ErrCode(""), // Should be allowed due to clock skew tolerance + }, + // Expires Time Validation Tests + { + Name: "ValidExpiresTime", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaExpires: int64(now.Add(5 * time.Minute).Unix()), // Expires in 5 minutes + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: false, + }, + Expected: ErrCode(""), + }, + { + Name: "ExpiredSignature", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaExpires: int64(now.Add(-5 * time.Minute).Unix()), // Expired 5 minutes ago + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: false, + }, + Expected: ErrSigProfile, + ExpectedMsg: "Signature expired at", + }, + { + Name: "ExpiredSignature_WithinSkewTolerance", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaExpires: int64(now.Add(-30 * time.Second).Unix()), // Expired 30 seconds ago + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: false, + ExpiredSkew: time.Minute, // Allow 1 minute skew + }, + Expected: ErrCode(""), // Should be allowed within skew tolerance + }, + { + Name: "ExpirationEnforcementDisabled", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaExpires: int64(now.Add(-1 * time.Hour).Unix()), // Very expired + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: true, // Should ignore expiration + }, + Expected: ErrCode(""), + }, + + // Complex Timing Tests + { + Name: "BothCreatedAndExpires_Valid", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-2 * time.Minute).Unix()), // 2 minutes ago + MetaExpires: int64(now.Add(3 * time.Minute).Unix()), // 3 minutes from now + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, + DisableExpirationEnforcement: false, + }, + Expected: ErrCode(""), + }, + { + Name: "BothCreatedAndExpires_CreatedTooOld", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-10 * time.Minute).Unix()), // 10 minutes ago + MetaExpires: int64(now.Add(3 * time.Minute).Unix()), // 3 minutes from now + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, // Only allow 5 minutes + DisableExpirationEnforcement: false, + }, + Expected: ErrSigProfile, + ExpectedMsg: "is older than allowed duration", // Created validation fails first + }, + { + Name: "BothCreatedAndExpires_Expired", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated, MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(now.Add(-2 * time.Minute).Unix()), // 2 minutes ago (valid) + MetaExpires: int64(now.Add(-3 * time.Minute).Unix()), // Expired 3 minutes ago + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, + DisableExpirationEnforcement: false, + }, + Expected: ErrSigProfile, + ExpectedMsg: "Signature expired at", + }, + + // Error Handling Tests + { + Name: "InvalidCreatedMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaCreated}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + // Missing MetaCreated value + }, + }, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, + }, + Expected: ErrSigProfile, + ExpectedMsg: "Failed to get created timestamp", + }, + { + Name: "InvalidExpiresMetadata", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{MetaExpires}, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + // Missing MetaExpires value + }, + }, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: false, + }, + Expected: ErrSigProfile, + ExpectedMsg: "Failed to get expires timestamp", + }, + + // No Metadata Present Tests + { + Name: "NoCreatedMetadata_NoValidation", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, // No created metadata + MetadataValues: fixedMetadataProvider{values: map[Metadata]any{}}, + }, + }, + Profile: VerifyProfile{ + CreatedValidDuration: time.Minute * 5, + }, + Expected: ErrCode(""), // Should pass since no created time to validate + }, + { + Name: "NoExpiresMetadata_NoValidation", + Sig: extractedSignature{ + Label: "sig1", + Signature: []byte{}, + Input: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{}, // No expires metadata + MetadataValues: fixedMetadataProvider{values: map[Metadata]any{}}, + }, + }, + Profile: VerifyProfile{ + DisableExpirationEnforcement: false, + }, + Expected: ErrCode(""), // Should pass since no expires time to validate + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + err := tc.Profile.validateTiming(tc.Sig, now) + if tc.Expected == ErrCode("") { + sigtest.Diff(t, nil, err, "Diff") + return + } + var sigErr *SignatureError + if errors.As(err, &sigErr) { + sigtest.Diff(t, tc.Expected, sigErr.Code, "Unexpected error code") + if tc.ExpectedMsg != "" && err != nil { + if !strings.Contains(sigErr.Message, tc.ExpectedMsg) { + t.Errorf("Expected error message to contain '%s', got: %s", tc.ExpectedMsg, err.Error()) + } + } + } else { + t.Fatal("Error was not type *SignatureError") + } + }) + } +} diff --git a/verify.go b/verify.go index 7015ee7..9bc7217 100644 --- a/verify.go +++ b/verify.go @@ -14,6 +14,7 @@ import ( "math/big" "net/http" "slices" + "strings" "time" sfv "github.com/dunglas/httpsfv" @@ -27,7 +28,6 @@ var ( RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, DisallowedMetadata: []Metadata{MetaAlgorithm}, // The algorithm should be looked up from the keyid not an explicit setting. CreatedValidDuration: time.Minute * 5, // Signatures must have been created within within the last 5 minutes - DateFieldSkew: time.Minute, // If the created parameter is present, the Date header cannot be more than a minute off. } // DefaultRequiredFields covers the request body with 'content-digest' the method and full URI. @@ -35,6 +35,9 @@ var ( DefaultRequiredFields = Fields("content-digest", "@method", "@target-uri") ctxKeyAddDebug = struct{}{} + + defaultExpireSkew = time.Minute + defaultCreatedValidDuration = 5 * time.Minute ) // KeySpec is the per-key information needed to verify a signature. @@ -72,7 +75,7 @@ type KeyFetcher interface { // VerifyProfile sets the parameters for a fully valid request or response. // A valid signature is a relatively easy accomplishment. Did the signature include all the important parts of the request? Did it use a strong enough algorithm? Was it signed 41 days ago? There are choices to make about what constitutes a valid signed request or response beyond just a verified signature. type VerifyProfile struct { - SignatureLabel string // Which signature this profile applies to. '*' applies to all + SignatureLabel string // Which signature this profile applies to. RequiredFields []SignedField RequiredMetadata []Metadata DisallowedMetadata []Metadata @@ -81,9 +84,11 @@ type VerifyProfile struct { // Timing enforcement options DisableTimeEnforcement bool // If true do no time enforcement on any fields DisableExpirationEnforcement bool // If expiration is present default to enforce - CreatedValidDuration time.Duration // Duration allowed for between time.Now and the created time - ExpiredSkew time.Duration // Maximum duration allowed between time.Now and the expired time - DateFieldSkew time.Duration // Maximum duration allowed between Date field and created + CreatedValidDuration time.Duration // Duration allowed for between time.Now and the created time. Default to 5 minutes. + ExpiredSkew time.Duration // Maximum duration allowed between time.Now and the expired time. Default to 1 minute. + + // Control time for testing + nowTime func() time.Time } type VerifyResult struct { @@ -124,6 +129,9 @@ func NewVerifier(kf KeyFetcher, profile VerifyProfile) (*Verifier, error) { if kf == nil { return nil, newError(ErrSigKeyFetch, "KeyFetcher cannot be nil") } + if profile.nowTime == nil { + profile.nowTime = time.Now + } return &Verifier{ keys: kf, profile: profile, @@ -190,13 +198,13 @@ func (ver *Verifier) verify(hrr httpMessage) (VerifyResult, error) { SignatureBase: string(base.base), } } - keyspec, err := ver.verifySignature(hrr, sig, base) + keyspec, ks, err := ver.verifySignature(hrr, sig, base) vr.KeySpecer = keyspec if err != nil { return vr, err } - if err := ver.profile.validate(sig); err != nil { + if err := ver.profile.validate(sig, ks.Algo); err != nil { return vr, err } @@ -248,7 +256,7 @@ func parseSignaturesFromRequest(headers http.Header) (signaturesSFV, error) { return psd, nil } -// unmarshalSignature unmarshals a signature from hhtp structured field value (sfv) format. +// unmarshalSignature unmarshals a signature from http structured field value (sfv) format. func unmarshalSignature(sigs signaturesSFV, label string) (extractedSignature, error) { sigInfo := extractedSignature{ Label: label, @@ -286,7 +294,7 @@ func unmarshalSignature(sigs signaturesSFV, label string) (extractedSignature, e for _, componentItem := range sigInputList.Items { name, ok := componentItem.Value.(string) if !ok { - return sigInfo, newError(ErrSigInvalidSignature, fmt.Sprintf("signature components must be string types")) + return sigInfo, newError(ErrSigInvalidSignature, "signature components must be string types") } cIDs = append(cIDs, componentID{ Name: name, @@ -306,7 +314,7 @@ func unmarshalSignature(sigs signaturesSFV, label string) (extractedSignature, e return sigInfo, nil } -func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base signatureBase) (KeySpecer, error) { +func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base signatureBase) (KeySpecer, KeySpec, error) { var specer KeySpecer var ks KeySpec var err error @@ -314,28 +322,34 @@ func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base if slices.Contains(sig.Input.MetadataParams, MetaKeyID) { keyid, err := sig.Input.MetadataValues.KeyID() if err != nil { - return nil, newError(ErrSigKeyFetch, "Could not get keyid from signature metadata", err) + return nil, ks, newError(ErrSigKeyFetch, "Could not get keyid from signature metadata", err) } specer, err = ver.keys.FetchByKeyID(r.Context(), r.Headers(), keyid) if err != nil { - return nil, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) + return nil, ks, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) } ks, err = specer.KeySpec() if err != nil { - return nil, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) + return nil, ks, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) } } else { specer, err = ver.keys.Fetch(r.Context(), r.Headers(), sig.Input.MetadataValues) if err != nil { - return specer, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) + return specer, ks, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) } ks, err = specer.KeySpec() if err != nil { - return specer, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) + return specer, ks, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) } } + // Check the algorithm value matches the KeySpec if it was provided as metadata + signatureAlg, err := sig.Input.MetadataValues.Alg() + if err != nil && signatureAlg != "" && ks.Algo != Algorithm(signatureAlg) { + return specer, ks, newError(ErrSigUnsupportedAlgorithm, fmt.Sprintf("Algorithm in signature metadata is '%s' but the KeySpec is for algorithm '%s'", signatureAlg, ks.Algo)) + } + switch ks.Algo { case Algo_RSA_PSS_SHA512: if rsapub, ok := ks.PubKey.(*rsa.PublicKey); ok { @@ -346,37 +360,37 @@ func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base msgHash := sha512.Sum512(base.base) err := rsa.VerifyPSS(rsapub, crypto.SHA512, msgHash[:], sig.Signature, opts) if err != nil { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } - return specer, nil + return specer, ks, nil } else { - return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) + return specer, ks, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) } case Algo_RSA_v1_5_sha256: if rsapub, ok := ks.PubKey.(*rsa.PublicKey); ok { msgHash := sha256.Sum256(base.base) err := rsa.VerifyPKCS1v15(rsapub, crypto.SHA256, msgHash[:], sig.Signature) if err != nil { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } - return specer, nil + return specer, ks, nil } else { - return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) + return specer, ks, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) } case Algo_HMAC_SHA256: if len(ks.Secret) == 0 { - return specer, newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) + return specer, ks, newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) } msgHash := hmac.New(sha256.New, ks.Secret) msgHash.Write(base.base) // write does not return an error per hash.Hash documentation calcualtedSignature := msgHash.Sum(nil) if !hmac.Equal(calcualtedSignature, sig.Signature) { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } case Algo_ECDSA_P256_SHA256: if epub, ok := ks.PubKey.(*ecdsa.PublicKey); ok { if len(sig.Signature) != 64 { - return specer, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 64 bytes for algorithm '%s'", Algo_ECDSA_P256_SHA256)) + return specer, ks, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 64 bytes for algorithm '%s'", Algo_ECDSA_P256_SHA256)) } msgHash := sha256.Sum256(base.base) // Concatenate r and s to form the signature as per the spec. r and s and *not* ANS1 encoded. @@ -385,15 +399,15 @@ func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base s := new(big.Int) s.SetBytes(sig.Signature[32:64]) if !ecdsa.Verify(epub, msgHash[:], r, s) { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } } else { - return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) + return specer, ks, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) } case Algo_ECDSA_P384_SHA384: if epub, ok := ks.PubKey.(*ecdsa.PublicKey); ok { if len(sig.Signature) != 96 { - return specer, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 96 bytes for algorithm '%s'", Algo_ECDSA_P384_SHA384)) + return specer, ks, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 96 bytes for algorithm '%s'", Algo_ECDSA_P384_SHA384)) } msgHash := sha512.Sum384(base.base) // Concatenate r and s to form the signature as per the spec. r and s and *not* ANS1 encoded. @@ -402,26 +416,132 @@ func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base s := new(big.Int) s.SetBytes(sig.Signature[48:96]) if !ecdsa.Verify(epub, msgHash[:], r, s) { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } } else { - return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) + return specer, ks, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) } case Algo_ED25519: if edpubkey, ok := ks.PubKey.(ed25519.PublicKey); ok { if !ed25519.Verify(edpubkey, base.base, sig.Signature) { - return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + return specer, ks, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) } } else { - return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires ed25519.PublicKey but got type: %T", ks.PubKey)) + return specer, ks, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires ed25519.PublicKey but got type: %T", ks.PubKey)) } default: - return specer, newError(ErrSigUnsupportedAlgorithm, fmt.Sprintf("Invalid verification algorithm '%s'", ks.Algo)) + return specer, ks, newError(ErrSigUnsupportedAlgorithm, fmt.Sprintf("Invalid verification algorithm '%s'", ks.Algo)) + } + return specer, ks, nil +} + +func (vp VerifyProfile) now() time.Time { + if vp.nowTime == nil { + return time.Now() + } + return vp.nowTime() +} + +// validate enforces the VeriryProfile settings are met for the given signature. This should only done after the signature is *verified*. +func (vp VerifyProfile) validate(sig extractedSignature, ksAlgo Algorithm) error { + // Validate signature label + if vp.SignatureLabel != "" && sig.Label != vp.SignatureLabel { + return newError(ErrSigProfile, fmt.Sprintf("Signature label '%s' does not match required label '%s'", sig.Label, vp.SignatureLabel)) + } + + // Validate required fields are present in signature + if len(vp.RequiredFields) > 0 { + // Create a map of component names from the signature for efficient lookup + sigComponentNames := make(map[string]bool) + for _, component := range sig.Input.Components { + sigComponentNames[component.Name] = true + } + + // Check that all required fields are present + for _, requiredField := range vp.RequiredFields { + fieldName := strings.ToLower(requiredField.Name) + if !sigComponentNames[fieldName] { + return newError(ErrSigProfile, fmt.Sprintf("Signature missing required field '%s'", requiredField.Name)) + } + } + } + + // Validate required metadata + for _, md := range vp.RequiredMetadata { + if !slices.Contains(sig.Input.MetadataParams, md) { + return newError(ErrSigProfile, fmt.Sprintf("Signature missing required meta parameter '%s'", md)) + } + } + + // Validate disallowed metadata + for _, md := range vp.DisallowedMetadata { + if slices.Contains(sig.Input.MetadataParams, md) { + return newError(ErrSigProfile, fmt.Sprintf("Signature contains disallowed meta parameter '%s'", md)) + } + } + + // Validate allowed algorithms + if len(vp.AllowedAlgorithms) > 0 { + if !slices.Contains(vp.AllowedAlgorithms, ksAlgo) { + return newError(ErrSigProfile, fmt.Sprintf("Algorithm '%s' is not in allowed algorithms list", ksAlgo)) + } } - return specer, nil + + return vp.validateTiming(sig, vp.now()) } -func (vp VerifyProfile) validate(sig extractedSignature) error { +// validateTiming validates all the time based properties are within tolerance of the VerifyProfile. currentTime is passed in as a parameter to capture a stable time for all subsequent checks. +func (vp VerifyProfile) validateTiming(sig extractedSignature, currentTime time.Time) error { + // Early return if time enforcement is disabled + if vp.DisableTimeEnforcement { + return nil + } + + // Validate created time if present + if slices.Contains(sig.Input.MetadataParams, MetaCreated) { + createdTime, err := sig.Input.MetadataValues.Created() + if err != nil { + return newError(ErrSigProfile, "Failed to get created timestamp from signature metadata", err) + } + + createdTimestamp := time.Unix(int64(createdTime), 0) + + // Check if signature is too old (beyond CreatedValidDuration) + createDuration := vp.CreatedValidDuration + if createDuration == 0 { + createDuration = defaultCreatedValidDuration + } + if currentTime.Sub(createdTimestamp) > createDuration { + return newError(ErrSigProfile, fmt.Sprintf("Signature created time %s is older than allowed duration %s", createdTimestamp, createDuration)) + } + + // Check if signature was created in the future (allow some clock skew) + allowedSkew := time.Minute // Default 1 minute clock skew allowance + if createdTimestamp.Sub(currentTime) > allowedSkew { + return newError(ErrSigProfile, fmt.Sprintf("Signature created time %s is too far in the future", createdTimestamp)) + } + } + + // Validate expires time if present + if slices.Contains(sig.Input.MetadataParams, MetaExpires) && !vp.DisableExpirationEnforcement { + expiresTime, err := sig.Input.MetadataValues.Expires() + if err != nil { + return newError(ErrSigProfile, "Failed to get expires timestamp from signature metadata", err) + } + + expiresTimestamp := time.Unix(int64(expiresTime), 0) + + // Check if signature has expired + skew := vp.ExpiredSkew + if skew == 0 { + skew = defaultExpireSkew + } + + if currentTime.Sub(expiresTimestamp) > skew { + return newError(ErrSigProfile, fmt.Sprintf("Signature expired at %s (now: %s, allowed skew: %s)", expiresTimestamp, currentTime, skew)) + } + } + return nil } diff --git a/verify_test.go b/verify_test.go index 4c0dfe3..a7f9a3a 100644 --- a/verify_test.go +++ b/verify_test.go @@ -11,7 +11,8 @@ import ( "github.com/remitly-oss/httpsig-go/sigtest" ) -func TestVerify(t *testing.T) { +// TestVerifyResult ensures the VerifyReuslt contains the expected shape. +func TestVerifyResult(t *testing.T) { testcases := []struct { Name string RequestFile string @@ -84,7 +85,7 @@ func TestVerify(t *testing.T) { if tc.AddDebugInfo { req = req.WithContext(httpsig.SetAddDebugInfo(req.Context())) } - actual, err := httpsig.Verify(req, tc.Keys, httpsig.VerifyProfile{SignatureLabel: tc.Label}) + actual, err := httpsig.Verify(req, tc.Keys, httpsig.VerifyProfile{SignatureLabel: tc.Label, DisableTimeEnforcement: true}) if err != nil { t.Fatal(err) }