Sigelith Handover — specification
Proof of delivery signed with the recipient’s key and completed by a public stamp — precisely enough to implement it, or to verify a package yourself.
format: sigelith-handover-v1 · source: HANDOVER_SPEC.md, published with the source code of Sigelith Desktop
Check a deliverySigelith DesktopProof spec
Status: DRAFT 0.3 (2026-09-28) — accepted by the owner; reference
implementation in progress. Frozen together with its test vectors.
0.3 hardens 0.2 after a threat review and removes every server-side store:
Sigelith's server only stamps, exactly as it does today (§0.3). Decisions
behind this draft: HANDOVER.md sections 8–9 (in Polish). Once frozen,
identifiers and byte layouts in this document MUST NOT change; a new version
gets a new identifier.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be read as in RFC 2119.
0. What this is
A sender S gives a package (one or more files) to a recipient R.
- R can open the package only after signing an answer that accepts it.
- S's proof takes effect only at the moment R becomes able to open the package.
- That moment is public: an ordinary Sigelith stamp of the last key part.
Everything travels between the parties — by e-mail, a shared folder, a USB stick. Sigelith's server does what it already does for everyone: it stamps 32-byte values in a public log. It never stores, forwards or sees a package, an offer or an answer, and it cannot tell that a handover took place.
0.1 The flow
- Send. S's application encrypts the package with a key made of two parts, A and B. Part A goes into the offer, sealed to R's key; part B stays with S. S signs the offer, stamps its digest and sends the package file to R by any channel.
- Check. R's application opens the package file and checks everything it can — both cards, S's signature, the ciphertext hash, part A, the preview — before it asks the person anything.
- Answer. R accepts (a signature approved with Windows Hello) or refuses. R's application stamps the answer's digest and gives R an answer file for S.
- Complete. S's application reads the answer file and stamps part B in the Sigelith log. This stamp is the moment of delivery.
- Open. R's application finds B in the public log by itself, assembles the key, decrypts and checks every hash.
- Prove. Both applications keep an evidence package that anyone can verify without Sigelith.
With an exchange folder (§10.3) steps 1–5 run without anybody handling a file.
0.2 Why it is fair
- S hands no secret to anybody. Only S holds B until it publishes it, and S's application publishes it only against R's signed acceptance.
- R does not have to trust S. The acceptance says it takes effect only when B is in the public log by a deadline. From that moment R can open the package: the log publishes every entry in real time and independent witnesses mirror it (§8). If S never publishes B, the acceptance never takes effect.
- Everything else is arithmetic. A wrong part A is caught before R answers; a wrong ciphertext, content or preview is provable afterwards by anyone, by recomputation (§11.3).
Fair exchange needs a third party. Here that party is only a public log with time: it holds no secret, decides nothing and does not know it is being used.
0.3 Changes
| draft 0.2 | draft 0.3 | why |
|---|---|---|
| relay for ciphertext on Sigelith's server | none; the package travels between the parties | no user files on the server: no hosting of content, no abuse surface, no disk growth |
| mailbox and inbox on the server | none; answer file by any channel, or an exchange folder | the server carries no messages between users |
| encryption key P-256 (HPKE DHKEM) | hybrid ML-KEM-768 + X25519 | packages recorded today stay closed against a future quantum computer |
| one signature encoding | raw ECDSA or a WebAuthn assertion (§2.1) | Windows Hello and passkeys sign in WebAuthn form |
| answers could conflict | one answer per offer; an acceptance that took effect governs | R cannot void a delivery after reading it |
any valid_until |
bounded by the offer | no open-ended acceptances |
| answer stamp optional | R stamps it before releasing the answer (MUST) | a lower bound for the delivery time; long-term validity of signatures |
| binding shown by the application | binding record, signed and stamped (§3.4) | the binding claim demonstrably predates any dispute |
| plain file names | Unicode and file-type rules, Mark of the Web (§4.1) | spoofed names and malicious files |
| offer readable by the transport | sealed envelopes (§10.1) | e-mail and cloud providers see sizes only |
Draft 0.1 (key release by beat-key operators with IBE) was replaced by 0.2 on
the same day; see HANDOVER.md section 8.
0.4 Claims discipline
What a completed handover proves:
- S's key signed an offer to R's key that commits to a package with
container hash
H; the stamp of the offer shows it existed at time T0. - R's key signed an acceptance of that offer, stating that R holds the ciphertext with the committed hash; the stamp of the acceptance shows it existed at time T1.
- Part B was recorded in the Sigelith log at time T, after T1 and no later than the deadline in the acceptance. From T, R had everything needed to open the package: T is the time of delivery.
- With the disclosed container, anyone can check which files the package held.
What it does NOT prove: that a person read or understood the content; who stands behind a key (binding, §3.3, is a precondition, not a feature); that the offer was sent or when it arrived; legal "delivery" in any jurisdiction (§14). Silence proves nothing: an unanswered offer is a status, not evidence. Copy MUST NOT use the name of any postal service ("Einschreiben", "list polecony" etc.) nor the words "qualified" or "registered delivery". Description: "proof of delivery signed with the recipient's key and completed by a public stamp".
0.5 Privacy
- Sigelith's server receives only stamps: the offer digest, the answer digest, part B, the binding record digest and file hashes — 32-byte values indistinguishable from any other stamp.
- Transport channels (e-mail, cloud folders) see sealed envelopes and the ciphertext: sizes, never cards, names or content.
- After delivery, B is public forever. The content stays protected by part A (sealed to R's key with a post-quantum hybrid) and by the ciphertext staying with the parties.
1. Identifiers (new, sigelith- prefix)
| identifier | used for |
|---|---|
sigelith-handover-v1 |
the v field of every object in this document |
sigelith-handover-v1\|card |
card signature and fingerprint |
sigelith-handover-v1\|binding |
binding record signature and digest |
sigelith-handover-v1\|offer |
offer signature and offer digest |
sigelith-handover-v1\|answer |
answer signature and answer digest |
sigelith-handover-v1\|part-a |
commitment to A |
sigelith-handover-v1\|seal-a |
HPKE info prefix when sealing A |
sigelith-handover-v1\|part-b |
commitment to B; search rule of §8 |
sigelith-handover-v1\|content-key |
HKDF info: content key |
sigelith-handover-v1\|container |
content aad |
sigelith-handover-v1\|preview-key |
HKDF info: preview key |
sigelith-handover-v1\|preview |
preview aad |
sigelith-handover-v1\|envelope |
HPKE info for transport envelopes |
sigelith-handover-evidence-v1 |
evidence package (§11) |
sigelith-handover-defect-v1 |
defect proof file (§11.3) |
sigelith-receipt-v1 |
signed stamp receipt (§9.2), usable outside Handover |
Rules:
- Every domain string used in a hash or signature ends with
|, and none is a prefix of another. - The digest of a signed object is SHA-256 of exactly the bytes its
signature covers,
domain ‖ JCS(object without sig). A digest never covers a signature, so signature malleability cannot change it.
The existing log identifiers (beattime-entry-v1, beattime-checkpoint-v1,
beattime-proof-v1) and the seal identifiers (beattime-seal-v1,
beattime-beat-v1) are untouched. Handover adds, it changes nothing.
2. Primitives
| primitive | specification |
|---|---|
| hash | SHA-256 |
| key derivation | HKDF-SHA256 (RFC 5869) |
| canonical JSON | JCS subset (RFC 8785) as in checkpoints (apps/tsa/checkpoint.py): sorted ASCII keys, no whitespace, integers only ( |
| signatures of S and R | ECDSA P-256 with SHA-256 (ES256); public keys validated (on the curve, not the point at infinity); encodings in §2.1 |
| encryption to a party | HPKE (RFC 9180), mode base, single-shot, KEM ML-KEM-768 + X25519 hybrid (as in the IETF HPKE post-quantum draft and cryptography ≥ 50; code point frozen with the test vectors), KDF HKDF-SHA256, AEAD AES-256-GCM. Context is bound through info only (common libraries expose no aad for single-shot HPKE). Public key = ML-KEM-768 encapsulation key (1184 B) ‖ X25519 public key (32 B); ciphertext = enc (1120 B) ‖ AEAD output. |
| content | AES-256-GCM-STREAM exactly as beattime-seal-v1 §5 (SEAL.md): 65536-byte segments, nonce = 7-byte prefix ‖ 4-byte big-endian counter ‖ 1-byte last flag, one 32-byte aad for all segments |
| preview | AES-256-GCM, 12-byte all-zero nonce (the key is used once) |
| binary fields | base64 standard with padding (as beattime-seal-v1), canonical only (decoding and re-encoding gives the same text); digests as lowercase hex |
| fingerprint text | Crockford Base32 |
2.1 Signature encodings
es256—sig= 64-byter‖s(IEEE P1363), base64, over the messagedomain ‖ JCS(object without sig).sMUST be ≤ n/2 (low-S); software keys MUST use RFC 6979 (or hedged) nonces.es256-webauthn— for Windows Hello and passkeys, which sign only in WebAuthn form.sig={"authenticator_data": b64, "client_data_json": b64, "signature": b64 (DER, as produced)}. The verifier checks:typein the client data iswebauthn.get;challengeequals base64url(SHA-256(message)); the rpIdHash equals SHA-256("sigelith.org"); the flags show user presence and user verification; the ECDSA signature verifies overauthenticator_data ‖ SHA-256(client_data_json).originis not checked: outside a browser it is whatever the calling application writes.
The card states which encoding its key uses. On Windows the application signs
through the WebAuthn platform authenticator (webauthn.dll, i.e. Windows
Hello). Tested on 2026-09-28 on Windows 11 (API version 9, Intel PTT TPM) with
desktop/tools/winhello_probe.py: a desktop application may create a key for
rpId sigelith.org, every signature carries user verification, and a card and
an acceptance signed this way pass these rules unchanged. Where Windows Hello
is unavailable, a software key (es256, sig_storage: software) is the
fallback, and the application says so.
2.2 Why these algorithms
- Signatures: P-256. The signing key SHOULD live in secure hardware and require user verification. Windows TPM 2.0, Android Keystore/StrongBox, Apple Secure Enclave and passkeys all offer P-256; none reliably offers Ed25519 or a post-quantum signature. No post-quantum signature is needed here: the offer and the answer are stamped, and the stamps are anchored in Bitcoin, so a signature forged by a future quantum computer could not be dated back to before such a computer existed (§13).
- Encryption: hybrid ML-KEM-768 + X25519. An encrypted package recorded today (in a mailbox, on a cloud drive) could be opened by a future quantum computer if part A were sealed with classical elliptic curves only. The hybrid stays closed while either half holds. Its private key cannot live in a TPM (TPMs do not do ML-KEM); it is a software key protected by the operating system's user profile (DPAPI on Windows). Malware running as the user could copy it — but such malware could equally read the decrypted files, so the protection lost is small and the protection gained is long. Handshake v2 keys (Ed25519, software) stay as they are.
3. Identity card ("Sigelith ID")
3.1 Object
{
"v": "sigelith-handover-v1",
"type": "card",
"sig_alg": "es256-webauthn",
"sig_key": "<b64: P-256 public key, SEC1 uncompressed, 65 B>",
"sig_storage": "hardware-uv",
"enc_alg": "hpke-mlkem768x25519-hkdfsha256-aes256gcm",
"enc_key": "<b64: hybrid public key>",
"created": "2026-10-01T12:00:00Z",
"sig": "<signature by sig_key over 'sigelith-handover-v1|card|' ‖ JCS(card without sig)>"
}
- Two keys, never one key for both jobs.
sig_keysigns with user verification (Windows Hello, biometrics) every time — an answer then means "a person approved", not "a process signed".enc_keyhas no user verification: the applications open envelopes automatically. sig_storageishardware-uv(secure hardware with user verification) orsoftware. It is declared by the owner's application and proven only with a platform attestation (a later version); verifiers show it as declared.- The card carries no name, e-mail, address or label. A label is local to each address book.
This is every user's "own seed": the keys are generated on the device and never leave it. The "code" a person enters is the Windows Hello PIN; what comes out is a signature — nobody else can produce it, not even Sigelith, and anybody can check it.
3.2 Fingerprint
fingerprint = SHA-256("sigelith-handover-v1|card|" ‖ JCS(card without sig)) # 32 B
shown as Crockford Base32 of the first 20 bytes, 8 groups of 4
e.g. 7F3A-K5MZ-8PQT-2WXR-9HJD-4NBC-6VYE-1KQM
The card itself (about 2 KB) travels as a file or text by any channel. The QR code shows only the fingerprint, which keeps it small enough to scan from a screen; the application checks the received card against the scanned fingerprint.
3.3 Binding a card to a person — precondition
A signature proves "somebody holding this key", nothing more, until the card is bound to a person in a way the person cannot later deny. Accepted bindings, strongest first:
- exchanged in person — fingerprint QR scanned face to face (NFC later);
- named in a document signed by the person (the fingerprint in the text);
- a Sigelith handshake between the two devices (v3, when available);
- compared by voice on a phone or video call — at least the first 4 groups (80 bits) read aloud;
- received over a channel the person already controls (e-mail) — weakest.
Why at least 4 groups: an attacker who substitutes a card can generate keys until the part a person compares matches. Against 80 bits that is out of reach; against a 6-digit code it takes seconds.
The application MUST show the binding level of every card, MUST NOT present an unbound card as verified, and MUST show R the binding level of S's card next to every offer.
3.4 Binding record
{
"v": "sigelith-handover-v1",
"type": "binding",
"by": "<hex fingerprint of the party recording the binding>",
"card": "<hex fingerprint of the bound card>",
"level": 1,
"method": "qr-in-person",
"date": "2026-10-01",
"note": "…",
"sig": "<signature by the recorder's sig_key over 'sigelith-handover-v1|binding|' ‖ JCS(record without sig)>"
}
method is one of qr-in-person, signed-document, handshake, voice,
channel. The application records it when a contact is verified and stamps
its digest (SHOULD). The stamp shows that the binding was claimed before the
delivery — not invented once a dispute began.
3.5 Card changes
When a known contact presents a different card, the application MUST warn and MUST require a new binding before it sends to or accepts from that card. A later version MAY let an old key vouch for its successor.
3.6 Hardware attestation (optional companion)
When the platform attests the signing key — Windows Hello returns TPM
attestation (format tpm, chain to "Microsoft TPM Root Certificate Authority
2014") — the application keeps it as a companion of the card:
{
"v": "sigelith-handover-v1",
"type": "attestation",
"card": "<hex fingerprint of the card>",
"fmt": "tpm",
"attestation_object": "<b64: CBOR attestation object returned at key creation>",
"client_data_json": "<b64: client data of the key creation>"
}
- Separate, not inside the card. The card stays small, its format and fingerprint do not depend on the platform, and two cards made on the same computer cannot be linked through the attestation certificate unless their owner shows it.
- Only at key creation. The platform returns the attestation once, when the key is created; the application MUST save it then.
- Shared by choice. The application attaches it by default when its owner hands out the card and when the evidence of a handover is assembled; the owner can turn this off.
- Verification. Anybody can check it at any time, offline. A valid
attestation turns
sig_storage: hardware-uvfrom declared into proven, and the verifier names the TPM vendor and model from the certificate. Rules, in this order (each failure has the code in brackets): 1. The companion has exactly the fields above,vandtypeas shown,cardin hex and both blobs in canonical base64 [attestation-structure];fmtistpm[attestation-unsupported];cardis the fingerprint of the card being checked [attestation-card]. 2.client_data_jsonis JSON (no duplicate keys) of typewebauthn.create[attestation-structure]. 3. The attestation object is strict CBOR — definite and minimal lengths, no duplicate map keys, no tags, no floats, nothing after the item — with exactlyfmt(equal to the companion's),attStmtandauthData[attestation-structure]. 4. authenticatorData: rpIdHash = SHA-256("sigelith.org"), flags UP, UV and AT, the credential is a COSE ES256 P-256 key with exactly the keys 1, 3, -1, -2, -3, an extension map only when the ED flag is set, nothing after it [attestation-structure]; the credential equals the card'ssig_key[attestation-card]. 5.attStmthas exactlyver("2.0"),alg,x5c(1–5 certificates),sig,certInfo,pubArea[attestation-structure];algis -65535 (RS1 — what most TPMs under Windows use), -257 (RS256) or -7 (ES256) [attestation-unsupported]. 6.pubArea(TPMT_PUBLIC) is an ECC NIST P-256 key whose point equals the credential, nameAlg SHA-1/256/384/512, nothing after it;certInfo(TPMS_ATTEST) has magic TPM_GENERATED, type ATTEST_CERTIFY,extraData= H(authenticatorData ‖ SHA-256(client_data_json)) with the hash ofalg, andattested.name= nameAlg ‖ H_nameAlg(pubArea), nothing after it;sigovercertInfoverifies with the AIK key of the kindalgnames [attestation-statement]. 7. The AIK certificate (x5c[0]): X.509 v3, empty subject, a critical subject alternative name naming the TPM manufacturer, model and version (2.23.133.2.1/2/3), extended key usage including tcg-kp-AIKCertificate (2.23.133.8.3), basic constraints present with CA = false, the FIDO AAGUID extension (if present) equal to the authenticator's, no unknown critical extension [attestation-certificate]. 8. The chain: every intermediate is a CA without unknown critical extensions; names link byte for byte; certificate signatures are RSA PKCS#1 v1.5 with SHA-256/384/512 or ECDSA with SHA-256/384 on P-256/384/521 — nothing else, SHA-1 never; it ends in a pinned root (today only "Microsoft TPM Root Certificate Authority 2014", SHA-256870c7a35ceab3d59979f2c6a524042d404cb71518004350925fb2ced79a999da); every certificate and the root were valid at the card'screated[attestation-chain]. Revocation is not checked: the proof must verify offline, for decades.
A card file carries a card and, by its owner's choice, its attestation:
card file = "SIGELITH-CARD-1" 0x0A ‖ JCS({"card": <card>, "attestation": <companion or null>})
The card holds only public keys, so the file has no envelope. The evidence
package (§11.1) lists the attestations of its two cards in attestations
(at most two). An attestation adds a line to the report; its absence or
failure never changes the verdict.
4. Package
4.1 Container (plaintext)
container = JCS(manifest) ‖ 0x0A ‖ file_1 bytes ‖ … ‖ file_n bytes
manifest = {
"v": "sigelith-handover-v1",
"type": "manifest",
"salt": "<b64: 16 random bytes>",
"title": "Aneks do umowy najmu",
"note": "…",
"files": [{"name": "aneks.pdf", "size": 123456, "sha256": "<hex>", "type": "application/pdf"}, …]
}
H = SHA-256(container)
- One package MAY carry several files (v1: at most 1000); one answer covers
all of them.
title≤ 200 andnote≤ 2000 characters, both optional. - The
saltmakesHunguessable even when the files are known, soHmay appear in the offer. - JCS escapes line breaks inside strings, so the first
0x0Aends the manifest. The byte count after it MUST equal the sum ofsize.
Text and file-name rules — writers MUST produce, readers MUST reject otherwise:
- all strings in NFC; no control characters (a line feed is allowed in
noteonly); no bidirectional marks, embeddings, overrides or isolates (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069); - file names: none of the characters Windows forbids (
<>:"/\|?*—:would also address a hidden NTFS stream), not.or.., no Windows reserved device name (CON,NUL,COM1…, also with an extension), no trailing dot or space; unique ignoring case (compared after full Unicode uppercase mapping, as case-insensitive file systems compare names).
Readers MUST show full names with extensions, MUST NOT open received files
automatically, MUST mark saved files with the Mark of the Web
(Zone.Identifier) so that Windows treats them as coming from the internet,
and SHOULD warn before opening executable or macro-capable types.
4.2 Keys and commitments
nonce = 16 random bytes # public, in the offer
A = 32 random bytes # travels to R, sealed
B = 32 random bytes # stays with S until completion
K = HKDF-SHA256(ikm = A ‖ B, salt = nonce, info = "sigelith-handover-v1|content-key|", L = 32)
c_A = SHA-256("sigelith-handover-v1|part-a|" ‖ A)
c_B = SHA-256("sigelith-handover-v1|part-b|" ‖ B)
K depends on both parts: A alone or B alone reveals nothing. Because c_A
and c_B fix A and B, the offer fixes K — the ciphertext decrypts to at
most one plaintext, whoever tries. nonce, A and B MUST be fresh for every
offer.
4.3 Content encryption
nonce_prefix = 7 random bytes
aad = SHA-256("sigelith-handover-v1|container|" ‖ nonce)
C = AES-256-GCM-STREAM(K, nonce_prefix, container, aad) # §2
4.4 Preview
What R sees before answering. It is encrypted with a key derived from A, so only R can read it — and R can later prove what it said by revealing A.
preview = {"title": "…", "note": "…", "sender_name": "…",
"files": [{"name": "…", "size": 123, "type": "…"}, …], # at most 50 entries
"file_count": 3, "total_size": 4187234}
k_p = HKDF-SHA256(ikm = A, salt = nonce, info = "sigelith-handover-v1|preview-key|", L = 32)
p_ct = AES-256-GCM(k_p, iv = 12 zero bytes, pt = JCS(preview),
aad = "sigelith-handover-v1|preview|")
The rules of §4.1 apply to the preview. sender_name is what S calls itself;
R's application shows its own label for a known card and marks the
self-chosen name as such. After opening, R's application MUST compare the
preview with the manifest and flag any difference.
5. Offer
{
"v": "sigelith-handover-v1",
"type": "offer",
"nonce": "<b64 16 B>",
"created": "2026-10-01T12:00:00Z",
"expires": "2026-10-31T12:00:00Z",
"complete_within": 1209600,
"sender_card": { "…full card, §3.1…" },
"recipient_card": { "…full card, §3.1…" },
"content": {"sha256": "<hex H>", "size": 4187100},
"ciphertext": {"aead": "AES-256-GCM-STREAM", "segment": 65536,
"nonce_prefix": "<b64 7 B>", "sha256": "<hex>", "size": 4187234},
"part_a": {"commit": "<hex c_A>",
"sealed": "<b64: HPKE(recipient enc_key, info='sigelith-handover-v1|seal-a|' ‖ nonce, pt=A)>"},
"part_b": {"commit": "<hex c_B>"},
"preview": "<b64 p_ct>",
"sig": "<signature by sender sig_key over 'sigelith-handover-v1|offer|' ‖ JCS(offer without sig)>"
}
offer_digest = SHA-256("sigelith-handover-v1|offer|" ‖ JCS(offer without sig))
createdMUST NOT be more than 5 minutes after the log's time when R checks it.expires— R answers before it. Default 30 days aftercreated, at most 90.complete_within— seconds S promises to need, at most, between receiving an acceptance and publishing B. Default 1209600 (14 days — the answer may come by e-mail while S is away); allowed 3600 to 2592000 (1 hour to 30 days).
6. Answer: acceptance or refusal
{
"v": "sigelith-handover-v1",
"type": "answer",
"decision": "accept",
"offer": "<hex offer_digest>",
"ciphertext_sha256": "<hex, recomputed from the bytes R holds>",
"valid_until": "2026-10-15T13:00:00Z",
"signed_at": "2026-10-01T13:00:00Z",
"sig": "<signature by recipient sig_key over 'sigelith-handover-v1|answer|' ‖ JCS(answer without sig)>"
}
answer_digest = SHA-256("sigelith-handover-v1|answer|" ‖ JCS(answer without sig))
decisionisacceptorrefuse. A refusal has nociphertext_sha256and novalid_until.valid_until(D) = the log's current time (e.g. the HTTPDateheader of a log response) +offer.complete_within. It MUST NOT be later thanoffer.expires+offer.complete_within; an answer that breaks this is invalid.signed_atis R's clock, informational only.- Signing requires user verification (§3.1).
- R's application MUST stamp
answer_digestbefore it releases the answer to anybody (T1). S's application stamps it on receipt if it is not yet in the log (stamping is idempotent: the first stamp keeps its time). - One answer per offer. R's application MUST NOT sign a second answer for the same offer. If two answers exist anyway, an acceptance that took effect (§0.4 point 3) governs; otherwise the refusal counts. A hidden refusal cannot undo a delivery R has already been able to read.
Meaning — the application shows this text (translated) before the person approves, and verifiers render it the same way:
- accept: "I confirm that I have received package ⟨offer⟩ from ⟨sender card⟩ and that I hold its encrypted content. This confirmation takes effect at the moment the key part committed in the offer is recorded in the Sigelith log, provided that happens no later than ⟨D⟩. Otherwise it has no effect."
- refuse: "I refuse to accept package ⟨offer⟩ from ⟨sender card⟩."
The signed data is the structured object; the text is its fixed reading, not a free field, so nobody can make a person sign a different sentence. The wording is subject to legal review (§14).
7. Procedure
7.1 S sends
- S chooses R's card from the address book (its binding level is shown), the files, a title and a note.
- S's application stamps the SHA-256 of every file (SHOULD — "the file has its own stamp").
- Build the manifest (fresh
salt), the container andH. Drawnonce, A, B andnonce_prefix; deriveK; encrypt →C; hash it. - Build and encrypt the preview; seal A to R's
enc_key; computec_Aandc_B. - Build the offer; S approves the signature (Windows Hello). Compute
offer_digestand stamp it (SHOULD) → T0. - Write the package file (§10.1) and send it by any channel, or put it in the exchange folder (§10.3).
- Keep B (protected by the operating system), A,
nonce, the files and the pending state until completion or expiry.
7.2 R checks — automatically, before asking the person
- Open the envelope with R's
enc_key(an application with several cards tries each). Check both embedded cards (signatures, fingerprints), the offer signature, thatrecipient_cardis R's own card,created,expires(log time) andcomplete_within. - Open
part_a.sealed→ A; checkc_A. Derivek_p; decrypt the preview; apply §4.1. Any failure: the offer is defective and is never shown as a package to accept. - Check the ciphertext's size and SHA-256 against
offer.ciphertext. - Look up the stamp of
offer_digest(T0), if any. - Show the sender: a known card with its label and binding level, or an unknown card with its fingerprint and a warning.
- Only now show the package — title, note, files with extensions, size, sender and binding, T0 — with Accept, Refuse and Later.
7.3 R answers
- Accept: set
valid_until(§6); the person approves (Windows Hello); stampanswer_digest(MUST, before anything else); write the answer file (§10.1) and send it back by the channel the package came through, or put it in the exchange folder. Keep A,C, the offer and the answer. - Refuse: sign the refusal, stamp its digest, write the answer file,
delete A and
C. - Later / nothing: after
expiresthe application drops the offer.
7.4 S completes — automatically when the answer arrives
The answer arrives as a file S opens, as text S pastes, or in the exchange folder the application watches.
- Open the envelope; check the answer's signature with
recipient_cardof the pending offer whose digest isanswer.offer, and its structure. - Refusal: keep it as evidence, make sure its digest is stamped, mark the offer refused. B is never published.
- Acceptance: check
ciphertext_sha256against the offer and the bound onvalid_until. Check with the log's time thatvalid_untilis at least 1 hour away; otherwise do not publish: the delivery did not happen, and S may send a new offer (§7.6). Never publish B for an offer that was refused. - Make sure
answer_digestis stamped (T1). - Publish B:
POST /api/proof/stamp {"digest": hex(B)}. The recorded time T MUST be later than T1 and no later thanvalid_until; keep the response and its receipt (§9.2). - Build the evidence package (§11.1).
If publishing fails, retry until 1 hour before valid_until. If it never
succeeds, the delivery did not happen; the application tells S that B may
have reached the log operator without being recorded (§13, residual risk 4).
7.5 R opens — automatically
- R's application watches the log (§8) for B while it runs, and on every start while an acceptance is pending. S does not need to send anything.
- Found: check
c_B; T = the time of B's entry. - Derive
K, decryptCsegment by segment (a tag failure stops everything, as inbeattime-seal-v1), checkH, parse the manifest, check every size and hash, compare with the preview, apply §4.1. - Save the files (Mark of the Web, no automatic opening); keep R's evidence copy (§11.2).
- Any failure in step 3: build the defect proof (§11.3) and tell R that the package is defective and the acceptance has no effect.
7.6 Expiry
expirespasses with no answer: S's application shows "not collected". This is a status, not evidence (§0.4).- An acceptance whose
valid_untilpasses without B in the log has no effect. S may send a new offer (newnonce, A and B).
8. Finding B in the log
- The log publishes every entry in real time:
GET /api/proof/entries?from=<seq>(up to 1000 entries per page). Independent witnesses mirror it. - Starting from the last
seqknown when it answered, R's application checks each new entry:SHA-256("sigelith-handover-v1|part-b|" ‖ bytes(digest)) == c_B. - The weekly dumps (
/dumps/, the weekly GitHub release, the quarterly Zenodo deposit) keep the same entries permanently.
R therefore never depends on S, on a transport or on Sigelith's goodwill to obtain B once it is recorded.
9. Sigelith's server: stamps only
9.1 Stamp (existing, unchanged)
POST /api/proof/stamp {"digest": "<64 hex>"}. File hashes, the offer
digest, the answer digest, the binding record digest and part B are ordinary
digests; the log does not know, and cannot tell, what any of them is.
9.2 Stamp receipt — sigelith-receipt-v1 (all stamps)
Every response of POST /api/proof/stamp and every "found" response of
GET /api/proof/verify carries a field receipt (implemented in
apps/tsa/receipt.py):
{
"v": "sigelith-receipt-v1",
"seq": 1234,
"digest": "<hex>",
"utc": "2026-10-01T12:34:56.123456Z",
"chain_hash": "<hex>",
"key": "<b64 Ed25519 log key>",
"sig": "<b64: Ed25519(log key, 'sigelith-receipt-v1|' ‖ JCS(receipt without sig))>"
}
A signed receipt for an entry that the log does not hold at seq with that
chain_hash is provable misbehaviour — like an SCT in Certificate
Transparency, or a signed RFC 3161 token. The field is additive; existing
clients (including the mobile contract) ignore it.
- Key. The log's current Ed25519 key — the one that signs weekly roots and
checkpoints — through the same guard: a retired key, or a key other than the
one the server expects, signs nothing, and the response then has no
receipt. The domainssigelith-receipt-v1|,beattime-proof-v1|(roots) andbeattime-checkpoint-v1|(checkpoints) are disjoint: no signature of one format reads as another. - Deterministic. Ed25519 signatures are deterministic, so one entry always has one receipt; it can be issued on every read, without state.
- Format.
utcalways has six fraction digits andZ(the form of the public entries endpoint);keyandsigare canonical base64 (32 and 64 bytes). - Verification (codes in brackets): exactly the fields above,
v, a positiveseq, hex digests, canonicalutc, canonical base64 [receipt-structure];keyamong the verifier's pinned current log keys — a retired key is never accepted [receipt-key]; the signature [receipt-signature]; when checked for a known digest, the samedigest[receipt-digest]. In an evidence package a log proof counts only with its receipt, and only if itsutc,seqandchain_hashequal the receipt's (§12.1).
9.3 What the server never does
It stores no package, ciphertext, offer, answer, card or contact. It forwards no message between users, sends no notification, keeps no accounts. It never receives part A, a private key, the content key, the plaintext, file names, titles, notes or bindings. Part B reaches it only when S publishes it — public by design.
10. Transport — between the parties
10.1 Files and envelopes
envelope = HPKE(addressee enc_key, info = "sigelith-handover-v1|envelope|", pt = JCS(object))
package file = "SIGELITH-HANDOVER-1" 0x0A ‖ JCS({"kind": "package", "envelope_size": n, "ciphertext_size": m}) 0x0A ‖ envelope(offer) ‖ C
answer file = "SIGELITH-HANDOVER-1" 0x0A ‖ JCS({"kind": "answer", "envelope_size": n}) 0x0A ‖ envelope(answer)
answer text = "sigelith:answer:" ‖ base64url(answer file)
- The envelope hides cards, names and the preview from every channel; the header names no party. An application with several cards tries each.
- The signed objects inside are the same objects that go into the evidence; the envelope only protects them in transit.
- The answer text fits in an e-mail body or a messenger.
10.2 Channels
Any channel the parties already use: e-mail (the application MAY open the default mail client with the file attached; web-mail users attach it or paste the answer text), messengers, a USB stick, in person. For large packages the ciphertext MAY travel separately (S's own cloud link); R's application checks it against the offer like any other copy.
10.3 Exchange folder
Any folder both parties sync — OneDrive, Google Drive, Dropbox, Nextcloud, Syncthing, a network share. S's application writes package files into it; R's application watches it, opens packages addressed to its cards and writes answer files back; S's application watches for answers and completes by itself. The folder's provider sees envelopes and ciphertext only, and it is the parties' provider, chosen and contracted by them.
10.4 Later
Direct transfer between the applications over Tor onion services (both online at once; blocked on many company networks) — a later version.
11. Evidence
11.1 Sender's evidence package
One ZIP file, identifier sigelith-handover-evidence-v1:
evidence.json(canonical JSON) with exactly:v,offer,answer,part_b(hex, or null without an acceptance),binding(or null),attestations(§3.6, at most two, may be empty),log(digest → the/api/proof/verifypayload, as received, with its receipt) for the offer digest, the answer digest, part B, the binding record digest and the files' own stamps, anddisclosure("container.bin"or null);container.bin(optional disclosure): the exact container bytes — lets anyone recomputeHand read the files.
Nothing else goes into the ZIP. Next to it — never inside — the application MAY save a report (PDF) for a reader who runs no verifier: every check of §12 with its result, in the user's language, the SHA-256 of the ZIP it describes and how to verify that ZIP independently. The report is not evidence; the ZIP is.
Every log proof MUST carry its receipt (§9.2): a proof without one does not
count (§12.1). GET /api/proof/verify issues the receipt for every entry, old
ones included, so the application refreshes such a proof before it builds the
package. Applications SHOULD also check log proofs against checkpoints taken
from an independent copy (GitHub, Internet Archive, Zenodo, a witness) and
show the result — an additional level, not a condition of the verdict: a fresh
package has its receipts at once, a checkpoint only after a day. The
application SHOULD refresh the package once the week has closed and its
Bitcoin anchor is confirmed, so that it verifies without Sigelith's server. Sigelith keeps no copy of any
evidence: the application MUST tell the user to keep the file safe.
Readers of an evidence ZIP MUST reject absolute paths, .. segments and
duplicate names (no writing outside the chosen folder).
11.2 Recipient's copy
R's application keeps the same package from its side. It proves when the package became readable — useful to R when deadlines run from delivery: T cannot be earlier than R's own stamped acceptance.
11.3 Defect proof
{offer, A, B, C}. Anybody recomputes c_A, c_B and the ciphertext hash,
derives K and decrypts. A tag failure, a wrong H, a manifest that does not
match its files, or a preview that differs from the manifest shows the offer
was defective: the acceptance has no effect. A false claim fails the same
recomputation, so R cannot void a good delivery.
R's application keeps A, B and C of a package that failed step 3 of §7.5 and
writes them, on request, as one ZIP file, identifier
sigelith-handover-defect-v1, read under the rules of §11.1 (only the two
names below, nothing extracted):
defect.json(canonical JSON) with exactly:v,offer,part_aandpart_b(both hex);ciphertext.bin: the ciphertext exactly as received (stored, not compressed).
Verdicts: offer defective (with the code of the first failure),
not defective (the package opens with the committed parts and matches its
preview — the claim is unfounded), or invalid claim (A, B or C are not
the ones the offer commits to, the offer does not verify, or defect.json
breaks this format — the file proves nothing about the offer). A file that
is not a ZIP of exactly these two names is rejected before any verdict.
The file discloses A and B: whoever holds it can read the preview and the content as far as it decrypts. The application MUST say so before it saves the file. The web verifier reads files up to 512 MiB of content; Sigelith Desktop has no such limit (ZIP64).
12. Verification
The verifier (the web page /handover/verify/, Sigelith Desktop) reports each
check separately. Every time it uses — T0, T1, T, the binding's stamp — comes
from a log proof accepted under §12.1:
- Cards: structure, signatures (§2.1), fingerprints, key validation.
- Binding record: signature by the recorder, level, and whether its stamp predates the offer.
- Offer: structure, limits, §4.1 rules on what is disclosed, signature by
sender_card. - Answer: signature by
recipient_cardof that offer;answer.offerequalsoffer_digest;valid_untilwithin its bound; its stamp T1. - Acceptance:
ciphertext_sha256equalsoffer.ciphertext.sha256;c_Bmatchespart_b; B is in the log at time T; T1 < T ≤valid_until. - Conflicting answers: resolved by §6. The other party's package of the same offer adds its answer and its log proofs; two accepted proofs that give one digest different times are two signed receipts contradicting each other — evidence against the log, and the time does not count.
- Attestations (§3.6): each is matched to the sender's or the recipient's
card and verified; a valid one is reported as "TPM
". They never change the verdict. - Disclosure (optional):
Hof the container; manifest sizes and hashes; the files' own stamps.
Verdict: delivered at T, refused (at the time of the refusal's
stamp), not completed, or offer defective (§11.3). Both verifiers
tell the two files apart by their names inside the ZIP (evidence.json or
defect.json) and read a defect proof by §11.3 — arithmetic only, without
the log.
12.1 Accepting a time from a log proof
A log proof in the package is the text of a GET /api/proof/verify answer.
Nobody signed that text except for its receipt, so whoever brings the package
could change any other field. A digest has a time only if log[hex(digest)]
passes, in this order (codes in brackets):
- It is a string holding a JSON object without duplicate keys or fractions,
with
found: trueanddigestequal to the digest [log-structure]. - It carries
receipt[receipt-missing], valid under §9.2 for that digest with the verifier's pinned current log keys [receipt-*], and itsutc,seqandchain_hashequal the receipt's [log-mismatch]. weekis a real ISO week (YYYY-Www) that contains the receipt'sutc, from 5 minutes before its start (a stamp racing the week's closing lands in the next open week) to its end [log-week].week_rootandinclusion_proofcome together or not at all; when present, the path — at most 64 steps of exactly{"side": "L"|"R", "hash"}— folds the digest to the root: leafSHA-256(0x00 ‖ digest), nodeSHA-256(0x01 ‖ left ‖ right)[log-structure,log-inclusion].root_signature, when present, needsweek_root; itspublic_keyis a pinned current log key [log-key] and it verifies as Ed25519 overbeattime-proof-v1|<week>|<week_root>[log-signature].week_closed, when present, is a boolean; a closed week has its root, path and signature [log-incomplete].
A field set to null counts as absent. Every other field (beat, time,
checkpoint, anchors…) is ignored. The accepted time is the receipt's utc.
The proof's level is reported beside the time and never changes the verdict:
receipt; signed (rules 4 and 5 passed); anchored (signed, and the log
declares a Bitcoin anchor or a confirmed bank anchor of this root — a
declaration the verifier does not check).
13. Security analysis
| # | who | tries to | result |
|---|---|---|---|
| 1 | R | read without accepting | impossible: needs B, which only S holds until publication |
| 2 | R | accept, read, then void the delivery with a hidden refusal | the acceptance that took effect governs (§6) |
| 3 | R | accept with a deadline S cannot meet | S publishes only with at least 1 hour left |
| 4 | R | accept with an open-ended deadline | invalid: valid_until is bounded by the offer |
| 5 | R | deny the acceptance | signed with user verification and stamped at T1 |
| 6 | R | claim a defect that is not there | the recomputation of §11.3 fails |
| 7 | S | send a package R cannot open (bad part A, bad preview) | caught before R is asked; nothing is signed |
| 8 | S | send a bad ciphertext or content | publicly provable defect; the acceptance has no effect |
| 9 | S | use the acceptance without publishing B | no effect without B in the log by D |
| 10 | S | publish B after D | no effect |
| 11 | S | make the delivery look earlier than it was | T cannot precede R's stamped acceptance; the log cannot be rewritten (hash chain, witnesses, Bitcoin anchors) |
| 12 | S | mislead with the preview or file names | provable by revealing A; §4.1 rules reject spoofing characters |
| 13 | S | deliver malware | received files are untrusted: Mark of the Web, no automatic opening, warnings |
| 14 | anybody | substitute a card in transit | binding levels, fingerprint comparison of at least 80 bits, card-change warnings, stamped binding records |
| 15 | e-mail or cloud provider | read or profile | sealed envelopes: sizes only; the content needs A |
| 16 | a future quantum computer | open packages recorded today | part A sealed with ML-KEM-768 + X25519 |
| 17 | a future quantum computer | forge old signatures | offer and answer digests stamped and anchored before such a machine exists |
| 18 | Sigelith | read, forge or change a package | never receives A, a private key or content |
| 19 | Sigelith | hide B from R | public entries endpoint, independent witnesses, weekly dumps |
| 20 | Sigelith (log) with R | drop S's stamp of B and pass B to R | residual risk 4 |
| 21 | anybody | use Sigelith's server to store or distribute files | nothing to use: it stores 32-byte stamps only |
Residual risks — present in every design of this kind; each MUST be stated in the product's documentation:
- Binding. Who stands behind a card is outside the protocol (§3.3).
- Endpoint compromise. A compromised device can show one thing and sign another; malware running as the user can use the keys and read the files. Hardware keys stop key theft, not a lying screen.
- Implementation. The rules of §1, §2, §4.1 and §11.1 (canonical JSON, signatures, names, archives) are where real systems fail — including the Windows Hello signing path (§2.1), to be verified on real hardware before freezing.
- The log records what it receives — the same trust as any stamp. With receipts (§9.2) a false promise is provable; an outright refusal to record is visible to S (no entry) but not provable.
- Liveness. S's application must receive the answer and act: at once with an exchange folder, otherwise when S opens the answer. A delay never falsifies the result.
- B is public forever. After delivery the content is protected by part A (post-quantum hybrid, sealed to R) and by the ciphertext staying with the parties.
- Hardware claims need attestation.
sig_storageis declared, not proven, unless the card's owner shares its attestation (§3.6). Windows Hello provides TPM attestation; other platforms may not. - Evidence custody. Sigelith keeps no copy; a lost evidence package is a lost proof (the log holds digests only).
14. Legal notes (non-normative)
- What Sigelith operates for Handover: the existing stamp log, unchanged. It does not store, forward or learn about packages, offers or answers, sends no notifications and keeps no accounts. The parties transmit the data themselves, over channels they choose.
- What Sigelith supplies: software that runs on the parties' devices, open source (Apache-2.0), with a verifier anybody can run.
- Questions for counsel before launch: 1. Handover as designed is not an electronic registered delivery service (eIDAS art. 3(36)) provided by Sigelith — confirm. 2. Is the stamp log itself a (non-qualified) trust service — electronic time stamps — and what follows (eIDAS art. 19a; NIS2, which covers trust service providers regardless of size)? This concerns the log as it runs today. 3. Cyber Resilience Act: reporting of actively exploited vulnerabilities applies from 11 September 2026 to products made available in the course of a commercial activity — do Sigelith's applications qualify? 4. Wording and effect of the acceptance: when a declaration reaches its addressee (BGB § 130, KC art. 61); weight of a non-qualified time stamp in court (eIDAS art. 41). 5. Terms of use and the limitation of liability for a free feature. 6. Product claims (§0.4).
15. Test vectors
A reference implementation in Python (Sigelith Desktop, desktop/beatstamp/handover/;
the server needs none, it only stamps) generates the vectors with
desktop/tools/handover_vectors.py, frozen in
desktop/tests/vectors/handover-v1.json: card
(both signature encodings), binding record, container, keys and commitments,
preview, sealed part A (with a fixed HPKE ephemeral key for the vectors
only), offer, envelopes and files, acceptance, refusal, the §8 search rule, an
evidence package and defect proofs — the claims and their
sigelith-handover-defect-v1 files (defect_files: a defective, a good and
a false one, and one broken file per rule of §11.3) — plus a deliberately
broken variant for every check of §7.2, §7.4 and §12. A second, independent implementation — the
JavaScript verifier apps/web/static/web/handover/verify.js (verification only:
it never opens an HPKE envelope) — MUST pass the same vectors
(robocze/test_handover_js.py, also run by the desktop test suite). Cases that
need no HPKE randomness are added with handover_vectors.py --extend, without
touching the frozen bytes.
Attestation vectors (§3.6) use a synthetic chain under a test root
("Sigelith TEST TPM Root", passed to verifiers explicitly; the pinned
production root must reject it): valid RS1, RS256 and root-in-x5c cases plus
one broken case per rule of §3.6. A real Windows Hello attestation is never
committed — its certificate identifies a computer. It is checked by hand with
desktop/tools/winhello_probe.py --save <file> and both verifiers (verified
on 2026-09-28: Intel PTT, RS1, Microsoft TPM Root Certificate Authority 2014).
Receipt and log-proof vectors (§9.2, §12.1) are signed with a test log
key (seed in the vectors; the pinned production key must reject them): one
case per rule of §12.1, the ISO-week edge cases (week 53, week 00, year 0000)
and two evidence packages carrying real /api/proof/verify answers — a
delivery with disclosed content and a refusal (the recipient's copy), which
together test the conflict rule of §6. The web verifier page is tested on
the page Django actually renders, with those packages
(robocze/handover_js/test-dom.mjs).
16. Open points for the owner
Decided 2026-09-28: no server-side store at all; hybrid post-quantum
encryption keys from v1; signed stamp receipts for all stamps in v1; defaults
expires 30 days and complete_within 14 days. Still open:
- Legal review (§14) before public launch.
- Hardware attestation. Decided 2026-09-28: the Windows Hello path is the WebAuthn platform authenticator (§2.1), and its TPM attestation travels as a separate, optional companion of the card (§3.6), not inside it. Implementation follows the second (JavaScript) verifier.
- Later versions: direct transfer over Tor, card succession, revocation, platform attestation, binding to a national eID.