TgWitness / Documentation

Reference / Evidence

Independent witness

Run a witness with its own key and bot, publish signed checkpoints to a channel and check exports against it.

Настройка бота и прокси на русском.

A collector signs its own journal, so whoever controls the collector and its key could in principle rewrite or truncate the journal and sign it again. A witness makes that detectable. It is a separate process — another container, or better a server someone else runs — that every few minutes fetches the collector's signed journal head, signs what it saw with its own key, keeps it in its own journal and posts it to a Telegram channel.

Flow diagramCollector → Witness (signed checkpoint: record count + head hash); Witness → Witness journal, own key; Witness → Telegram channel (signed post via its own bot); Evidence ZIP → verify.py; Witness journal, own key → verify.py; Telegram channel → verify.py (Telegram Desktop export or t.me/s page)CollectorWitnessWitness journal, own keyTelegram channelEvidence ZIPverify.pysigned checkpoint: recordcount + head hashsigned post via its own botTelegram Desktop export ort.me/s page
Diagram as text
  1. Collector → Witness (signed checkpoint: record count + head hash)
  2. Witness → Witness journal, own key
  3. Witness → Telegram channel (signed post via its own bot)
  4. Evidence ZIP → verify.py
  5. Witness journal, own key → verify.py
  6. Telegram channel → verify.py (Telegram Desktop export or t.me/s page)

What the witness gets, and what it does not#

The witness has The witness never has
A read-only witness token for one workspace The collector's Telegram session or API credentials
Its own Ed25519 key and journal The collector's vault, signing key or panel token
Its own Telegram bot token Message content: the head is a record count and a hash

The token is created on the panel's Witness page, shown once and stored only as a SHA-256. It opens exactly one endpoint, GET /api/witness/head, which returns a freshly signed checkpoint plus signed content-free collection counters and operational health. It cannot open the panel, read message content or change settings, and the panel token is not accepted there. Revoke a token on the same page. The collector records when a new witness key starts checking (witness.seen) and shows the last check per token; a witness that has not checked within 2.5 intervals is marked overdue.

New witnesses send a random challenge on each poll; the capture statement echoes it under the collector signature. Counters are bound to the exact checkpoint and are included in signed witness posts. Legacy heads remain accepted by the standalone witness. Journal growth alone is never counted as a new message.

What it checks and records#

Every interval (10 minutes by default) the witness:

  1. fetches the head itself (the collector cannot push or skip it);
  2. verifies the collector's signature, the pinned collector key (--collector-fingerprint, or the first key seen), a nondecreasing count and an unchanged head at an unchanged count;
  3. appends a signed record to its own journal: witness.anchor with status ok, key_changed, rollback (fewer records than before), fork (same length, different head) or invalid (the answer failed verification), or witness.no_response when the collector did not answer;
  4. posts the result to the channel, signed, including "no answer" while the witness is running and can reach the Bot API. The post ID is recorded (witness.posted), a failed post too (witness.post_failed).

A post looks like this; the last line carries the signed statement:

TgWitness witness #42 · journal head
Collector 3fa9c2d1e0b4a7f6: 18234 records
Head 9b1f…c07e
2026-10-28T19:40:00.123+00:00
TGW1.eyJhdCI6…<statement>.<signature>

Run a witness#

You need a Telegram bot (create one with @BotFather), a channel where that bot is an administrator, and the collector's address reachable from the witness — over HTTPS outside a private network (see the HTTPS profile in deployment).

Open Extras → A bot in your channel → Set up a bot in the panel. Three steps cover read-only access, your channel and interval, then launching the witness. Publication policy, optional HTTP/SOCKS5 proxy and custom Bot API address are under Advanced settings. The form downloads witness.json with this workspace's collector key pinned. Download the newly created read-only token with Save access file as witness-token.txt; place it and a bot-token.txt created on the witness host beside that JSON. The collector never receives the bot token. Downloading does not start a service or publish a message. No hosted project witness is implicitly enrolled.

python -m tgwitness.witness run --config witness.json --check-config
python -m tgwitness.witness run --config witness.json
python -m tgwitness.witness fingerprint --config witness.json

The first command checks configuration and secret files without network requests or key creation. Paths in JSON are relative to that file; CLI/environment paths are relative to the working directory. Retain the witness fingerprint outside its host. The collector's last-poll indicator confirms a request only; publication is reported by Proof or checked in the witness journal and channel.

If the panel stays behind an SSH tunnel, expose only the scoped witness endpoint on a separate HTTPS hostname with this Caddy example. The default workspace then uses https://witness.example.org, other workspaces use https://witness.example.org/profiles/ID. Tokens are still required; the panel and its other API routes are not forwarded by that virtual host.

Choose all for regularly published checkpoints. A five-minute interval means about 288 posts per workspace per day. changes/problems deliberately leave some checks unpublished. MTProxy is for the collector's Telegram client. Witness HTTP polling and Telegram's HTTPS Bot API use their own direct/HTTP/SOCKS5 route (Bot API).

Next to the collector, with Docker (same host — independent process and key, but the same operator): put the values in .env and start the profile.

TGWITNESS_WITNESS_TOKEN=tgww_...
TGWITNESS_WITNESS_BOT_TOKEN=123456:ABC...
TGWITNESS_WITNESS_CHANNEL=@your_witness_channel
# another workspace: TGWITNESS_WITNESS_COLLECTOR=http://tgwitness:8787/profiles/<workspace ID>

docker compose --profile witness up -d
docker compose logs witness     # prints the witness key fingerprint

