Tidenda

Guides

Signing and signatures

Ed25519 over snapshot bytes. Why public-key signing, key rotation, verifier recipes.

Signing and signatures

Tidenda uses three distinct cryptographic signatures, each answering a different question:

  1. HMAC-SHA256 on webhook requests — answers "did this HTTP request really come from Tidenda right now?" It's a shared secret. The signed material is the timestamp plus the raw body. See Receiving webhooks for the recipe.
  2. Ed25519 on document snapshots — answers "did this document content come from Tidenda, and is it byte-for-byte what was published?" It's a public-key signature. The signed material is the snapshot JSON. This page covers it.
  3. Ed25519 on image originals — answers the same question for image bytes. Same key, different domain prefix so a snapshot signature can never be replayed as an image signature. See Image signatures below.

Most integrations only need the HMAC. The Ed25519 signature matters when:

  • Distributing content to third parties. A hub that pulls documents from Tidenda and republishes them to publishers wants the publishers to verify content authenticity without trusting the hub.
  • Long-term archival. An archive that holds documents for years wants to verify, when restoring, that the bytes have not been altered in storage.
  • Compliance / audit. A workflow that requires proof that published content came from a controlled source.

If none of those apply, you can stop after HMAC verification.

Why public-key signing?

The HMAC proves the request came from someone who holds the webhook's shared secret. That's enough for a direct receiver, but the moment content is forwarded, the secret can't go with it. A public-key signature can. A consumer downstream of an intermediary verifies the signature against Tidenda's public key — they don't need the hub's trust, they don't need the secret, and Tidenda never has to hand a secret to a third party.

Ed25519 was picked for three reasons. Signatures are short (64 bytes). Verification is fast and constant-time. The algorithm has no known weaknesses against the threat model that matters here (forgery). PKCS8 / SPKI encoding makes the keys readable by most cryptography libraries without custom parsing.

What is signed

The signed byte string is:

"void-snapshot-v1\0" || jsonBytes

where:

  • "void-snapshot-v1\0" is a fixed 17-byte prefix (16 ASCII characters + a NUL byte). It separates this signature from any other signature the same key might produce in the future. Without a prefix like this, a signature over snapshot JSON could be replayed as a signature over something else that happens to share the same bytes.
  • jsonBytes is the exact byte stream of the snapshot JSON as it was produced by the platform. Not a re-serialised parse of it — JSON has no canonical serialisation, so JSON.stringify(JSON.parse(x)) is not guaranteed to equal x, and the signature would not verify.

The signature is Ed25519 over that concatenated buffer.

Where the bytes come from

Snapshot bytes reach an integrator through two channels:

  • Webhook delivery. Each delivery for a snapshotted status (usable / withheld) carries document.snapshotBytes (base64) and document.snapshotSignature in the body. Decode and verify.
  • Public key endpoint, plus webhook bytes. The signature alone isn't enough; you also need a public key to verify against. Public keys live at GET /api/keys.

The trust list: GET /api/keys

This endpoint is public and unauthenticated. It returns the current active key plus every historical key that should still verify.

{
  "active": "void-2026-05",
  "keys": [
    {
      "keyId": "void-2026-05",
      "algorithm": "ed25519",
      "publicKey": "<base64 SPKI DER>",
      "validFrom": "2026-05-12T00:00:00Z",
      "validUntil": null
    },
    {
      "keyId": "void-2025-11",
      "algorithm": "ed25519",
      "publicKey": "<base64 SPKI DER>",
      "validFrom": "2025-11-01T00:00:00Z",
      "validUntil": "2026-05-12T00:00:00Z"
    }
  ]
}

Each delivered signature carries a keyId field. Look up the matching entry in keys and use its publicKey. Historical keys remain in the list (with validUntil set) so snapshots signed before a rotation stay verifiable.

publicKey is base64-encoded SPKI DER — the binary format openssl x509 -pubkey -noout produces and most cryptography libraries accept directly.

Cache the response for around an hour. The keys change rarely, and refetching on every delivery would mean a public unauthenticated call from every receiver against every payload. An hour is a sane default; longer is also fine.

Key rotation

Rotation looks like this from a receiver's perspective:

  1. A new signing key becomes active. From this moment on, fresh snapshots carry the new keyId.
  2. The new key appears in GET /api/keys. The old key stays in the list (with validUntil set to the rotation moment) so previously signed snapshots keep verifying.
  3. Eventually, after the operator decides the old key has aged out (no archived snapshots still need to verify against it), the old key may be removed from the list. Anything signed by a key that's no longer in keys can no longer be verified.

The right cache strategy is: cache the list with a short TTL (an hour), and on keyId not found in cache, refetch immediately. That way a rotation is invisible — a fresh delivery with the new keyId triggers a refetch and verification proceeds.

Verification

The full recipe in three languages.

Node.js

import { createPublicKey, verify } from 'node:crypto'

const DOMAIN = Buffer.from('void-snapshot-v1\0', 'utf8')

