Verifying signatures
Fetch the public keys and verify snapshot, media and transcript signatures.
On this page
Tidenda signs document snapshots, original media files and verified transcript segments with Ed25519. This guide is for developers who forward or archive that content and need to check it: fetch the public keys, verify each kind of signature, and handle a failure.
Every content signature has the same shape and the same recipe:
import { createPublicKey, verify } from 'node:crypto'
// signature: { alg: 'ed25519', keyId, sig } keys: the trust list's `keys`
export function verifySigned(prefix, bytes, 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([Buffer.from(prefix, 'utf8'), bytes])
return verify(null, signed, publicKey, Buffer.from(signature.sig, 'base64'))
}
Only the prefix and the bytes change between kinds. What each signature proves is explained in Signatures and trust.
Fetch the public keys
curl -s https://<public-api-host>/api/keys
The endpoint needs no credentials. It returns active, the key used
for new signatures, and keys, every key whose signatures should
still verify. Each key has a keyId, an algorithm of ed25519, a
publicKey in base64-encoded SPKI DER, a validFrom and, for keys
that no longer sign, a validUntil. The full response is in the
API reference.
Look keys up by the signature's keyId, never by active: older
content is signed by older keys. Cache the list for about an hour,
and fetch it again immediately when a keyId is not in the cache.
That way a key rotation needs no action.
Verify a document snapshot
A snapshot reaches a receiver in a full-mode webhook as
document.snapshotBytes (base64) and document.snapshotSignature.
The signed bytes are the prefix tidenda-snapshot-v1\0 followed by
the decoded snapshot:
const bytes = Buffer.from(event.document.snapshotBytes, 'base64')
if (!verifySigned('tidenda-snapshot-v1\0', bytes, event.document.snapshotSignature, keys)) {
throw new Error('Snapshot signature does not verify')
}
const snapshot = JSON.parse(bytes.toString('utf8'))
Verify the decoded bytes exactly as received. JSON has no canonical
form, so JSON.stringify(JSON.parse(bytes)) does not reproduce them
and the signature fails. When the content is forwarded, forward these
bytes and the signature together so the next system can repeat the
check.
snapshotSignature is null when there is no snapshot, and also
when the snapshot was stored unsigned. Treat unsigned content as
unverifiable.
The verifier samples do this inside a complete receiver in Node.js, Python and Go.
Verify an image or audio file
Images, audio files and uploaded video clips are signed over their original bytes, exactly as uploaded. Resized or re-encoded copies, such as a clip's standard and preview copies, do not verify.
| Kind | Prefix | Signature found in |
|---|---|---|
| Image | tidenda-image-v1\0 |
meta.signature on core/image; signature in a webhook's embeddedAssets |
| Audio | tidenda-audio-v1\0 |
meta.signature on core/audio; signature in a webhook's embeddedAudioAssets |
| Uploaded clip | tidenda-video-v1\0 |
meta.signature on a core/video with origin: upload |
const ok = verifySigned('tidenda-image-v1\0', originalBytes, asset.signature, keys)
The webhook's readUrl serves the original bytes to an API key of
the same organisation with the publisher:read scope. The contentHash next to the signature
is the lowercase hex SHA-256 of the same bytes, which makes a quick
check that the file is the right one before verifying. A signature
of null means the file was stored unsigned. The media contract is
in Media.
Verify a rendered video
A rendered video is exported in one or more shapes, and each file, a rendition, is signed over its own bytes, exactly as stored.
| Kind | Prefix | Signature found in |
|---|---|---|
| Rendered video | tidenda-video-v1\0 |
meta.renditions[].sig on core/video, one per file |
const ok = verifySigned('tidenda-video-v1\0', fileBytes, rendition.sig, keys)
Verify the file as downloaded. A platform that re-encodes the video on upload, as social platforms do, produces bytes that no longer verify; the signature proves what left the newsroom, not what a platform shows.
Verify a transcript segment
A transcript segment carries its own signature once an editor has
verified it against the recording. The signature sits on the segment
in the core/audio document, at content.segments[i].sig; webhooks
do not include it. To fetch the document, read it with GET /v1/documents?type=core/audio using a
publisher:read key; only usable recordings are returned, so page
through and match on id.
The signed bytes are the prefix tidenda-audio-segment-v1\0 followed
by this JSON string, with the fields in exactly this order and no
whitespace:
{"audioDocId":"<id>","segmentId":"<id>","startMs":0,"endMs":0,"speakerId":null,"text":""}
audioDocId is the core/audio document's id. speakerId is
written as null when the segment has no speaker. Strings are
escaped as JavaScript's JSON.stringify escapes them, which leaves
non-ASCII characters as they are:
function canonicalSegment(audioDocId, seg) {
return '{"audioDocId":' + JSON.stringify(audioDocId) +
',"segmentId":' + JSON.stringify(seg.id) +
',"startMs":' + JSON.stringify(seg.startMs) +
',"endMs":' + JSON.stringify(seg.endMs) +
',"speakerId":' + (typeof seg.speakerId === 'string' ? JSON.stringify(seg.speakerId) : 'null') +
',"text":' + JSON.stringify(seg.text) + '}'
}
const bytes = Buffer.from(canonicalSegment(audio.id, seg), 'utf8')
const ok = verifySigned('tidenda-audio-segment-v1\0', bytes, seg.sig, keys)
Other languages escape some characters differently by default.
Python's json.dumps escapes non-ASCII characters unless given
ensure_ascii=False; Go's encoder escapes <, >, &, U+2028 and
U+2029. Match the JSON.stringify output byte for byte.
Each verified segment also stores signedTextHash, the hex SHA-256
of the text that was signed. If it differs from the hash of the
current text, the segment was edited after verification and its
signature no longer matches.
Handle a failed verification
A signature that does not verify almost always comes from one of these:
- verifying re-serialised JSON instead of the original bytes;
- leaving out the prefix, or its trailing zero byte;
- verifying a derived image or audio file instead of the original;
- looking the key up by
activeinstead of bykeyId; - a cached key list that misses a recent rotation.
If none applies, treat the content as untrustworthy: do not process, forward or publish it, and raise an alert. When the content came in a webhook, still answer the delivery with a 2xx status. Any other answer causes a retry, and the retry carries the same bytes, which fail the same way.
An unknown keyId after a fresh fetch of the key list means the key
was never published by the platform, or was removed from the list.
Either way the signature cannot be verified.