= Taschenrechner+
Taschenrechner+ · Ende-zu-Ende-Verschlüsselung

Signal-Protokoll: X3DH + Double Ratchet

Jede einzelne Nachricht bekommt einen eigenen Schlüssel, der Sekunden später gelöscht wird. Der Server transportiert nur undurchsichtige Blobs. Diese Seite zeigt das nicht nur – du kannst es hier selbst durchspielen.

Every single message gets its own key, wiped seconds later. The server only ever carries opaque blobs. This page doesn't just describe that – you can try it out right here.

Sitzungsaufbau X3DH Extended Triple Diffie-Hellman
Pro Nachricht Double Ratchet neuer Schlüssel, alter verbrannt
Chiffre (AEAD) XChaCha20-Poly1305 Key 32 B · Nonce 24 B · Tag 16 B
Kurven & Hash X25519 · Ed25519 · BLAKE2b alles aus libsodium
1 · Zum Ausprobieren

Tipp etwas ein – und sieh, was das Handy verlässt

Links der Klartext, den nur der Empfänger je sieht. Rechts der Blob, der beim Server landet. Länge und CBOR-Kodierung sind echt gerechnet, genau wie in der App.

Im Klartext verpackt (CBOR)

Dieselbe Struktur trägt auch Bilder, Bestätigungen und Timer-Änderungen – der Server kann die Typen nicht unterscheiden.

Was gesendet wird (messages.payload)

Kopf, Header, Nonce – lesbar, aber signiert Prekey-Teil verschlüsselt

Die Ciphertext-Bytes hier sind Zufallsbytes in exakt der Länge, die die App erzeugt – echte Schlüssel gibt es nur auf dem Gerät. Alles andere ist die wirkliche Kodierung.

The ciphertext bytes shown here are random bytes of exactly the length the app produces – real keys exist only on the device. Everything else is the actual encoding.

Ratchet-Header (40 B) 01 version 1 B 00|01 typ 1 B Prekey-Teil nur bei typ = 01 IK · Sig · EK · ids 168 B dhPk Ratchet-Public-Key 32 B pn alte Kette 4 B n Index 4 B nonce zufällig 24 B Ciphertext XChaCha20-Poly1305(mk, nonce, CBOR, aad) Klartext-Länge + 16 B Poly1305-Tag z. B. 92 + 16 = 108 B Klartext – aber als AAD authentifiziert aad = prefix ‖ AD ‖ header (AD = IK_dh_Alice ‖ IK_dh_Bob, reist nicht mit) verschlüsselt mit Message-Key mk mk existiert nur für diese eine Nachricht Wer am Header dreht, bricht das Poly1305-Tag: der Empfänger verwirft die Nachricht, ohne sie anzuzeigen.
Das Drahtformat im Ganzen. Der Ratchet-Header sagt dem Empfänger, welchen Schlüssel er ableiten muss. Dem Server verrät er nur, dass dies die n-te Nachricht seit dem letzten Schlüsselwechsel ist.
The wire format as a whole. The ratchet header tells the recipient which key to derive. All it tells the server is that this is message number n since the last key change.
2 · Derselbe Chat, zwei Blickwinkel

Links Bobs Handy. Rechts alles, was der Server hat.

Ein Gespräch von sieben Minuten – einmal als Unterhaltung, einmal als Datenbanktabelle. Die Byte-Größen rechts sind aus den tatsächlichen Nachrichten gerechnet.

B Bob verifiziert
Zeit von → an Bytes Was der Server daraus schließen kann
Genau hier steckt das eigentliche Leck: die Länge

Der Ciphertext ist immer Klartext + 16 Byte – kein Auffüllen. Eine Bestätigung sind immer rund 207 Byte, ein kurzes „ok“ etwa 110, ein Foto mit Vorschaubild gut 10 000 – allein an der Größe auseinanderzuhalten. Der Inhalt bleibt geheim, das Muster nicht.

The ciphertext is always plaintext + 16 bytes – no padding. A receipt is always about 207 bytes, a short "ok" roughly 110, a photo with its thumbnail a good 10,000 – all distinguishable by size alone. The content stays secret; the pattern does not.

3 · Der Sitzungsaufbau

X3DH: ein Geheimnis verabreden, während der andere schläft

Das Bild dazu

Bob hinterlegt beim Server einen Stapel offener Vorhängeschlösser – jeder kann eines nehmen und etwas damit zuschließen, aufsperren kann nur Bob. Alice nimmt eines, schließt damit ihr eigenes Schloss zusammen und legt das Paket ins Fach.