export function verifySnapshot(snapshotBytesB64, signature, keys) {
  const bytes = Buffer.from(snapshotBytesB64, 'base64')
  const key = keys.find((k) => k.keyId === signature.keyId)
  if (!key) throw new Error(`Unknown keyId: ${signature.keyId}`)

  const publicKey = createPublicKey({
    key: Buffer.from(key.publicKey, 'base64'),
    format: 'der',
    type: 'spki'
  })

  const signed = Buffer.concat([DOMAIN, bytes])
  const ok = verify(null, signed, publicKey, Buffer.from(signature.sig, 'base64'))
  if (!ok) throw new Error('Snapshot signature does not verify')
  return JSON.parse(bytes.toString('utf8'))
}

Python

import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives import serialization

DOMAIN = b'void-snapshot-v1\x00'

def verify_snapshot(snapshot_b64, signature, keys):
    raw = base64.b64decode(snapshot_b64)
    key = next(k for k in keys if k['keyId'] == signature['keyId'])
    public_key = serialization.load_der_public_key(
        base64.b64decode(key['publicKey'])
    )
    assert isinstance(public_key, Ed25519PublicKey)
    public_key.verify(base64.b64decode(signature['sig']), DOMAIN + raw)
    return json.loads(raw.decode('utf-8'))

Go

package main

import (
    "crypto/ed25519"
    "crypto/x509"
    "encoding/base64"
    "errors"
)

var domain = []byte("void-snapshot-v1\x00")

func verifySnapshot(snapshotBytesB64 string, sigKeyID, sigB64 string, keys map[string]string) ([]byte, error) {
    raw, err := base64.StdEncoding.DecodeString(snapshotBytesB64)
    if err != nil { return nil, err }
    pubB64, ok := keys[sigKeyID]
    if !ok { return nil, errors.New("unknown keyId: " + sigKeyID) }
    pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
    if err != nil { return nil, err }
    pubAny, err := x509.ParsePKIXPublicKey(pubBytes)
    if err != nil { return nil, err }
    pub, ok := pubAny.(ed25519.PublicKey)
    if !ok { return nil, errors.New("not an Ed25519 public key") }
    sig, err := base64.StdEncoding.DecodeString(sigB64)
    if err != nil { return nil, err }
    signed := append(append([]byte{}, domain...), raw...)
    if !ed25519.Verify(pub, signed, sig) {
        return nil, errors.New("signature does not verify")
    }
    return raw, nil
}

Working, fully self-contained versions of all three live under Verifier samples.

Common verification mistakes

  • Re-serialising the JSON before verifying. The signature is over the bytes the platform produced. Verify against the decoded snapshotBytes, not against JSON.stringify(JSON.parse(x)).
  • Forgetting the domain prefix. Verification against jsonBytes alone will fail. The signed material is "void-snapshot-v1\0" || jsonBytes.
  • Looking up the wrong key. A snapshot's keyId does not always match the current active key; older snapshots are signed by older keys.
  • Trusting only the HMAC for re-distribution. The HMAC is receiver-to-Tidenda only. Downstream consumers cannot verify it because they don't have the receiver's secret. Use Ed25519 for forwarded content.

What to do if a signature does not verify

It almost always means one of:

  • The receiver is verifying re-serialised JSON instead of the original bytes.
  • The receiver forgot the domain prefix.
  • The receiver looked up the wrong key.
  • The receiver's cached trust list is stale and missing a rotation.

If none of those apply: stop processing the event and alert. A signature mismatch on a snapshot that arrived through a valid HMAC means the content was modified somewhere between the platform and the receiver, which should never happen.

Image signatures

Image originals carry their own Ed25519 signature using the same key material as document snapshots but a different domain prefix:

"void-image-v1\0" || originalBytes

The signature lives at meta.signature on the core/image document and at images/<id>/original.sig in object storage as a JSON sidecar:

{ "keyId": "void-2026-05", "alg": "ed25519", "sig": "<base64>" }

Verification is structurally identical to the snapshot recipe above — swap the prefix and the bytes:

import { createPublicKey, verify } from 'node:crypto'

const IMAGE_DOMAIN = Buffer.from('void-image-v1\0', 'utf8')

export function verifyImage(originalBytes, signature, keys) {
  const key = keys.find((k) => k.keyId === signature.keyId)
  if (!key) throw new Error(`Unknown keyId: ${signature.keyId}`)
  const publicKey = createPublicKey({
    key: Buffer.from(key.publicKey, 'base64'),
    format: 'der',
    type: 'spki'
  })
  const signed = Buffer.concat([IMAGE_DOMAIN, originalBytes])
  const ok = verify(null, signed, publicKey, Buffer.from(signature.sig, 'base64'))
  if (!ok) throw new Error('Image signature does not verify')
}

What's signed is the original bytes — the user's upload, byte for byte. The derived delivery JPEG carries a different representation and is not what the signature covers; consumers forwarding content with provenance should ship the original bytes plus the signature, not the delivery variant.

Why two domains for one key: snapshot JSON and image bytes are different things; a signature over one must never verify as a signature over the other. The prefix is what enforces that.

Related pages

  • Media — the image contract: upload model, formats, rights, AI provenance, and how the signature fits into the rest of the image's metadata.
  • Receiving webhooks — how snapshots reach receivers, including HMAC verification.
  • API reference — the full GET /api/keys response shape.
  • Verifier samples — working code in Node, Python, Go.