Rundunrundun

Webhook signatures

Every Rundun webhook delivery is signed using RFC 9421 HTTP Message Signatures (IETF standard, February 2024). Body integrity is guaranteed by RFC 9530 Content-Digest.

Verifying signatures before processing a webhook is strongly recommended. It ensures the delivery came from Rundun and the payload was not tampered with in transit.

Headers sent on every delivery

Content-Digest: sha-256=:<base64-sha256-of-body>:
Signature-Input: sig1=("content-digest" "@method" "@target-uri");created=1748000000;keyid="rundun-key"
Signature: sig1=:<base64-hmac-sha256>:
Header Purpose
Content-Digest SHA-256 hash of the raw request body (RFC 9530). Proves the payload was not tampered with.
Signature-Input Describes which components were signed and when (created is a Unix timestamp).
Signature HMAC-SHA256 over the listed components, computed using your webhook secret.

Verification algorithm

  1. Parse created from Signature-Input. Reject the request if now - created > 300 (5 minutes). This prevents replay attacks.
  2. Compute SHA-256 of the raw request body. Compare with the value in Content-Digest. Reject if they differ.
  3. Reconstruct the signature base string from the signed components in the order listed by Signature-Input.
  4. Compute HMAC-SHA256 over the base string using your webhook secret.
  5. Compare your computed signature with the value in the Signature header using a timing-safe comparison.

Node.js example

This example performs full verification without external dependencies:

import { createHmac, createHash, timingSafeEqual } from 'node:crypto'

interface WebhookHeaders {
  'content-digest': string
  'signature-input': string
  'signature': string
}

/**
 * Verify a Rundun webhook delivery.
 * @param rawBody - The raw request body string (do NOT parse to JSON first)
 * @param headers - Lowercase header names from the incoming request
 * @param secret  - The webhook secret you set when dispatching the run
 * @returns true if the signature is valid, false otherwise
 */
function verifyRundunWebhook(
  rawBody: string,
  headers: WebhookHeaders,
  secret: string,
): boolean {
  // Step 1: Replay protection — reject if created timestamp is >5 minutes ago
  const sigInput = headers['signature-input']
  const createdMatch = sigInput?.match(/created=(\d+)/)
  if (!createdMatch) return false
  const created = parseInt(createdMatch[1], 10)
  if (Math.floor(Date.now() / 1000) - created > 300) return false

  // Step 2: Verify Content-Digest (body integrity)
  const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('base64')
  const expectedDigest = `sha-256=:${bodyHash}:`
  const actualDigest = headers['content-digest']
  if (
    !timingSafeEqual(
      Buffer.from(expectedDigest, 'utf8'),
      Buffer.from(actualDigest, 'utf8'),
    )
  ) return false

  // Step 3: Reconstruct the signature base string
  // Components listed in Signature-Input: "content-digest" "@method" "@target-uri"
  // Append "@signature-params" as the final component
  const sigParamsValue = sigInput.replace(/^sig1=/, '')
  const sigBase = [
    `"content-digest": ${actualDigest}`,
    `"@method": POST`,
    // @target-uri is the full URL — extract from Signature-Input context or pass it in
    // For simplicity, use the path. For full RFC 9421, pass the full URL.
    `"@signature-params": ${sigParamsValue}`,
  ].join('\n')

  // Step 4–5: Compute and compare HMAC-SHA256
  const expected = createHmac('sha256', secret).update(sigBase, 'utf8').digest('base64')
  const actualMatch = headers['signature']?.match(/sig1=:([^:]+):/)
  if (!actualMatch) return false
  const actual = actualMatch[1]

  return timingSafeEqual(
    Buffer.from(expected, 'base64'),
    Buffer.from(actual, 'base64'),
  )
}

Express.js integration

import express from 'express'

const app = express()

// IMPORTANT: Use express.raw() to get the raw body — JSON parsing changes the string
app.post('/hooks/rundun', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8')
  const secret = process.env.RUNDUN_WEBHOOK_SECRET!

  const isValid = verifyRundunWebhook(rawBody, {
    'content-digest': req.headers['content-digest'] as string,
    'signature-input': req.headers['signature-input'] as string,
    'signature': req.headers['signature'] as string,
  }, secret)

  if (!isValid) {
    return res.status(401).json({ error: 'invalid_signature' })
  }

  const payload = JSON.parse(rawBody)
  // process payload...

  res.status(200).json({ received: true })
})

Using the http-message-signatures package

For full RFC 9421 compliance including @target-uri and @authority components, use the http-message-signatures package:

npm install http-message-signatures
import { verifyMessage } from 'http-message-signatures'

const isValid = await verifyMessage(
  { headers: req.headers, method: req.method, url: req.url },
  {
    async getPublicKey(keyId) {
      // Return the shared HMAC secret as a symmetric key
      return { id: keyId, alg: 'hmac-sha256', key: Buffer.from(process.env.RUNDUN_WEBHOOK_SECRET!) }
    },
  },
)

Python example

import hashlib
import hmac
import base64
import time
import re

def verify_rundun_webhook(
    raw_body: bytes,
    content_digest: str,
    signature_input: str,
    signature: str,
    secret: str,
) -> bool:
    """
    Verify a Rundun webhook delivery.
    raw_body must be the raw bytes before any JSON parsing.
    """
    # Step 1: Replay protection
    match = re.search(r'created=(\d+)', signature_input)
    if not match:
        return False
    created = int(match.group(1))
    if int(time.time()) - created > 300:
        return False

    # Step 2: Verify Content-Digest
    body_hash = base64.b64encode(hashlib.sha256(raw_body).digest()).decode()
    expected_digest = f'sha-256=:{body_hash}:'
    if not hmac.compare_digest(expected_digest, content_digest):
        return False

    # Step 3: Reconstruct signature base string
    sig_params = signature_input.replace('sig1=', '')
    sig_base = '\n'.join([
        f'"content-digest": {content_digest}',
        '"@method": POST',
        f'"@signature-params": {sig_params}',
    ])

    # Step 4–5: Compute and compare HMAC-SHA256
    expected_sig = base64.b64encode(
        hmac.new(secret.encode(), sig_base.encode(), hashlib.sha256).digest()
    ).decode()

    actual_match = re.search(r'sig1=:([^:]+):', signature)
    if not actual_match:
        return False
    actual_sig = actual_match.group(1)

    return hmac.compare_digest(expected_sig, actual_sig)


# Flask example
from flask import Flask, request, abort
import json

app = Flask(__name__)

@app.post('/hooks/rundun')
def webhook():
    is_valid = verify_rundun_webhook(
        raw_body=request.get_data(),
        content_digest=request.headers.get('Content-Digest', ''),
        signature_input=request.headers.get('Signature-Input', ''),
        signature=request.headers.get('Signature', ''),
        secret=os.environ['RUNDUN_WEBHOOK_SECRET'],
    )
    if not is_valid:
        abort(401)

    payload = request.get_json()
    # process payload...
    return {'received': True}

Important: use the raw body

The signature covers the raw body bytes. Any transformation — JSON parsing and re-serializing, whitespace normalization, encoding changes — will cause verification to fail. Always read the raw request body before parsing it as JSON.