Der springende Punkt: Der Server verwaltet die Schlösser, hat aber keinen einzigen Schlüssel. Und jedes Einmal-Schloss gibt er nur ein einziges Mal heraus.

The picture behind it

Bob leaves a stack of open padlocks with the server – anyone may take one and lock something with it, but only Bob can open it. Alice takes one, locks it together with her own, and drops the parcel in the box.

The crucial part: the server keeps the padlocks but holds not a single key. And it hands out each one-time padlock exactly once.

Warum gleich vier DH-Werte?

  • DH1 bindet Alices dauerhafte Identität ein – Bob weiß, dass wirklich sie schreibt.
  • DH2 bindet Bobs Identität ein – Alice weiß, dass nur er es öffnen kann.
  • DH3 bringt Frische: Alices Einmalschlüssel gegen Bobs wechselnden Prekey.
  • DH4 nutzt Bobs Einmal-Prekey. Er macht diese Sitzung selbst dann uneinnehmbar, wenn Bobs Prekey später einmal auffliegt.

Why four DH values?

  • DH1 ties in Alice's long-term identity – Bob knows it really is her writing.
  • DH2 ties in Bob's identity – Alice knows only he can open it.
  • DH3 adds freshness: Alice's one-off key against Bob's rotating prekey.
  • DH4 uses Bob's one-time prekey. It keeps this session closed even if Bob's prekey is exposed later on.
Alice (Handy) Server Bob (offline) hinterlegt: IK, SPK (+Signaturen), 100 × OPK nur öffentliche Schlüssel – die privaten bleiben auf Bobs Handy claim_prekey_bundle(Bob-ID) IK_sign, IK_dh, dhSig, SPK, spkSig, 1 × OPK der OPK wird dabei atomar gelöscht – jeder nur ein einziges Mal prüft beide Signaturen, erzeugt EK DH1…DH4 → HKDF → SK startet damit den Double Ratchet 1. Nachricht, typ = 01 Prekey-Teil: IK_A, EK_A, spkId, opkId Postfach → Bob Bob rechnet SK mit seinen privaten Keys nach
Der Server kann kein SK berechnen – ihm fehlt von jedem Paar die private Hälfte. Und er kann auch keine falschen Schlüssel unterschieben: Die Kontakt-ID ist der Ed25519-Public-Key, mit dem alles signiert ist. Passt die Signatur nicht, verweigert die App den Kontakt.
The server cannot compute SK – it is missing the private half of every pair. Nor can it slip in keys of its own: the contact ID is the Ed25519 public key everything is signed with. If a signature doesn't match, the app refuses the contact.
DH1 = X25519(IK_dh_A, SPK_B)     DH3 = X25519(EK_A, SPK_B)
DH2 = X25519(EK_A,    IK_dh_B)   DH4 = X25519(EK_A, OPK_B)      (falls vorhanden)

SK  = HKDF(salt = 0³², ikm = 0xFF³² ‖ DH1 ‖ DH2 ‖ DH3 ‖ DH4, info = "SEBO-X3DH-v1", 32 B)
AD  = IK_dh_A ‖ IK_dh_B          fließt in jede spätere Nachricht als Kontext ein
DH1 = X25519(IK_dh_A, SPK_B)     DH3 = X25519(EK_A, SPK_B)
DH2 = X25519(EK_A,    IK_dh_B)   DH4 = X25519(EK_A, OPK_B)      (when present)

SK  = HKDF(salt = 0³², ikm = 0xFF³² ‖ DH1 ‖ DH2 ‖ DH3 ‖ DH4, info = "SEBO-X3DH-v1", 32 B)
AD  = IK_dh_A ‖ IK_dh_B          feeds into every later message as context
4 · Zum Ausprobieren

Die Ratsche: dreh sie – zurück geht sie nie

Klick abwechselnd auf „Alice sendet“ und „Bob sendet“ und beobachte, was mit den Schlüsseln passiert. Verbrauchte Schlüssel sind durchgestrichen: Sie sind wirklich weg, überschrieben im Speicher.

Zwei Räder, die ineinandergreifen

Das kleine Rad dreht bei jeder Nachricht: Aus dem Chain-Key entstehen ein Message-Key (für genau diese Nachricht) und der nächste Chain-Key. Rückwärts rechnen ist unmöglich – es ist ein Hash.

