Go verifier
A complete webhook receiver with both signature checks, in Go.
Save as verify-signature.go. Setup and usage are on Verifier samples.
// Verify a Tidenda webhook delivery end-to-end.
//
// Two signatures travel with every delivery:
//
// 1. HMAC-SHA256 over `${X-Tidenda-Timestamp}.${raw-body}` keyed with the
// shared webhook secret. Header: `X-Tidenda-Signature: sha256=<hex>`.
// 2. (When document.snapshotBytes is present) Ed25519 over the byte
// sequence []byte("tidenda-snapshot-v1\x00") || decoded snapshot bytes.
//
// The HMAC proves the HTTP request came from Tidenda. The Ed25519
// signature proves the snapshot content has not been tampered with -
// useful if you re-publish the content downstream.
//
// Run:
//
// TIDENDA_WEBHOOK_SECRET=wh_... \
// TIDENDA_SIGNING_KEY=<base64-SPKI-DER> \
// go run verify-signature.go
//
// Then POST something to http://localhost:8787/webhook.
package main
import (
"crypto/ed25519"
"crypto/hmac"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// The Ed25519 domain-separation prefix. Note the trailing NUL byte - the
// Ed25519 message is exactly these 20 bytes followed by the decoded
// snapshot bytes. See docs/RECEIVING_WEBHOOKS.md section 4.
var snapshotDomain = []byte("tidenda-snapshot-v1\x00")
// Tolerance window for X-Tidenda-Timestamp drift, in seconds.
const maxClockSkewSeconds = 300
// In real deployments these come from a secret manager. Hardcoded
// placeholders here so the sample focuses on verification.
var (
secret = []byte(envOr("TIDENDA_WEBHOOK_SECRET", "wh_00000000_replace_me"))
signingKeyB64 = os.Getenv("TIDENDA_SIGNING_KEY") // base64 SPKI DER
)
type snapshotSignature struct {
Alg string `json:"alg"`
KeyID string `json:"keyId"`
Sig string `json:"sig"`
}
type document struct {
SnapshotBytes string `json:"snapshotBytes"`
SnapshotSignature *snapshotSignature `json:"snapshotSignature"`
}
type webhookPayload struct {
ID string `json:"id"`
Document *document `json:"document"`
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusNotFound)
return
}
// Read the raw body. Verification must run against the bytes that actually
// came off the wire - re-serialising a parsed object will mangle
// whitespace and unicode escapes, and the HMAC won't match.
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
sigHeader := r.Header.Get("X-Tidenda-Signature")
tsHeader := r.Header.Get("X-Tidenda-Timestamp")
if !verifyHMAC(raw, tsHeader, sigHeader) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// HMAC is good - safe to parse the JSON.
var payload webhookPayload
if err := json.Unmarshal(raw, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Optional: verify the snapshot signature when one is attached.
// webhook.test / document.draft etc. carry null snapshots so there
// is nothing to verify in those cases.
//
// On a mismatch, drop the event and alert, but still answer 2xx: any
// other status triggers a retry, and the retry carries the same
// snapshot bytes, which would fail the same way.
if d := payload.Document; d != nil && d.SnapshotBytes != "" && d.SnapshotSignature != nil {
if !verifySnapshot(d.SnapshotBytes, d.SnapshotSignature) {
log.Printf("ALERT: snapshot signature mismatch, event %s dropped", payload.ID)
w.WriteHeader(http.StatusOK)
return
}
}
w.WriteHeader(http.StatusOK)
}
func verifyHMAC(rawBody []byte, timestamp, header string) bool {
// The header is `sha256=<lowercase hex>`. Anything else is invalid.
if !strings.HasPrefix(header, "sha256=") {
return false
}
given := strings.TrimPrefix(header, "sha256=")
// Replay protection: reject when the dispatch timestamp is too far
// from the local wall clock. Symmetric window tolerates skew either way.
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > maxClockSkewSeconds {
return false
}
// Signed material is exactly `${timestamp}.${raw-body}`. The body
// is application/json, which is valid UTF-8 by definition.
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%d.", ts)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal is the constant-time comparator and length-safe.
// Don't use == on the hex strings; it short-circuits.
return hmac.Equal([]byte(expected), []byte(given))
}
func verifySnapshot(snapshotB64 string, sig *snapshotSignature) bool {
if signingKeyB64 == "" {
log.Println("TIDENDA_SIGNING_KEY not set - skipping snapshot signature check")
return true
}
if sig.Alg != "ed25519" {
return false
}
// Decode the snapshot to raw bytes. Verify against THESE bytes -
// not against a re-serialised JSON, which would not be byte-equal.
snapshot, err := base64.StdEncoding.DecodeString(snapshotB64)
if err != nil {
return false
}
sigBytes, err := base64.StdEncoding.DecodeString(sig.Sig)
if err != nil {
return false
}
// Public keys are published as base64-encoded SPKI DER at
// GET /api/keys. In production you would fetch /api/keys, cache
// for ~1h, and look the matching key up by sig.KeyID.
keyDER, err := base64.StdEncoding.DecodeString(signingKeyB64)
if err != nil {
return false
}
parsed, err := x509.ParsePKIXPublicKey(keyDER)
if err != nil {
return false
}
pub, ok := parsed.(ed25519.PublicKey)
if !ok {
return false
}
// The signed message is the 20-byte domain prefix followed by the
// raw snapshot bytes. Domain separation prevents a signature minted
// for one purpose being replayed as another.
message := append([]byte{}, snapshotDomain...)
message = append(message, snapshot...)
return ed25519.Verify(pub, message, sigBytes)
}
func envOr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func main() {
port := envOr("PORT", "8787")
http.HandleFunc("/webhook", handleWebhook)
log.Printf("Webhook receiver listening on http://localhost:%s/webhook", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}