With a published image use docker compose --project-directory . -f deploy/compose/compose.image.yaml --profile witness up -d.

On someone else's server (a real independent witness): copy the repository there, set TGWITNESS_WITNESS_COLLECTOR=https://your-collector.example.org plus the three values above in .env, then

docker compose --project-directory . -f deploy/compose/compose.witness.yaml up -d

or without Docker:

python -m tgwitness.witness run --collector https://your-collector.example.org \
    --token-file witness-token.txt --bot-token-file bot-token.txt --channel @your_witness_channel

Options#

Option Environment Default Meaning
--interval TGWITNESS_WITNESS_INTERVAL 10 Minutes between checks, 1–1440
--timeout _TIMEOUT 30 Seconds to wait for the collector and the Bot API, 5–600
--post _POST all all: every check; changes: a new head or a problem; problems: only problems and missing answers
--title _TITLE TgWitness witness First words of every channel post
--collector-fingerprint _COLLECTOR_FINGERPRINT – Pin the collector key instead of trusting the first answer
--proxy _PROXY direct socks5://host:port or http://host:port
--bot-api _BOT_API https://api.telegram.org Bot API address, e.g. a local Bot API server
--data-dir _DATA witness-data Witness key and journal
--config _CONFIG – JSON file with any of the options above

The collector address, tokens and channel come from --collector, --token or --token-file, --bot-token or --bot-token-file and --channel, or _COLLECTOR, _TOKEN, _TOKEN_FILE, _BOT_TOKEN, _BOT_TOKEN_FILE and _CHANNEL, which keeps secrets out of the process list.

A config file uses the option names with underscores, plus texts, which replaces the words of the channel posts:

{"collector": "https://your-collector.example.org", "token_file": "witness-token.txt",
 "bot_token_file": "bot-token.txt", "channel": "@your_witness_channel",
 "interval": 5, "timeout": 30, "post": "changes", "title": "Partner witness",
 "texts": {"ok": "journal head", "no_response": "no answer from the collector", "records": "records"}}

texts keys: ok, key_changed, rollback, fork, invalid, no_response, collector, records, head. The command line wins over the environment, the environment over the file. Unknown options or text keys stop the witness with a message instead of being ignored. Wording never affects verification: verify.py checks the signed token at the end of every post. With --post changes or problems, checks that were not posted are still in the witness journal and verify.py lists them as never posted; the start record names the policy. Every check still appears in the journal, so fewer posts mean fewer independent Telegram times, not fewer checks.

Without a channel the witness still keeps its journal. Keep the witness key fingerprint (python -m tgwitness.witness fingerprint) somewhere else.

Verify#

On the witness side, export its journal:

python -m tgwitness.witness export --out witness.zip
# Docker (add -f deploy/compose/compose.witness.yaml on a witness server):
docker compose exec witness python -m tgwitness.witness export --out /witness/witness.zip
docker compose cp witness:/witness/witness.zip .

Then, anywhere, with the collector's signed ZIP and the channel exported from Telegram Desktop (Export chat history → JSON gives result.json) or its public https://t.me/s/… page:

python verify.py tgwitness-evidence.zip --fingerprint COLLECTOR_SHA256 \
    --witness witness.zip --witness-fingerprint WITNESS_SHA256 --channel result.json

verify.py checks the witness journal (chain and signatures), then that every head the witness saw is part of the exported journal: the record at that count must have exactly that hash. A head that does not match fails verification — the journal was changed after the witness saw it. It also reports heads seen after the export, anomalies the witness recorded, answers that failed, periods whose matching checkpoints are present in this export ("Witnessed from X to Y") and gaps. Later checkpoints do not extend those periods. The result includes the last matched record number and the number of trailing records without a matching checkpoint. Only answers signed by the export's own key count: an export signed by a key the witness never saw is reported as not witnessed, and without --fingerprint its signer stays unpinned. With --channel every signed post must match the witness journal record by record: changed posts fail verification, missing posts are listed. A date in a supplied Telegram JSON export is editable metadata, outside the signed witness token. Matching the token does not authenticate the publication date; the result explicitly reports that publication-time verification was not performed.

Require a witnessed export boundary#

For a handover that must include a witnessed final record, add --require-witness:

python verify.py tgwitness-evidence.zip --fingerprint COLLECTOR_SHA256 \
    --witness witness.zip --witness-fingerprint WITNESS_SHA256 --require-witness

This requires the independently retained witness fingerprint and a matching checkpoint at the exact end of this export. A checkpoint covering only an earlier prefix is insufficient. Pause collection, let the witness observe the final state, and export before further journal writes; if the journal changes, retain another checkpoint and export that state. A later checkpoint alone cannot verify a shorter export's final hash. When --channel is also supplied, missing claimed posts or an empty match set fail this strict check. Without the flag, ordinary integrity checks still report unwitnessed records without rejecting an otherwise intact archive.

The flag enforces consistency with the supplied, pinned witness records. The operator must still establish the witness's independence and preserve its records separately. It does not establish message truth, complete collection or an independently measured runtime.

A witness shows what the collector reported at those times and that later exports agree with it. It does not show that the collector recorded everything Telegram delivered, or that message content is true. A witness run by the same operator on the same machine is weaker than one run by someone else.

An increased count alone cannot establish append-only continuity online: the offline comparison must match each retained prefix hash against the full collector export. Witness downtime and failed publication leave intervals without external anchors. Missing posts can be reported against a retained witness journal; neither a channel nor a server controlled by the same operator is an immutable third-party archive.