Rundunrundun

Feature

Signed delivery

Every webhook delivery is cryptographically signed. Verify authenticity and body integrity in one function. Built on the IETF RFC 9421 standard.

Overview

Webhook signatures prove that a delivery came from Rundun and that the payload wasn't tampered with in transit. Without signature verification, any party who discovers your webhook URL could send fraudulent payloads that trigger actions in your system.

Rundun uses RFC 9421 HTTP Message Signatures (IETF standard, February 2024), with body integrity via companion RFC 9530 Content-Digest. This is a formal standard — not a custom HMAC implementation — which means there are libraries available in every language.

Headers sent on every delivery

Content-Digest: sha-256=:abc123base64hash==:
Signature-Input: sig1=("content-digest" "@method" "@target-uri");created=1716370320;keyid="rundun-key"
Signature: sig1=:base64sighere==:
HeaderPurpose
Content-DigestSHA-256 hash of the raw request body — proves payload integrity
Signature-InputDescribes which components were signed and when (created timestamp)
SignatureHMAC-SHA256 over the listed components using your webhook secret

Verification (Node.js)

import { createHmac, timingSafeEqual } from 'crypto'

function verifyRundunWebhook(req) {
  const secret  = process.env.WEBHOOK_SECRET
  const maxAge  = 5 * 60 * 1000  // reject if older than 5 minutes

  // 1. Parse Signature-Input to get the created timestamp
  const sigInput = req.headers['signature-input'] ?? ''
  const createdMatch = sigInput.match(/created=(\d+)/)
  if (!createdMatch) throw new Error('Missing created timestamp')
  const created = parseInt(createdMatch[1], 10) * 1000
  if (Date.now() - created > maxAge) throw new Error('Signature expired (replay protection)')

  // 2. Verify Content-Digest matches the body
  const body = req.body  // raw Buffer
  const expectedDigest = createHmac('sha256', secret).update(body).digest('base64')
  const digestHeader = (req.headers['content-digest'] ?? '').replace('sha-256=:', '').replace(':', '')
  if (!timingSafeEqual(Buffer.from(expectedDigest), Buffer.from(digestHeader))) {
    throw new Error('Content-Digest mismatch — body tampered')
  }

  // 3. Verify the Signature over the signed components
  const method    = req.method.toUpperCase()
  const targetUri = `https://${req.headers.host}${req.url}`
  const sigBase   = `"content-digest": ${req.headers['content-digest']}\n` +
                    `"@method": ${method}\n` +
                    `"@target-uri": ${targetUri}\n` +
                    `"@signature-params": ${sigInput}`
  const expected = createHmac('sha256', secret).update(sigBase).digest('base64')
  const actual   = (req.headers['signature'] ?? '').replace('sig1=:', '').replace(':', '')
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) {
    throw new Error('Signature invalid')
  }
}

Verification (Python)

import hmac, hashlib, base64, time
from flask import request, abort

def verify_rundun_webhook():
    secret = os.environ['WEBHOOK_SECRET'].encode()
    max_age = 5 * 60  # 5 minutes

    sig_input = request.headers.get('Signature-Input', '')
    created_match = re.search(r'created=(\d+)', sig_input)
    if not created_match:
        abort(400, 'Missing created timestamp')
    created = int(created_match.group(1))
    if time.time() - created > max_age:
        abort(400, 'Signature expired')

    body = request.get_data()
    expected_digest = base64.b64encode(
        hmac.new(secret, body, hashlib.sha256).digest()
    ).decode()
    digest_header = request.headers.get('Content-Digest', '').strip('sha-256=:').strip(':')
    if not hmac.compare_digest(expected_digest, digest_header):
        abort(400, 'Content-Digest mismatch')

    # Full signature verification follows the same pattern as Node.js above

Replay protection

The created timestamp in Signature-Input is the Unix timestamp when the signature was generated. Reject deliveries where created is more than 5 minutes in the past. This prevents an attacker who captured a valid delivery from replaying it later.

Setting your webhook secret

Set a strong random secret when dispatching a run:

{
  "webhook": {
    "url": "https://your-app.com/hooks/rundun",
    "secret": "whsec_your-random-32-char-secret",
    "events": ["run.completed"]
  }
}

Generate a secret with: node -e "console.log('whsec_' + require('crypto').randomBytes(20).toString('hex'))"

Start sending checklists today

No app install required for your executors. Free forever to complete. You pay only when you send.