feat(auth/xhubsig): X-Hub-Signature HMAC webhook verification + HTTP middleware

Verify X-Hub-Signature-256 (and SHA-1) webhook signatures. Middleware
buffers and re-exposes the body for downstream handlers. Errors honor
Accept header: TSV default (text/plain for browsers), JSON, CSV, or
Markdown — three fields (error, description, hint) with pseudocode hints.
This commit is contained in:
AJ ONeal 2026-04-13 15:13:30 -06:00
parent aebef71a95
commit 4abac2a0df
No known key found for this signature in database
8 changed files with 767 additions and 0 deletions

7
auth/xhubsig/LICENSE Normal file
View File

@ -0,0 +1,7 @@
Authored in 2026 by AJ ONeal <aj@therootcompany.com>
To the extent possible under law, the author(s) have dedicated all copyright
and related and neighboring rights to this software to the public domain
worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication along with
this software. If not, see <https://creativecommons.org/publicdomain/zero/1.0/>.

91
auth/xhubsig/README.md Normal file
View File

@ -0,0 +1,91 @@
# xhubsig
Verify [X-Hub-Signature-256](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) HMAC-SHA256 webhook signatures. HTTP middleware included.
```sh
go get github.com/therootcompany/golib/auth/xhubsig
```
## Middleware
Wrap any `http.Handler`. Verified body is buffered and re-readable by the next handler.
```go
x := xhubsig.New(webhookSecret)
mux.Handle("POST /webhook", x.Require(handleWebhook))
```
Require both SHA-256 and SHA-1 (all must pass):
```go
x := xhubsig.New(webhookSecret, xhubsig.SHA256, xhubsig.SHA1)
```
Accept either SHA-256 or SHA-1 (at least one must be present; all present must pass):
```go
x := xhubsig.New(webhookSecret, xhubsig.SHA256, xhubsig.SHA1)
x.AcceptAny = true
```
Raise the body limit (default 256 KiB):
```go
x.Limit = 1 << 20 // 1 MiB
```
## Sign / Verify
Compute a signature (for sending or testing):
```go
sig := xhubsig.Sign(xhubsig.SHA256, secret, body)
// → "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
```
Verify a signature directly:
```go
err := xhubsig.Verify(xhubsig.SHA256, secret, body, r.Header.Get("X-Hub-Signature-256"))
if errors.Is(err, xhubsig.ErrMissingSignature) { ... }
if errors.Is(err, xhubsig.ErrInvalidSignature) { ... }
```
Signature format: `sha256=<hex hmac-sha256 of raw request body>` using the webhook secret as the HMAC key. sha256 is the default algorithm.
## Error responses
Errors honor the `Accept` header; `Content-Type` matches. Default is TSV.
| `Accept` | Format |
|---|---|
| `text/tab-separated-values` | vertical key-value TSV *(default)* |
| `text/html` | `text/plain` TSV *(browser-safe)* |
| `application/json` | JSON object |
| `text/csv` | vertical key-value CSV |
| `text/markdown` | pipe table |
TSV example (`missing_signature`):
```
field value
error missing_signature
description No valid signature header was found.
hint X-Hub-Signature-256 is required. `X-Hub-Signature-256: sha256=hex(hmac_sha256(secret, body))`
```
JSON example:
```json
{
"error": "missing_signature",
"description": "No valid signature header was found.",
"hint": "X-Hub-Signature-256 is required.\n`X-Hub-Signature-256: sha256=hex(hmac_sha256(secret, body))`"
}
```
Error codes: `missing_signature`, `invalid_signature`, `body_too_large`.
## License
CC0-1.0. Public domain.

80
auth/xhubsig/errors.go Normal file
View File

