Tidenda

Node.js verifier

A complete webhook receiver with both signature checks, in Node.js.

Save as verify-signature.js. Setup and usage are on Verifier samples.

// Verifies a Tidenda webhook delivery end-to-end.
//
//   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  b"tidenda-snapshot-v1\0" || decoded-snapshot-bytes.
//
// Why two signatures? The HMAC proves "this HTTP request came from
// Tidenda". The Ed25519 signature proves "this content came from
// Tidenda and was not modified by any intermediary" - useful if you
// re-publish the snapshot downstream.
//
// Run:
//   TIDENDA_WEBHOOK_SECRET=wh_... \
//   TIDENDA_SIGNING_KEY=<base64-SPKI-DER> \
//   node verify-signature.js
//
// Then POST something to http://localhost:8787/webhook.

import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto'
import { createServer } from 'node:http'

// In real deployments, both of these come from a secret manager. They are
// hardcoded placeholders here so the sample focuses on verification.
const SECRET = process.env.TIDENDA_WEBHOOK_SECRET ?? 'wh_00000000_replace_me'
const SIGNING_KEY_B64 = process.env.TIDENDA_SIGNING_KEY ?? '' // base64 SPKI DER

// 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 §4.
const SNAPSHOT_DOMAIN = Buffer.from('tidenda-snapshot-v1\0', 'utf8')

// Tolerance window for X-Tidenda-Timestamp drift, in seconds. The sender
// re-stamps every retry, so this only catches genuinely stale traffic.
const MAX_CLOCK_SKEW_SECONDS = 300

const server = createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook') {
    res.statusCode = 404
    return res.end()
  }

  // Collect the raw body. Verification MUST run against the bytes that actually
  // came off the wire - re-serialising a parsed JSON object will mangle
  // whitespace and unicode escapes, and the HMAC won't match.
  const chunks = []
  req.on('data', (c) => chunks.push(c))
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks)
    const signatureHeader = req.headers['x-tidenda-signature'] ?? ''
    const timestampHeader = req.headers['x-tidenda-timestamp'] ?? ''

    if (!verifyHmac(rawBody, String(timestampHeader), String(signatureHeader))) {
      res.statusCode = 401
      return res.end()
    }

    // HMAC is good - the bytes can now be trusted enough to JSON.parse them.
    let payload
    try {
      payload = JSON.parse(rawBody.toString('utf8'))
    } catch {
      res.statusCode = 400
      return res.end('invalid json')
    }

    // Optional: verify the snapshot signature if one is attached. Events
    // like `webhook.test` or `document.draft` have null snapshots; nothing
    // to verify in that case.
    //
    // 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.
    const doc = payload?.document
    if (doc && doc.snapshotBytes && doc.snapshotSignature) {
      if (!verifySnapshot(doc.snapshotBytes, doc.snapshotSignature)) {
        console.error(`ALERT: snapshot signature mismatch, event ${payload.id} dropped`)
        res.statusCode = 200
        return res.end()
      }
    }

    res.statusCode = 200
    res.end()
  })
})

function verifyHmac(rawBody, timestamp, header) {
  // The header is `sha256=<lowercase hex>`. Anything else is invalid.
  if (!header.startsWith('sha256=')) return false
  const given = header.slice('sha256='.length)

  // Replay protection: reject if the dispatch timestamp is too far from
  // the local wall clock. The window is symmetric, so clock skew is
  // tolerated in either direction.
  const ts = Number.parseInt(timestamp, 10)
  if (!Number.isFinite(ts)) return false
  if (Math.abs(Date.now() / 1000 - ts) > MAX_CLOCK_SKEW_SECONDS) return false

  // Signed material is exactly `${timestamp}.${raw-body}`. The body is
  // appended as UTF-8 text - the platform sends application/json bodies,
  // which are valid UTF-8 by definition.
  const expected = createHmac('sha256', SECRET)
    .update(`${ts}.${rawBody.toString('utf8')}`)
    .digest('hex')

  // timingSafeEqual requires equal-length buffers, so length-check first.
  // Otherwise it throws and you'd leak that fact via timing.
  if (expected.length !== given.length) return false
  return timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}

function verifySnapshot(snapshotBytesB64, signature) {
  if (!SIGNING_KEY_B64) {
    console.warn('TIDENDA_SIGNING_KEY not set - skipping snapshot signature check')
    return true
  }
  if (signature.alg !== 'ed25519') return false

  // Decode the snapshot to raw bytes. Verify against THESE bytes -
  // not against `JSON.stringify(JSON.parse(bytes))`, which would not be
  // byte-equal (JSON has no canonical form).
  const snapshotBytes = Buffer.from(snapshotBytesB64, 'base64')
  const sig = Buffer.from(signature.sig, 'base64')

  // 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.
  const message = Buffer.concat([SNAPSHOT_DOMAIN, snapshotBytes])

  // Public keys are published as base64-encoded SPKI DER at GET /api/keys.
  // In production you'd fetch /api/keys, cache for ~1h, and look the
  // matching key up by signature.keyId. This sample uses a single hardcoded key.
  const publicKey = createPublicKey({
    key: Buffer.from(SIGNING_KEY_B64, 'base64'),
    format: 'der',
    type: 'spki'
  })

  // The first argument is the digest algorithm. Ed25519 has its own built-in
  // hash (SHA-512 internally), so pass null - Node will route correctly.
  return verify(null, message, publicKey, sig)
}

const port = Number(process.env.PORT ?? 8787)
server.listen(port, () => {
  console.log(`Webhook receiver listening on http://localhost:${port}/webhook`)
})