Das große Rad dreht bei jedem Richtungswechsel: Wer als Erster zurückschreibt, legt einen neuen X25519-Schlüssel bei, beide rechnen einen neuen Root-Key aus, und eine frische Kette beginnt.

Two wheels, meshed together

The small wheel turns with every message: the chain key yields a message key (for this one message) and the next chain key. Running it backwards is impossible – it is a hash.

The big wheel turns on every change of direction: whoever writes back first attaches a new X25519 key, both sides derive a new root key, and a fresh chain begins.

Was das praktisch bedeutet

  • Handy heute geklaut? Die Nachrichten von gestern bleiben zu – ihre Schlüssel existieren nicht mehr.
  • Schlüssel heute abgegriffen? Nach dem nächsten Richtungswechsel ist der Angreifer wieder ausgesperrt.
  • Nachricht verspätet? Der übersprungene Schlüssel wird aufgehoben (max. 1000 Lücke), einmal benutzt und dann gelöscht – dieselbe Nachricht ein zweites Mal einzuspielen scheitert.

What that means in practice

  • Phone stolen today? Yesterday's messages stay closed – their keys no longer exist.
  • Keys grabbed today? After the next change of direction the attacker is locked out again.
  • Message arrived late? The skipped key is kept (gaps up to 1000), used once and then deleted – replaying the same message a second time fails.
KDF_RK(rk, dh) = HKDF(salt = rk, ikm = dh, info = "SEBO-RATCHET-ROOT-v1", 64 B) → (rk′, ck)
KDF_CK(ck)     = mk  = BLAKE2b(0x01, key = ck)      ← für eine Nachricht, danach überschrieben
                 ck′ = BLAKE2b(0x02, key = ck)      ← für die nächste
KDF_RK(rk, dh) = HKDF(salt = rk, ikm = dh, info = "SEBO-RATCHET-ROOT-v1", 64 B) → (rk′, ck)
KDF_CK(ck)     = mk  = BLAKE2b(0x01, key = ck)      ← for one message, overwritten after use
                 ck′ = BLAKE2b(0x02, key = ck)      ← for the next one
5 · Bilder, Dateien, Sprachnachrichten

Anhänge: die Kiste hier, der Schlüssel dort

Große Dateien gehen nicht durch den Ratchet, sondern getrennt in den Storage – mit einem Einmalschlüssel, der in der Nachricht mitreist.

Foto (JPEG) 412 337 B XChaCha20-Poly1305 key_f, nonce_f zufällig aad = "SEBO-ATTACHMENT-v1" Upload Storage-Bucket „attachments“ Objekt <uuid> · 412 353 B · octet-stream Server sieht nur Größe, Owner, Empfänger, Zeit key_f · nonce_f · BLAKE2b(ct) mime · size · name · w×h · thumb · caption Nachricht (CBOR) t = "att" Double Ratchet mk · XChaCha20-Poly1305 POST /messages Postfach public.messages payload ≈ 10 kB (Vorschaubild inklusive) Empfänger lädt das Objekt, prüft den Digest, entschlüsselt mit key_f und löscht es vom Server. Ungeladene Objekte verfallen nach 14 Tagen.
Zwei Schlösser, ein Schlüsselbund. Wer nur den Storage-Blob hat, kann ihn nicht öffnen. Wer nur die Nachricht hat, hat den Schlüssel, aber nicht die Datei. Der Digest verhindert, dass jemand heimlich ein anderes Objekt unterschiebt.
Two locks, one keyring. Whoever holds only the storage blob cannot open it. Whoever holds only the message has the key but not the file. The digest stops anyone from quietly swapping in a different object.
6 · Die ehrliche Bilanz

Der Brief ist versiegelt. Der Umschlag ist es nicht.

