Tidenda

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