Proof specification
How the Sigelith proof-of-existence layer works — precisely enough to verify it yourself, without trusting us.
protocol: beattime-proof-v1 · last updated October 2026
Sigelith can record that a file existed at a point in time — without ever receiving the file. Only its SHA-256 hash is submitted. Every hash is woven into an append-only hash-chain, batched weekly into a Merkle tree, and the weekly root is signed with our Ed25519 key and anchored outside our infrastructure: in the Bitcoin blockchain (via OpenTimestamps), which anyone can check offline, without us, and in a bank transfer, a dated record kept by a regulated bank — on request we hand you the bank's confirmation for that root, and the bank can confirm it to a court. Cosigners on separate machines countersign the log, and copies of it are kept in public archives.
your file ──SHA-256──▶ digest (64-hex, computed on your device)
│ the file never leaves your device
▼
append-only hash-chain: linkₙ = SHA-256( linkₙ₋₁ ‖ digest ‖ UTC )
│ one ISO week of stamps
▼
Merkle tree (RFC 6962-style) ───────▶ weekly root
│ frozen when the week closes
┌───────────────┼────────────────────┐
▼ ▼ ▼
Ed25519 Bitcoin bank
signature (OpenTimestamps) transfer
1Stamping a hash
The unit of proof is a document's SHA-256 digest, written as 64 lowercase
hexadecimal characters. The digest is computed on your device (in the browser via
Web Crypto, or in the app). Only those 64 characters are sent to POST /api/proof/stamp
— never the file, its name, or its size.
Stamping is idempotent and first-seen: the first time a digest is recorded it is
timestamped; re-submitting the same digest always returns that original timestamp (and HTTP 200
rather than 201). A stamp stores the digest, its @beat and UTC time (to centibeat
precision), its position in the hash-chain, and its ISO week.
2The append-only hash-chain
Stamps form a tamper-evident chain in insertion order. Each new link folds in the previous link, so no earlier entry can be altered, reordered or removed without breaking every link after it:
link₀ = "0000…0000" (GENESIS, 64 zero hex chars)
linkₙ = SHA-256( linkₙ₋₁ ‖ digestₙ ‖ utcₙ )
linkₙ₋₁— the previous stamp's chain value, as a 64-char hex string;digestₙ— this stamp's 64-char hex SHA-256;utcₙ— the stamp's UTC time as an ISO-8601 string (the exact bytes used are returned by the API).
The three strings are concatenated and hashed as UTF-8. The result is this stamp's chain value and becomes the input to the next one.
3Weekly Merkle tree
Time is partitioned into ISO weeks (YYYY-Www, Monday→Sunday; a week
closes the following Monday 00:00 UTC). All stamps in a week, ordered by insertion,
form the leaves of a binary Merkle tree built the RFC 6962 way, with domain
separation between leaves and internal nodes:
leaf = SHA-256( 0x00 ‖ digest_bytes ) // digest_bytes = the 32 raw bytes of the document's SHA-256
node = SHA-256( 0x01 ‖ left ‖ right ) // left, right = 32-byte child hashes
Leaves are paired left-to-right at each level. If a level has an odd number of nodes, the lonely node is carried up unchanged (it is not duplicated) — avoiding the well-known second-preimage ambiguity of duplicate-last-node schemes. The single hash remaining at the top is the weekly root.
While a week is open its root is provisional (recomputed as stamps arrive). When the week is closed the root is frozen and stored; from then on it never changes, and the signing and anchoring steps below apply to that frozen value.
4Signing the weekly root
At closing, the frozen root is signed with an Ed25519 private key (held only in the server environment, never in the repository). The signed message is canonical and binds the week to its root, so a signature cannot be replayed onto a different week or root:
message = "beattime-proof-v1|" + week_key + "|" + root_hex (UTF-8 bytes)
signature = Ed25519-Sign( private_key, message ) (returned base64)
The corresponding public key is published (below and via the API) so anyone can verify the signature independently with any standard Ed25519 library.
Key rotation. A key that has signed a published root is never deleted from the record: when it is replaced it moves to the key history with the date it was retired and the reason. A retired key signs nothing new. Every closed root is then re-signed with the current key — the root itself does not change, and its existence in time is attested independently by Bitcoin and the bank reference (§5), so re-signing moves nothing in time. The API always reports the key that actually produced a given signature, so a signature from an older PDF certificate stays verifiable against the history below.
5External anchoring — Bitcoin & bank reference
The signature proves we attest to the root. The two anchors below give dated records of the root that exist outside our own infrastructure, so the timestamp does not rest on trusting Sigelith:
-
Bitcoin, via OpenTimestamps.
The root is submitted to OpenTimestamps calendars (typically within the hour) and, once it is
included in a Bitcoin block, the
.otsproof upgrades to a Bitcoin attestation. The.otsfile is downloadable and verifiable with the standard OpenTimestamps client against the public Bitcoin blockchain — independent of Sigelith entirely. - A bank transfer. Each closed week's root is also written into the title of a transfer at a regulated bank, which issues a reference number — different for each anchoring — and keeps a dated record of it in a system we do not control. The reference is shown on the public proof page and on each certificate. Unlike Bitcoin, the bank's record is not open to everyone: on request we hand the holder of a proof the bank's confirmation for that root, and the bank can confirm it directly to a court.
6Verifying a proof yourself
Call GET /api/proof/verify?digest=<hex>. For a stamped hash it returns the
timestamp, the weekly root, the inclusion proof, the signature + public key, and the OpenTimestamps /
Bitcoin status. Verification has three independent parts:
- Inclusion — recompute the leaf and walk the proof to the weekly root.
- Signature — check the Ed25519 signature over the canonical message of §4.
- Bitcoin — verify the downloaded
.otswith the OpenTimestamps client.
Inclusion proof
The inclusion_proof is an ordered list of sibling hashes, each tagged with the side it
sits on ("L" = sibling is on the left, "R" = on the right). Start from your
leaf and fold in each sibling; the final value must equal week_root:
h = SHA-256( 0x00 ‖ digest_bytes ) # your leaf
for (side, sibling) in inclusion_proof:
if side == "L": h = SHA-256( 0x01 ‖ sibling ‖ h )
else: h = SHA-256( 0x01 ‖ h ‖ sibling )
assert h == week_root
The Python and
PHP SDKs and the
REST API reference wrap these calls; you can also download a self-contained
PDF certificate for any stamped hash at /api/proof/cert/<digest>.
7What a proof does and does not attest
A confirmed proof attests existence by a point in time and integrity: the exact bytes that hash to that digest existed no later than the recorded time, and have not changed since. It does not attest:
- authorship or ownership — anyone can stamp any hash;
- the truth or meaning of the content;
- that it is a qualified electronic timestamp under eIDAS or any specific legal regime — it is a best-effort, public attestation, provided as-is (see the Terms).
Entries are public and permanent (append-only). Do not stamp a hash you need to keep secret — although the hash reveals nothing about the file, the fact that some file with that hash existed becomes public.
8API & public key
| Endpoint | Purpose |
|---|---|
POST /api/proof/stamp | Record a digest (body: {"digest": "<64-hex>"}). |
GET /api/proof/verify | Look up a digest: timestamp, root, inclusion proof, signature, anchors. |
GET /api/proof/cert/<digest> | Download the PDF certificate for a stamped hash. |
GET /api/proof/ots/<week_key> | Download the week's OpenTimestamps .ots proof. |
GET /api/proof/entries | The whole log, page by page (from, limit, follow next). |
GET /api/proof/checkpoints/latest | The latest signed checkpoint; /api/proof/checkpoints/<n> for any other. |
GET /api/proof/consistency | RFC 9162 consistency proof between the tree sizes of two checkpoints (first, second). |
Full request/response schemas are in the OpenAPI reference. Stamping is rate-limited; verification more generously so.
9Public log & checkpoints
Besides the weekly trees, one global Merkle tree covers the whole log from its first entry, in entry order. Every day, and right after each week closes, Sigelith signs the state of that tree — a checkpoint — and chains it to the previous checkpoint by the SHA-256 of its file. Each checkpoint names a recent Bitcoin block, so it cannot be older than that block, and is itself anchored in Bitcoin through OpenTimestamps. Every week's checkpoints, log dump and page are copied to an immutable release on GitHub.
This lets anyone audit the log itself, not only a single proof: download every entry, recompute the hash chain and every root, and check with an RFC 9162 consistency proof that a later checkpoint extends an earlier one — nothing removed, nothing rewritten. Take the checkpoint from an independent copy rather than from us.
Three times for every entry
GET /api/proof/verify and the PDF certificate give each entry three times. Only the first
one is our word; the other two come from Bitcoin and the bank and bracket the moment the entry must
have been recorded:
- Recorded — Sigelith's server clock, to the microsecond (
time.recorded). - Not earlier than — the Bitcoin block named in the last checkpoint issued
before the entry. The entry lies outside that checkpoint's tree, so it was added later, and
the checkpoint could not exist before its block was mined (
time.not_before). - Not later than — the earliest anchor that covers the entry: the OpenTimestamps
proof of the first checkpoint that contains it, the OpenTimestamps proof of its weekly root, or the
bank transfer for that root (
time.not_after; all of them intime.upper_bounds).
Block times come from block headers, which miners set within about two hours, so the bounds are
accurate to hours, not seconds — and independent of our clock. The response also carries the entry's
path in the global tree to the first checkpoint that contains it (checkpoint: number,
file hash, leaf_index, tree_size, audit_path), so the
certificate plus a checkpoint taken from an independent copy is enough to verify without us.
What the bounds cannot show is how long a request waited before it was recorded. A delay can only make an entry later, never earlier, and the receipt comes back at once with the recorded time, so whoever submits sees a delay immediately. The recorded time itself comes from our server clock, measured every five minutes over NTS against PTB, Germany's national metrology institute (with Netnod as a fallback); stamping stops when the last measurement is older than 15 minutes or the clock is off by more than 0.25 s (how we keep time).
- Checkpoints, one static page per week: /checkpoints/
- Independent copies and the exact formats: github.com/DeiFlagellum/sigelith-log
- The whole log:
GET /api/proof/entries, or weekly dumps at/dumps/<week>.jsonl
10File proofs from backups
Sigelith Backup (3.0+) seals every backup version with one log entry and can later
prove any single file of that version without revealing the others. Format
sigelith-file-proof-v1, file extension .sigelith-proof.
Seal of a backup version
salt_i = HMAC-SHA256( version_seed, path_i ) # 32-byte random seed, kept in the version folder
leaf_i = SHA-256( 0x00 ‖ salt_i ‖ sha256(file_i) ‖ size_i as 8 bytes big-endian ‖ SHA-256(path_i) )
node = SHA-256( 0x01 ‖ left ‖ right ) # leaves in path order, paired level by level;
# an odd last node moves up unchanged
statement = "SIGELITH-BACKUP-SEAL 1\n"
"index-sha256 <hex>\n" # SHA-256 of the version index
"files-root <hex>\n" # root of the tree above
"files <n>\n"
digest = SHA-256( statement ) # the only value sent to the log (§1)
Verifying a file proof
- SHA-256 of the file must equal
file.sha256and its lengthfile.size. Iffile.pathis present, SHA-256 of its UTF-8 bytes must equalleaf.path_sha256. - Compute
leaffromleaf.saltas above and foldmerkle_pathwith the side convention of §6 — the result must equalfiles_root. statementmust readfiles-root <files_root>andfiles <files>.- SHA-256 of
statementmust equalbeatproof.digest, and the embeddedbeatproof-v1must verify as in §6 — inclusion, signature and the key history below.
The salt prevents guessing: the sibling hashes in a proof are indistinguishable from random, so a proof
of one file says nothing about the other files of the backup. What is attested is §7 applied to the
file: a file with exactly these bytes was part of a backup sealed in the given week. Sigelith Desktop
(from 3.0.1) and /verify/ check these proofs; the reference implementation is
fileproof.py in Sigelith Backup.
11For a court expert: checking a stamp without our software
Everything below uses standard tools only — sha256sum, Python 3 with its standard
library, OpenSSL 3 — and public material. Nothing has to be installed from us or taken from us on
trust. Every command was run on entry no. 276 of the public log; the Example boxes let you
repeat each step to the byte. On Windows, Git for Windows ships Bash and OpenSSL (Python may be called
python or py there).
A passed check establishes what §7 says, and no more: the bytes that hash to the digest existed no later than the recorded time and have not changed since. It says nothing about who made the file or whether its content is true.
A. The receipt — the quick check
A receipt (sigelith-receipt-v1) is the log's signed statement “entry seq has this
digest, this time and this chain link”. It comes with every stamp and with
GET /api/proof/verify?digest=<hex>, and Sigelith apps keep it with the proof. Save it as
receipt.json.
# 1. The file's fingerprint must equal "digest" in the receipt
sha256sum document.pdf # Windows: certutil -hashfile document.pdf SHA256
# 2. The signed message: the prefix, then the receipt without "sig" as canonical JSON
# (RFC 8785 — keys sorted, no spaces; for this ASCII-only object Python writes exactly those bytes)
python3 -c 'import json,sys; r=json.load(open("receipt.json")); r.pop("sig"); sys.stdout.write("sigelith-receipt-v1|"+json.dumps(r,sort_keys=True,separators=(",",":")))' > msg.bin
# 3. Key and signature from the receipt. The fixed 16 characters in front of the key turn
# the raw 32-byte Ed25519 key into a standard PEM public key (RFC 8410).
KEY='<"key" from the receipt>'
SIG='<"sig" from the receipt>'
printf -- '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA%s\n-----END PUBLIC KEY-----\n' "$KEY" > pub.pem
printf '%s' "$SIG" | openssl base64 -d -A > sig.bin
openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in msg.bin -sigfile sig.bin
# → Signature Verified Successfully
Change a single character of the message — one second of the time, one digit of the digest — and
OpenSSL answers Signature Verification Failure.
{"v": "sigelith-receipt-v1", "seq": 276,
"digest": "d8e87078f23debeb39c7bb117a23c6cd2aa1125bf474f74a276a0805d497fcfa",
"utc": "2026-10-05T18:22:47.734797Z",
"chain_hash": "15442f9ece6e1a8aaaac254a3faa07c4aa6a319278729b1474a7d9a315067f24",
"key": "e7y9THJIUKvNKOZHmdBjJ8E0bOKyBFVxxMpAJ8w574Y=",
"sig": "aOcjVgSyaSt8kj+FLJP/0dJd1kI3aij42JKB40rCu11zUb0ON/PezJkg4aVV6hmuV3rk8FSlYIrhtBkp3PiuAA=="}
Signed message (one line, no line break at the end):
sigelith-receipt-v1|{"chain_hash":"15442f9ece6e1a8aaaac254a3faa07c4aa6a319278729b1474a7d9a315067f24","digest":"d8e87078f23debeb39c7bb117a23c6cd2aa1125bf474f74a276a0805d497fcfa","key":"e7y9THJIUKvNKOZHmdBjJ8E0bOKyBFVxxMpAJ8w574Y=","seq":276,"utc":"2026-10-05T18:22:47.734797Z","v":"sigelith-receipt-v1"}
B. Is the key ours?
A valid signature shows only that some key signed. Compare key with the key
history, published in places that do not depend on each other: Signing keys on this
page, the README of
github.com/DeiFlagellum/sigelith-log,
and the key field of every checkpoint. A retired key signs nothing after its retirement
date — see the September 2026 incident.
C. Without the receipt and without our server
The receipt is our signature. The log lets you go further, to evidence that does not depend on us:
- The entry. Find the digest in the weekly dump
/dumps/<week>.jsonl(also in the GitHub releases):seq,digest,utc,prev_chain,chain_hash. The chain link isSHA-256(prev_chain ‖ digest ‖ chain_utc), wherechain_utcis the time the way Python'sisoformat()writes it:+00:00instead ofZ, and no fraction when the microseconds are exactly zero (a historical detail, kept unchanged). - The checkpoint. The entry's leaf in the global tree is
SHA-256(0x00 ‖ "beattime-entry-v1|" seq "|" digest "|" utc), withutcas in the receipt. Walk the audit path of RFC 9162 §2.1.3.2 (leaf_index,tree_size,audit_pathfromGET /api/proof/verify, or recomputed from the dumps) up torootof the first checkpoint that contains the entry. Take that checkpoint from an independent copy — GitHub, the Internet Archive, Zenodo — rather than from us; its SHA-256 must equal theprevfield of the next checkpoint. - The checkpoint's signature. As in A.3, with the message
beattime-checkpoint-v1|followed by the checkpoint withoutsigas canonical JSON (the same Python line with that prefix). - Bitcoin. Every checkpoint file has an OpenTimestamps proof
(
<n>.json.ots).ots verify 000014.json.ots— the OpenTimestamps client with a Bitcoin node, or the public verifier at opentimestamps.org — gives, from Bitcoin alone, the latest time at which the checkpoint, and so the entry, existed. The checkpoint issued before the entry names a Bitcoin block; the entry is not in that checkpoint's tree, so it was recorded after that block.
2be7e698c972bae5837c10de6f1c526fdceaa42924d54bd4f727da1140b8269a, file SHA-256
9bc657c7a070d365ff86035f14a13dd71606103dcab6ceba905f85c83aded2f8), signed with the same key.
Checkpoint no. 14 is in Bitcoin block 970104; checkpoint no. 13, issued before the entry, names
block 969921. The entry was therefore recorded between those two blocks — at
2026-10-05T18:22:47.734797Z by our clock.
D. How the recorded time is measured
The recorded time is the server's clock. The server keeps in step with authenticated time servers
(NTS, RFC 8915): two of the Physikalisch-Technische Bundesanstalt (PTB, Germany's national metrology
institute, UTC(PTB)), Netnod (UTC(SP)) and Cloudflare. Independently of that, every five minutes the
service itself measures its offset against PTB over authenticated NTS — an answer forged on the way
fails authentication and is discarded — and turns to Netnod only when both PTB servers are unreachable.
Every measurement is kept. No new entry is recorded — the API answers 503
— while the last successful measurement is older than 15 minutes or the offset exceeds 0.25 s.
The recorded time is therefore accurate to well under a second; the Bitcoin bounds of part C do not
depend on our clock at all, but are accurate only to hours.
№Signing keys
№Cosigners
Our key alone proves only that we signed — not that we were honest about when. A
cosigner is a separate program on a different machine, under a different
account, that reads this public log every minute, checks the hash chain and every entry's time
against its own clock, recomputes the global tree (RFC 9162) itself and signs what
it saw (sigelith-cosign-v1). It has no inbound interface: it pulls the log and pushes
its signature here; nobody can send it anything to sign. This server holds no cosigner key — it
only stores and hands out the cosignatures. On any anomaly — a broken chain, an entry dated before the
cosigner's previous look at the log, an entry from its future — it stops signing until its
operator decides.
Rule: a stamp is trusted when any two of three signatures support it — our log key (the receipt) and the cosigners. Two of the three signers are run by Sigelith (this server and an AWS account); the third by Konveria, an independent company in Kraków with no personal or capital ties to us that also publishes our Android apps on Google Play. Backdating a stamp would take breaking into two separate systems, and would still contradict the published, Bitcoin-anchored checkpoints.
What a cosignature says. It names the tree size and root, the last entry and its
chain hash, the cosigner's time time and its previous observation
since (size and time). Every entry in the tree was in the log no later than
time, by the cosigner's clock. An entry new since since.size was
not in the log at since.time, and the cosigner checked that its recorded time
lies between the two observations (±30 s) — about a minute wide.
Check it yourself. GET /api/proof/cosign?digest=<sha256> gives,
for each cosigner, the earliest signature that includes the entry and the entry's audit path.
(1) Rebuild the message "sigelith-cosign-v1|" + JCS(document without "sig") and
verify the Ed25519 signature with the key above — never with the key inside the document alone.
(2) Recompute the entry's leaf (part 9) and verify the audit path to
root with leaf_index and tree_size (RFC 9162 §2.1.3.2).
The cosigner program is public, with the full protocol and a test vector:
github.com/DeiFlagellum/sigelith-cosigner
(PROTOCOL.md).
№Key incident, September 2026
Until 21 September 2026 the same Ed25519 private key was configured on two machines: this
service and a development mirror. The mirror runs the same scheduled jobs, so it closed
weeks of its own, signed those roots with that shared key and anchored them in Bitcoin
through OpenTimestamps. Two such roots exist, for 2026-W25 and
2026-W30. They are listed below.
The practical consequence is worth stating plainly: for the period that key was in use, a valid signature alone no longer proves a root is ours, because the mirror could produce one too. Nothing about the hashes recorded in the log changed, and no stamp was lost, altered or backdated — but a claim resting on that key's signature and nothing else is weaker than this page previously implied.
If you hold a certificate issued before 21 September 2026: your timestamp stands. The document hash, the inclusion path, the weekly root, the Bitcoin attestation and the bank reference are unchanged and still verify. The certificate names the retired key; download it again (or re-verify the hash) to get the current signature.
How to tell an authoritative root from a rejected one — three checks you can run without trusting anything we say:
- Authoritative roots are the ones published on /proof/, each carried by a bank reference issued by a third party and by a Bitcoin attestation.
- A weekly root frozen before its own week ended cannot be that week's final root.
The rejected
2026-W25root was frozen on the Monday of that week. - Re-verify your own hash against the published root: the inclusion path on your certificate must recompute to it. A root that no stamp of yours leads to is not the root that recorded your document.
Roots we publish in order to reject them — signed with the retired key, never authoritative:
What changed since: the key was rotated on 21 September 2026 and every published root was re-signed with the current one. The retired key is refused by the signing code itself, each machine now pins the public key it is allowed to sign with, the mirror no longer anchors anything, and on 23 September 2026 the two roots above were removed from the mirror's database — after their full contents, including the raw OpenTimestamps proofs, were archived so that this account can be checked rather than believed.
№Naming history
On 27 September 2026 the proof infrastructure described on this page — the log, its checkpoints, certificates and the verification API — took the name Sigelith, with sigelith.org as its address. The operator is the same, and so is the system: sigelith.org and beattime.live are served by one installation, with one log and one signing key.
The announcement below kept BeatTime as the name of the time notation and of the clock apps. Later
the same day the rest of the site followed the proof infrastructure: the whole service is now
Sigelith, the time notation is written simply .beat (a value: @523), and
BeatTime survives only as the store names of the two apps on Google Play,
BeatTime: Universal Clock and BeatTime Watch Face. Pages at beattime.live
redirect to the same address at sigelith.org; the API, proof files and everything else a program
reads answer at both addresses without a redirect, so apps and scripts written for beattime.live
keep working unchanged.
- Changed: the name, the address, and the name of the repository with the weekly
releases —
DeiFlagellum/beattime-logbecameDeiFlagellum/sigelith-logbefore its first release; GitHub redirects the old address. The desktop client BeatStamp became Sigelith Desktop (version 3.0), and its repositoryDeiFlagellum/beatstampbecameDeiFlagellum/sigelith-desktop. Every weekly release, Internet Archive snapshot and Zenodo version is published under the new name, from the first one (week2026-W39). - Not changed, and never will be: the entries and checkpoints of the log, the
signing keys and their history above, the anchors, and the format identifiers inside signed data —
beattime-proof-v1,beattime-entry-v1andbeattime-checkpoint-v1. Those strings are part of the signed bytes; renaming them would break every proof already issued. A future format would get a new identifier, and the old ones would stay verifiable. - Documents issued as BeatTime — certificates, proofs, links — keep verifying exactly as before, at beattime.live and at sigelith.org alike.
The name was announced in
naming-2026-09-27.txt,
recorded in this log as entry #191 on 27 September 2026 at 17:17:27 UTC (@720.45, week
2026-W39). Its SHA-256 is
9b7a539e…c60271
— download the file, hash it yourself and compare.