feat: add WithHTTPSign HTTP signature verification middleware

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 23:57:30 +00:00
parent 67dcc83a37
commit 7b3f99e421
4 changed files with 240 additions and 0 deletions

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

216
httpsign_test.go Normal file
View file

@ -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)
}
}

View file

@ -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())
}
}