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:
- 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.
- 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.
- 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.jsonBytesis 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, soJSON.stringify(JSON.parse(x))is not guaranteed to equalx, 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) carriesdocument.snapshotBytes(base64) anddocument.snapshotSignaturein 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:
- A new signing key becomes active. From this moment on, fresh
snapshots carry the new
keyId. - The new key appears in
GET /api/keys. The old key stays in the list (withvalidUntilset to the rotation moment) so previously signed snapshots keep verifying. - 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
keyscan 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 againstJSON.stringify(JSON.parse(x)). - Forgetting the domain prefix. Verification against
jsonBytesalone will fail. The signed material is"void-snapshot-v1\0" || jsonBytes. - Looking up the wrong key. A snapshot's
keyIddoes not always match the currentactivekey; 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/keysresponse shape. - Verifier samples — working code in Node, Python, Go.