@ -0,0 +1,80 @@
package xhubsig
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"strings"
)
type httpError struct {
Error string `json:"error"`
Description string `json:"description,omitempty"`
Hint string `json:"hint,omitempty"`
}
func (e httpError) rows() [][2]string {
rows := [][2]string{{"error", e.Error}}
if e.Description != "" {
rows = append(rows, [2]string{"description", e.Description})
}
if e.Hint != "" {
rows = append(rows, [2]string{"hint", e.Hint})
}
return rows
}
func acceptedFormat(accept string) string {
for part := range strings.SplitSeq(accept, ",") {
mt := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
switch mt {
case "application/json":
return "json"
case "text/csv":
return "csv"
case "text/markdown":
return "markdown"
case "text/html":
return "text/plain"
}
}
return "tsv"
}
// writeDelimited writes vertical key-value TSV or CSV. Newlines within
// values are collapsed to a space so agents can split on newlines reliably.
func writeDelimited(w http.ResponseWriter, httpCode int, ct string, sep rune, e httpError) {
w.Header().Set("Content-Type", ct)
w.WriteHeader(httpCode)
cw := csv.NewWriter(w)
cw.Comma = sep
cw.Write([]string{"field", "value"})
for _, row := range e.rows() {
cw.Write([]string{row[0], strings.ReplaceAll(row[1], "\n", " ")})
}
cw.Flush()
}
func serializeError(w http.ResponseWriter, r *http.Request, httpCode int, e httpError) {
switch acceptedFormat(r.Header.Get("Accept")) {
case "tsv":
writeDelimited(w, httpCode, "text/tab-separated-values", '\t', e)
case "text/plain":
writeDelimited(w, httpCode, "text/plain", '\t', e)
case "csv":
writeDelimited(w, httpCode, "text/csv", ',', e)
case "markdown":
w.Header().Set("Content-Type", "text/markdown")
w.WriteHeader(httpCode)
fmt.Fprintln(w, "| field | value |")
fmt.Fprintln(w, "| --- | --- |")
for _, row := range e.rows() {
fmt.Fprintf(w, "| %s | %s |\n", row[0], strings.ReplaceAll(row[1], "\n", " "))
}
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpCode)
json.NewEncoder(w).Encode(e)
}
}

View File

@ -0,0 +1,35 @@
package xhubsig_test
import (
"fmt"
"net/http"
"github.com/therootcompany/golib/auth/xhubsig"
)
func ExampleSign() {
sig := xhubsig.Sign(xhubsig.SHA256, "It's a Secret to Everybody", []byte("Hello, World!"))
fmt.Println(sig)
// Output:
// sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
}
func ExampleVerify() {
body := []byte("Hello, World!")
sig := xhubsig.Sign(xhubsig.SHA256, "secret", body)
err := xhubsig.Verify(xhubsig.SHA256, "secret", body, sig)
fmt.Println(err)
// Output:
// <nil>
}
func ExampleXHubSig_Require() {
x := xhubsig.New("webhookSecret")
mux := http.NewServeMux()
mux.Handle("POST /webhook", x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// body is verified and re-readable here
w.WriteHeader(http.StatusNoContent)
})))
}

3
auth/xhubsig/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/auth/xhubsig
go 1.26.0

147
auth/xhubsig/middleware.go Normal file
View File