Der Server liest mit bei jeder einzelnen Nachricht von e17a4c52-8f3d-4b6e-9a01-… an 9b2e6d3a-1c4f-4e8b-a7d2-… 20.09.2026, 14:32:07 UTC · 174 Bytes IP 84.•••.•••.12 · Header: n = 7, pn = 0 4 Sekunden später: 207 Bytes zurück (Bestätigung) versiegelt Nur Bobs Handy öffnet das mit dem Message-Key dieser einen Nachricht Inhalt „Hallo Bob, bis 18 Uhr?“ Typ: Text / Bild / Datei / Bestätigung Dateiname, MIME, Maße, Vorschaubild Selbstlösch-Timer, Sendezeit laut Handy Schlüssel zum Anhang im Storage → für den Server: 108 Byte Rauschen
Das ist die Architekturentscheidung eines zentralen Postfachs. Inhalte sind geschützt wie bei Signal. Wer mit wem und wann schreibt, sieht der Server – anders als bei Signals „Sealed Sender“ gibt es hier keine Verschleierung des Absenders.
This is the architectural trade-off of a central mailbox. Content is protected as it is in Signal. Who writes to whom and when is visible to the server – unlike Signal's “sealed sender”, there is no concealment of the sender here.
StarkInhalte
Signal-Design mit libsodium-Primitiven, Forward Secrecy und Break-in Recovery. Ein gestohlener Gerätezustand öffnet weder alte Nachrichten noch – nach dem nächsten Richtungswechsel – neue. Header und Identitäten sind als AAD gebunden, der Server kann nichts umschreiben oder unterschieben.
StarkEchtheit
Die Kontakt-ID ist der Signaturschlüssel selbst. Ein Server, der fremde Schlüssel unterjubeln will, scheitert an der Signaturprüfung – vorausgesetzt, ihr habt die ID auf einem anderen Weg abgeglichen (Sicherheitsnummer in der App).
OffenMetadaten
Wer, mit wem, wann, wie oft, wie groß – plus IP. Über Zeitpunkt und Länge lassen sich Bestätigungen, kurze Texte und Bilder unterscheiden. Auffüllen auf feste Blockgrößen würde das entschärfen.
GrenzeEndgerät
Gegen ein kompromittiertes Betriebssystem, eine mitlesende Tastatur-App oder den Blick über die Schulter hilft keine Verschlüsselung. Die Tarnung als Taschenrechner ist ein Bedienkomfort, kein Sicherheitsmerkmal.
GrenzePrüfstand
Eigene Umsetzung des Signal-Protokolls, nicht libsignal, und bislang ohne externes Audit. Das Design folgt der veröffentlichten Spezifikation; ob die Umsetzung in jedem Randfall trägt, zeigen erst gezielte Tests und ein zweites Augenpaar.
7 · Bausteine

Womit gerechnet wird

Alles aus libsodium. Es gibt keine selbstgeschriebene Krypto-Primitive – nur deren Verdrahtung.

Everything comes from libsodium. No crypto primitive is hand-written here – only the wiring between them.

Verfahren libsodium Größen Wofür
Ed25519crypto_signpk 32 B · sig 64 B Identität (Kontakt-ID = Base32 des Public Keys), signiert DH-Identität und Prekeys
X25519crypto_scalarmultpk 32 B · out 32 B X3DH (DH1–DH4), DH-Ratchet, Prekeys, Ephemeral-Key
XChaCha20-Poly1305aead_xchacha20poly1305key 32 · nonce 24 · tag 16 Nachrichten, Anhänge, lokaler Cache, gespeicherte Server-Session
BLAKE2b (keyed)crypto_generichash32 / 64 B KDF_CK, HKDF, Anhang-Digest, Sicherheitsnummer
Argon2idcrypto_pwhashmoderate · 32 B Code → lokaler Schlüssel (nur auf dem Gerät)

Lebensdauer der Schlüssel

IK_sign, IK_dhdauerhaft – deine Identität
SPK7 Tage, 30 Tage aufbewahrt
OPKgenau eine Sitzung
DHs (Ratchet)bis zum nächsten Richtungswechsel
mkeine Nachricht, dann überschrieben
key_fein Anhang

Und lokal auf dem Gerät?

Code ──Argon2id──▶ K
K    ──secretbox─▶ DEK (32 B zufällig)
DEK ──HKDF("SEBO-KEY-db")──────▶ SQLite
DEK ──HKDF("SEBO-KEY-cache")───▶ Anhang-Cache
DEK ──HKDF("SEBO-KEY-session")─▶ Server-Session
Passcode ──Argon2id──▶ K
K        ──secretbox─▶ DEK (32 random B)
DEK ──HKDF("SEBO-KEY-db")──────▶ SQLite
DEK ──HKDF("SEBO-KEY-cache")───▶ attachment cache
DEK ──HKDF("SEBO-KEY-session")─▶ server session

Ratchet-Zustände, private Schlüssel und Nachrichten liegen ausschließlich in dieser verschlüsselten Datenbank. Kein Code, kein DEK – kein Lesen.

Ratchet states, private keys and messages live only inside this encrypted database. No passcode, no DEK – nothing to read.