diff --git a/go.mod b/go.mod index 688b31a..32b3a53 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gin-contrib/authz v1.0.6 github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/gzip v1.2.5 + github.com/gin-contrib/httpsign v1.0.3 github.com/gin-contrib/secure v1.1.2 github.com/gin-contrib/sessions v1.0.4 github.com/gin-contrib/slog v1.2.0 diff --git a/go.sum b/go.sum index 3e39b7d..8feecb2 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQ github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= +github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I= +github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k= github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U= github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w= github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= diff --git a/httpsign_test.go b/httpsign_test.go new file mode 100644 index 0000000..a25ea6f --- /dev/null +++ b/httpsign_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-contrib/httpsign" + "github.com/gin-contrib/httpsign/crypto" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +const testSecretKey = "test-secret-key-for-hmac-sha256" + +// testKeyID is the key ID used in HTTP signature tests. +var testKeyID = httpsign.KeyID("test-client") + +// newTestSecrets builds a Secrets map with a single HMAC-SHA256 key for testing. +func newTestSecrets() httpsign.Secrets { + return httpsign.Secrets{ + testKeyID: &httpsign.Secret{ + Key: testSecretKey, + Algorithm: &crypto.HmacSha256{}, + }, + } +} + +// signRequest constructs a valid HTTP Signature Authorization header for the +// given request, signing the specified headers with HMAC-SHA256 and the test +// secret key. The Date header is set to the current time if not already present. +func signRequest(req *http.Request, keyID httpsign.KeyID, secret string, headers []string) { + // Ensure a Date header exists. + if req.Header.Get("Date") == "" { + req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + } + + // Build the signing string in the same way the library does: + // each header as "header: value", joined by newlines. + var parts []string + for _, h := range headers { + var val string + switch h { + case "(request-target)": + val = fmt.Sprintf("%s %s", strings.ToLower(req.Method), req.URL.RequestURI()) + case "host": + val = req.Host + default: + val = req.Header.Get(h) + } + parts = append(parts, fmt.Sprintf("%s: %s", h, val)) + } + signingString := strings.Join(parts, "\n") + + // Sign with HMAC-SHA256. + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingString)) + sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + // Build the Authorization header. + authValue := fmt.Sprintf( + "Signature keyId=\"%s\",algorithm=\"hmac-sha256\",headers=\"%s\",signature=\"%s\"", + keyID, + strings.Join(headers, " "), + sig, + ) + req.Header.Set("Authorization", authValue) +} + +// ── WithHTTPSign ────────────────────────────────────────────────────────── + +func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Use only (request-target) and date as required headers, disable + // validators to keep the test focused on signature verification. + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), // no validators — pure signature check + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + signRequest(req, testKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for validly signed request, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + + // Sign with the wrong secret so the signature is invalid. + signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid signature, got %d", w.Code) + } +} + +func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + + // Send a request with no signature at all. + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing signature, got %d", w.Code) + } +} + +func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New( + api.WithRequestID(), + api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + ), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + signRequest(req, testKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + + // Verify that WithRequestID also ran. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + + // Sign with an unknown key ID that does not exist in the secrets map. + unknownKeyID := httpsign.KeyID("unknown-client") + signRequest(req, unknownKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unknown key ID, got %d", w.Code) + } +} diff --git a/options.go b/options.go index 4e9a92d..3b0fba0 100644 --- a/options.go +++ b/options.go @@ -12,6 +12,7 @@ import ( "github.com/gin-contrib/authz" "github.com/gin-contrib/cors" gingzip "github.com/gin-contrib/gzip" + "github.com/gin-contrib/httpsign" "github.com/gin-contrib/secure" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -232,3 +233,23 @@ func WithAuthz(enforcer *casbin.Enforcer) Option { e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer)) } } + +// WithHTTPSign adds HTTP signature verification middleware via +// gin-contrib/httpsign. Incoming requests must carry a valid cryptographic +// signature in the Authorization or Signature header as defined by the HTTP +// Signatures specification (draft-cavage-http-signatures). +// +// The caller provides a key store mapping key IDs to secrets (each pairing a +// shared key with a signing algorithm). Optional httpsign.Option values may +// configure required headers or custom validators; sensible defaults apply +// when omitted (date, digest, and request-target headers are required; date +// and digest validators are enabled). +// +// Requests with a missing, malformed, or invalid signature are rejected with +// 401 Unauthorised or 400 Bad Request. +func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option { + return func(e *Engine) { + auth := httpsign.NewAuthenticator(secrets, opts...) + e.middlewares = append(e.middlewares, auth.Authenticated()) + } +}