@ -0,0 +1,147 @@
package xhubsig
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"errors"
"fmt"
"hash"
"io"
"net/http"
"strings"
)
const DefaultLimit = 256 * 1024
type Hash struct {
Header string
New func() hash.Hash
Prefix string
}
var SHA256 = Hash{
Header: "X-Hub-Signature-256",
New: sha256.New,
Prefix: "sha256=",
}
var SHA1 = Hash{
Header: "X-Hub-Signature",
New: sha1.New,
Prefix: "sha1=",
}
type XHubSig struct {
Secret string
Hashes []Hash
AcceptAny bool
Limit int64
}
func New(secret string, hashes ...Hash) *XHubSig {
if len(hashes) == 0 {
hashes = []Hash{SHA256}
}
return &XHubSig{
Secret: secret,
Hashes: hashes,
AcceptAny: false,
Limit: DefaultLimit,
}
}
// signatureHint builds a pseudocode hint showing how to compute each
// configured signature header using the webhook secret.
func (x *XHubSig) signatureHint() string {
lines := make([]string, len(x.Hashes))
for i, h := range x.Hashes {
algo := strings.TrimSuffix(h.Prefix, "=")
lines[i] = fmt.Sprintf("`%s: %shex(hmac_%s(secret, body))`", h.Header, h.Prefix, algo)
}
return strings.Join(lines, "\n")
}
func (x *XHubSig) writeHTTPError(w http.ResponseWriter, r *http.Request, errCode, detail string) {
var (
httpCode int
description string
hint string
)
switch errCode {
case "body_too_large":
httpCode = http.StatusRequestEntityTooLarge
description = "Request body exceeds the maximum allowed size."
hint = detail + "; reduce the payload size."
case "missing_signature":
httpCode = http.StatusUnauthorized
description = "No valid signature header was found."
hint = detail + "\n" + x.signatureHint()
case "invalid_signature":
httpCode = http.StatusUnauthorized
description = "Signature verification failed."
hint = detail + "\n" + x.signatureHint()
default:
httpCode = http.StatusInternalServerError
description = "An unexpected error occurred."
}
serializeError(w, r, httpCode, httpError{
Error: errCode,
Description: description,
Hint: hint,
})
}
func (x *XHubSig) readBody(r *http.Request) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, x.Limit+1))
r.Body.Close()
if len(body) > int(x.Limit) {
return nil, ErrBodyTooLarge
}
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}
func (x *XHubSig) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := x.readBody(r)
if err != nil {
if errors.Is(err, ErrBodyTooLarge) {
x.writeHTTPError(w, r, "body_too_large", fmt.Sprintf("limit is %d bytes", x.Limit))
return
}
w.WriteHeader(http.StatusBadRequest) // for loggers; client cannot receive a body
return
}
anyPresent := false
for _, h := range x.Hashes {
sig := r.Header.Get(h.Header)
if sig == "" {
if !x.AcceptAny {
x.writeHTTPError(w, r, "missing_signature", fmt.Sprintf("%s is required", h.Header))
return
}
continue
}
anyPresent = true
if err := Verify(h, x.Secret, body, sig); err != nil {
x.writeHTTPError(w, r, "invalid_signature", fmt.Sprintf("%s value did not match the expected HMAC", h.Header))
return
}
}
if !anyPresent {
headers := make([]string, len(x.Hashes))
for i, h := range x.Hashes {
headers[i] = h.Header
}
x.writeHTTPError(w, r, "missing_signature", "expected one of: "+strings.Join(headers, ", "))
return
}
next.ServeHTTP(w, r)
})
}

30
auth/xhubsig/xhubsig.go Normal file
View File

@ -0,0 +1,30 @@
package xhubsig
import (
"crypto/hmac"
"encoding/hex"
"errors"
)
var (
ErrMissingSignature = errors.New("missing signature")
ErrInvalidSignature = errors.New("invalid signature")
ErrBodyTooLarge = errors.New("body too large")
)
func Sign(h Hash, secret string, body []byte) string {
mac := hmac.New(h.New, []byte(secret))
mac.Write(body)
return h.Prefix + hex.EncodeToString(mac.Sum(nil))
}
func Verify(h Hash, secret string, body []byte, sig string) error {
if sig == "" {
return ErrMissingSignature
}
expected := Sign(h, secret, body)
if hmac.Equal([]byte(expected), []byte(sig)) {
return nil
}
return ErrInvalidSignature
}

View File

@ -0,0 +1,374 @@
package xhubsig
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
var testSecret = "It's a Secret to Everybody"
var testBody = []byte("Hello, World!")
func TestSignSHA256(t *testing.T) {
sig := Sign(SHA256, testSecret, testBody)
expected := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
if sig != expected {
t.Errorf("Sign SHA256 = %q, want %q", sig, expected)
}
}
func TestSignSHA1(t *testing.T) {
mac := hmac.New(sha1.New, []byte(testSecret))
mac.Write(testBody)
want := "sha1=" + hex.EncodeToString(mac.Sum(nil))
sig := Sign(SHA1, testSecret, testBody)
if sig != want {
t.Errorf("Sign SHA1 = %q, want %q", sig, want)
}
}
func TestVerifySHA256(t *testing.T) {
sig := Sign(SHA256, testSecret, testBody)
if err := Verify(SHA256, testSecret, testBody, sig); err != nil {
t.Errorf("Verify SHA256 should succeed: %v", err)
}
if err := Verify(SHA256, testSecret, testBody, "sha256=deadbeef"); !errors.Is(err, ErrInvalidSignature) {
t.Errorf("Verify SHA256 with wrong sig = %v, want ErrInvalidSignature", err)
}
if err := Verify(SHA256, testSecret, testBody, ""); !errors.Is(err, ErrMissingSignature) {
t.Errorf("Verify SHA256 with empty sig = %v, want ErrMissingSignature", err)
}
}
func TestVerifySHA1(t *testing.T) {
sig := Sign(SHA1, testSecret, testBody)
if err := Verify(SHA1, testSecret, testBody, sig); err != nil {
t.Errorf("Verify SHA1 should succeed: %v", err)
}
if err := Verify(SHA1, "wrong-secret", testBody, sig); !errors.Is(err, ErrInvalidSignature) {
t.Errorf("Verify SHA1 with wrong secret = %v, want ErrInvalidSignature", err)
}
}
func TestHashConstants(t *testing.T) {
if SHA256.Header != "X-Hub-Signature-256" {
t.Errorf("SHA256.Header = %q, want %q", SHA256.Header, "X-Hub-Signature-256")
}
if SHA256.Prefix != "sha256=" {
t.Errorf("SHA256.Prefix = %q, want %q", SHA256.Prefix, "sha256=")
}
if SHA1.Header != "X-Hub-Signature" {
t.Errorf("SHA1.Header = %q, want %q", SHA1.Header, "X-Hub-Signature")
}
if SHA1.Prefix != "sha1=" {
t.Errorf("SHA1.Prefix = %q, want %q", SHA1.Prefix, "sha1=")
}
}
func newSignedRequest(t *testing.T, body []byte, hashes ...Hash) *http.Request {
t.Helper()
r := httptest.NewRequest("POST", "/", bytes.NewReader(body))
for _, h := range hashes {
r.Header.Set(h.Header, Sign(h, testSecret, body))
}
return r
}
func TestNewDefaults(t *testing.T) {
x := New(testSecret)
if x.Secret != testSecret {
t.Errorf("Secret = %q, want %q", x.Secret, testSecret)
}
if len(x.Hashes) != 1 || x.Hashes[0].Header != SHA256.Header {
t.Errorf("Hashes = %v, want [SHA256]", x.Hashes)
}
if x.AcceptAny {
t.Error("AcceptAny = true, want false")
}
if x.Limit != DefaultLimit {
t.Errorf("Limit = %d, want %d", x.Limit, DefaultLimit)
}
}
func TestRequireAllMustPass(t *testing.T) {
called := false
x := New(testSecret, SHA1, SHA256)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("handler failed to read body: %v", err)
}
if string(body) != string(testBody) {
t.Errorf("handler body = %q, want %q", body, testBody)
}
called = true
}))
r := newSignedRequest(t, testBody, SHA1, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if !called {
t.Error("handler should have been called when all headers present and valid")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAllMissingOneHeader(t *testing.T) {
x := New(testSecret, SHA1, SHA256)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called when a header is missing")
}))
r := newSignedRequest(t, testBody, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAllOneHeaderWrong(t *testing.T) {
x := New(testSecret, SHA1, SHA256)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called when a signature is wrong")
}))
r := newSignedRequest(t, testBody, SHA1, SHA256)
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAllNoHeaders(t *testing.T) {
x := New(testSecret, SHA256)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called without signature")
}))
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "text/tab-separated-values" {
t.Errorf("Content-Type = %q, want %q", ct, "text/tab-separated-values")
}
if !strings.Contains(w.Body.String(), "X-Hub-Signature-256") {
t.Errorf("body = %q, want mention of expected header", w.Body.String())
}
}
func TestRequireAllDefaultsSHA256(t *testing.T) {
called := false
x := New(testSecret)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
r := newSignedRequest(t, testBody, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if !called {
t.Error("handler should have been called with default SHA256")
}
}
func TestRequireAnyAtLeastOne(t *testing.T) {
called := false
x := New(testSecret, SHA1, SHA256)
x.AcceptAny = true
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("handler failed to read body: %v", err)
}
if string(body) != string(testBody) {
t.Errorf("handler body = %q, want %q", body, testBody)
}
called = true
}))
r := newSignedRequest(t, testBody, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if !called {
t.Error("handler should be called with only SHA256 header present and valid")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAnyBothHeaders(t *testing.T) {
called := false
x := New(testSecret, SHA1, SHA256)
x.AcceptAny = true
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
r := newSignedRequest(t, testBody, SHA1, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if !called {
t.Error("handler should have been called with both headers")
}
}
func TestRequireAnyNoHeaders(t *testing.T) {
x := New(testSecret, SHA256)
x.AcceptAny = true
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called without any signature")
}))
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAnyPresentButWrong(t *testing.T) {
x := New(testSecret, SHA1, SHA256)
x.AcceptAny = true
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called with wrong signature")
}))
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAnyPresentButWrongOtherValid(t *testing.T) {
x := New(testSecret, SHA1, SHA256)
x.AcceptAny = true
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called when one present header is wrong")
}))
r := newSignedRequest(t, testBody, SHA1)
r.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d — all present sigs must pass", w.Code, http.StatusUnauthorized)
}
}
func TestRequireBodyReadable(t *testing.T) {
x := New(testSecret, SHA256)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("failed to read body: %v", err)
}
if !strings.Contains(string(body), "Hello") {
t.Errorf("body = %q, want to contain 'Hello'", body)
}
}))
r := newSignedRequest(t, testBody, SHA256)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
}
func TestNewLimitField(t *testing.T) {
x := New(testSecret)
if x.Limit != DefaultLimit {
t.Errorf("Limit = %d, want %d", x.Limit, DefaultLimit)
}
x.Limit = 1 << 20
if x.Limit != 1<<20 {
t.Errorf("Limit = %d, want %d", x.Limit, 1<<20)
}
}
func TestRequireBodyTooLarge(t *testing.T) {
x := New(testSecret)
x.Limit = 5
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called when body exceeds limit")
}))
bigBody := make([]byte, 100)
r := httptest.NewRequest("POST", "/", bytes.NewReader(bigBody))
r.Header.Set(SHA256.Header, Sign(SHA256, testSecret, bigBody))
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
}
if ct := w.Header().Get("Content-Type"); ct != "text/tab-separated-values" {
t.Errorf("Content-Type = %q, want %q", ct, "text/tab-separated-values")
}
if !strings.Contains(w.Body.String(), "error") {
t.Errorf("body = %q, want JSON with 'error' key", w.Body.String())
}
}
func TestErrorTextPlainForBrowser(t *testing.T) {
x := New(testSecret)
handler := x.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called without signature")
}))
r := httptest.NewRequest("POST", "/", bytes.NewReader(testBody))
r.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "text/plain" {
t.Errorf("Content-Type = %q, want %q", ct, "text/plain")
}
if !strings.Contains(w.Body.String(), "missing_signature") {
t.Errorf("body = %q, want TSV with missing_signature", w.Body.String())
}
}
func TestVerifyGitHubTestVector(t *testing.T) {
sig256 := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
if err := Verify(SHA256, testSecret, testBody, sig256); err != nil {
t.Errorf("Verify should pass with GitHub's official test vector: %v", err)
}
mac := hmac.New(sha1.New, []byte(testSecret))
mac.Write(testBody)
sig1 := "sha1=" + hex.EncodeToString(mac.Sum(nil))
if err := Verify(SHA1, testSecret, testBody, sig1); err != nil {
t.Errorf("Verify should pass with SHA1 test vector: %v", err)
}
}