Das Protokoll, aufgeschrieben
Schlüssellängen, KDF-Eingaben, Wire-Format und die Grenzen jeder Konstruktion, aus den Design-Dokumenten, gegen die die App gebaut wird. Was jeder Teil für dich tut, steht auf der Sicherheitsseite.
Jeder Abschnitt nennt die Konstruktion, die die Android-App ausführt, in der Notation der Spezifikation, der sie folgt, und listet danach, wogegen sie nicht schützt. Unter der Notation steht die Funktion, die sie implementiert. Sie wird bei jedem Build dieser Website an den angegebenen Zeilennummern aus der Quelldatei geschnitten, ohne die Kommentare. Wird eine Funktion umbenannt oder entfernt, schlägt der Build fehl, ein veralteter Ausschnitt kann also nicht online bleiben.
Die Labels in den KDF-Aufrufen sind die Domain-Separation-Strings der App und stehen hier wörtlich, damit eine unabhängige Implementierung dieselben Schlüssel ableiten kann.
Identität
Drei Schlüsselpaare und eine ID, die dir niemand zuteilen kann
Eine Identität entsteht beim ersten Start auf dem Telefon: ein X25519-Schlüssel für den Schlüsselaustausch, ein Ed25519-Schlüssel, der jeden veröffentlichten Prekey signiert, und ein ML-KEM-1024-Schlüssel für den Sealed-Sender-Umschlag. Die User-ID ist eine UUID, abgeleitet aus den beiden Public Keys. Die Registrierung wird abgelehnt, wenn die ID nicht zu den Schlüsseln passt oder der Registrierende keinen Nachweis signieren kann, der den Relay nennt. Ein Relay kann also keine ID für Schlüssel ausgeben, die er nicht besitzt.
Neben der Identität veröffentlicht die App Prekeys für alle, die eine Unterhaltung beginnen wollen, während sie offline ist: einen signierten X25519-Prekey, einen Vorrat an einmaligen X25519-Prekeys, einen signierten ML-KEM-1024-Prekey und einen Vorrat an signierten einmaligen ML-KEM-1024-Prekeys. Der Relay speichert Public Keys. Jeder private Schlüssel liegt im Tresor.
identity_key X25519 Schlüsselaustausch
signing_key Ed25519 signiert jeden Prekey dieser Identität
sealed_kem_key ML-KEM-1024 langlebig, Ziel des Sealed-Sender-Umschlags
user_id UUIDv8( SHA-256(identity_pk ‖ signing_pk)[0..16] )
identity_pk = 01 02 … 20, signing_pk = 21 22 … 40 → 20a7ec84-684f-8fe1-a4cb-3727d049734a
prekeys SPK X25519 (signiert) · OPK X25519 · PQSPK ML-KEM-1024 (signiert) · PQOPK ML-KEM-1024 (signiert)pub fn derive_user_id(identity_public_key: &[u8], identity_signing_key: &[u8]) -> Uuid {
let mut h = Sha256::new();
h.update(identity_public_key);
h.update(identity_signing_key);
let digest = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0F) | 0x80;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
Uuid::from_bytes(bytes)
}Grenzen
- Die Signaturen sind Ed25519 und halten gegen einen klassischen Fälscher. Ein Angreifer mit Quantencomputer, online zum Zeitpunkt des Handshakes, könnte sie fälschen; derselbe Angreifer bricht auch den X25519-Identitätsschlüssel, beide liegen also auf einer Stufe. Der Wechsel des Signaturschlüssels auf ML-DSA ist geplant.
- Der Erstkontakt ist Trust-on-first-use: Die App vertraut der ID, die sie bekommen hat. Jeder Schlüssel, der für diese ID abgerufen wird, muss zu ihr hashen, sonst lehnt die App die Sitzung ab. Die Sicherheitsnummern decken den gesamten Schlüsselaustausch ab, für den Abgleich über einen anderen Kanal.
Implementiert in X25519.kt · Ed25519.kt · MLKEM.kt · KeyManager.kt
Handshake
PQXDH mit ML-KEM-1024
Der Initiator holt das Prekey-Bundle des Gegenübers, prüft jede Signatur unter dessen Signaturschlüssel und lehnt das Bundle ab, wenn eine Signatur nicht stimmt oder kein ML-KEM-Prekey enthalten ist. Der Handshake nimmt das Post-Quanten-Material als nicht-optionales Argument, eine Sitzung ohne es kompiliert nicht. Ein Relay, der den ML-KEM-Prekey aus einem Bundle entfernt, bekommt eine Ablehnung.
Vier X25519-Berechnungen (drei, wenn der Vorrat an einmaligen Prekeys leer ist) und eine ML-KEM-1024-Encapsulation gehen in einen HKDF-SHA-512-Aufruf. Der Initiator kapselt an einen einmaligen ML-KEM-Prekey, wenn das Bundle einen hat, sonst an den signierten Last-Resort-Prekey. Die 32 Byte Ausgabe sind der erste Root Key des Ratchets.
Die erste Nachricht trägt den Ephemeral Key, die IDs der benutzten Prekeys und den KEM-Ciphertext. Kann der Empfänger nicht dekapseln, weil ein einmaliger Prekey durch eine Wiederholung verbraucht oder bei einer Wiederherstellung verloren ging, leiten beide Seiten verschiedene Geheimnisse ab und der AEAD-Tag schlägt fehl. Die Sitzung wird zurückgesetzt und aus einem frischen Bundle neu aufgebaut.
DH1 = X25519(IK_A, SPK_B)
DH2 = X25519(EK_A, IK_B)
DH3 = X25519(EK_A, SPK_B)
DH4 = X25519(EK_A, OPK_B) wenn ein einmaliger Prekey verfügbar war
(CT, SS) = ML-KEM-1024.Encaps(PQPK_B) einmaliger PQ-Prekey bevorzugt, sonst der signierte
SK = HKDF-SHA-512( salt = 0⁶⁴,
ikm = 0xFF³² ‖ DH1 ‖ DH2 ‖ DH3 [‖ DH4] ‖ SS,
info = "Aphotic_CURVE25519_SHA-512_ML-KEM-1024",
L = 32 )fun initiateSession(
ourIdentityPrivate: PrivateKey,
ourIdentityPublic: ByteArray,
theirIdentityPublic: PublicKey,
theirSignedPrekey: PublicKey,
theirOneTimePrekey: PublicKey?,
theirPqPrekey: ByteArray,
): X3DHResult {
val ephemeral = X25519.generateKeyPair()
val dh1 = X25519.dh(ourIdentityPrivate, theirSignedPrekey)
val dh2 = X25519.dh(ephemeral.private, theirIdentityPublic)
val dh3 = X25519.dh(ephemeral.private, theirSignedPrekey)
val dhConcat: ByteArray
if (theirOneTimePrekey != null) {
val dh4 = X25519.dh(ephemeral.private, theirOneTimePrekey)
dhConcat = dh1 + dh2 + dh3 + dh4
CryptoUtils.zeroize(dh4)
} else {
dhConcat = dh1 + dh2 + dh3
}
val (kemCiphertext, ss) = MLKEM1024.encapsulate(theirPqPrekey)
val ikm = KDF_F + dhConcat + ss
CryptoUtils.zeroize(ss)
val sharedSecret = CryptoUtils.hkdfSha512(HKDF_SALT, ikm, HKDF_INFO_PQ, 32)
CryptoUtils.zeroize(dh1)
CryptoUtils.zeroize(dh2)
CryptoUtils.zeroize(dh3)
CryptoUtils.zeroize(ikm)
CryptoUtils.zeroize(dhConcat)
return X3DHResult(
sharedSecret = sharedSecret,
ephemeralKeyPair = ephemeral,
kemCiphertext = kemCiphertext,
)
}Grenzen
- SK bekommt nur ein Angreifer, der X25519 und ML-KEM-1024 beide bricht. Ein später gebauter Quantencomputer gewinnt SK nicht aus heute aufgezeichnetem Verkehr.
- PQXDH schützt den Aufbau. Was danach mit den Schlüsseln passiert, ist Sache des Ratchets, unten.
Implementiert in X3DHManager.kt · SessionManager.kt
Ratchet
Triple Ratchet: ein KEM-Schritt auf jedem DH-Schritt
Jede Sitzung läuft über das Double Ratchet: eine symmetrische Kette, die jeder Nachricht einen eigenen Schlüssel gibt und ihn nach Gebrauch vernichtet, und ein DH-Ratchet, das den Root Key ersetzt, sobald die Unterhaltung die Richtung wechselt. Ein drittes Ratchet läuft im Gleichschritt mit dem zweiten. Bei jedem DH-Schritt erzeugt der Sender ein frisches ML-KEM-768-Schlüsselpaar, kapselt an das aktuelle des Gegenübers, und das KEM-Geheimnis geht neben der DH-Ausgabe in die Root-KDF.
Jede Nachricht trägt den aktuellen ML-KEM Public Key des Senders und, auf jeder Kette nach einem Schritt, den Ciphertext für das Gegenüber. Jede einzelne Nachricht einer Kette kann deshalb den Schritt des Gegenübers auslösen; nichts wird über Nachrichten verteilt oder wieder zusammengesetzt. Eine Nachricht darf der zuletzt empfangenen um bis zu 100 Positionen voraus sein. Die übersprungenen Schlüssel bleiben 7 Tage erhalten, höchstens 500 pro Sitzung, damit verspätete Nachrichten noch entschlüsseln. Bei 2²⁴ Nachrichten endet die Kette und die Sitzung wird neu aufgebaut.
Der Zustand wird erst in den Tresor geschrieben, wenn eine Entschlüsselung verifiziert ist. Eine fehlgeschlagene Entschlüsselung setzt die ganze Sitzung auf den Stand vor dem Versuch zurück.
Kettenschritt MK = HMAC-SHA-256(CK, 0x01) CK' = HMAC-SHA-256(CK, 0x02)
Nachricht AES-256-GCM(MK, plaintext, AD)
AD IK_sender ‖ IK_receiver ‖ 0x03 ‖ dhPub ‖ counter ‖ prev ‖ flags ‖ kemGen ‖ kemPub [‖ ctTargetGen ‖ kemCt]
Root-Schritt (RK', CK) = HKDF-SHA-256( salt = RK,
ikm = DH(32) ‖ SS(32),
info = "Aphotic_TripleRatchet",
L = 64 )
DH: X25519 zwischen dem neuen Ratchet Key und dem des Gegenübers
SS: ML-KEM-768, Encaps an den aktuellen Key des Gegenübers beim Senden, Decaps beim Empfang
Wire 0x03 ‖ flags ‖ kemGen(u32) ‖ kemPub(1184 B) [‖ ctTargetGen(u32) ‖ kemCt(1088 B)] ‖ AEAD-Ausgabe
+1190 B auf jeder Nachricht, +2282 B auf einer Kette nach einem Schrittfun kdfRk3(rootKey: ByteArray, dh: ByteArray, ss: ByteArray?): Pair<ByteArray, ByteArray> {
val ikm = if (ss != null) dh + ss else dh
val output = CryptoUtils.hkdf(rootKey, ikm, TRIPLE_RATCHET_INFO, 64)
val newRootKey = output.copyOfRange(0, 32)
val chainKey = output.copyOfRange(32, 64)
if (ss != null) CryptoUtils.zeroize(ikm)
CryptoUtils.zeroize(output)
return Pair(newRootKey, chainKey)
}Grenzen
- Ohne Kompromittierung lernt ein passiver Angreifer mit vollständigem Transkript und Quantencomputer nichts: Jeder Root-Übergang nach dem Aufbau mischt ein KEM-Geheimnis ein, das er nicht gewinnen kann, und der Aufbau ist PQXDH.
- Nach einem einmaligen Auslesen des Telefonzustands heilt die Senderichtung des Opfers bei seinem nächsten DH-Schritt und die Empfangsrichtung einen Round Trip später, gegen einen passiven Quanten-Angreifer.
- Ein Angreifer mit einer vollständigen Kopie des Zustands, der aktiv im Pfad sitzt, schlägt jedes Ratchet dieser Familie, auch dieses.
- Das Ratchet-Format wird beim Aufbau der Sitzung aus einer nativen Fähigkeit festgelegt und nie vom Wire gelesen. Ein Gegenüber, das die App mit KEM-Ratchet gepinnt hat, bekommt in keiner Richtung eine Sitzung ohne es.
Implementiert in DoubleRatchet.kt · TripleRatchet.kt · RatchetSession.kt
Direktnachrichten
Sealed Sender, post-quantum
Eine Direktnachricht geht ohne Authorization-Header an den Relay des Empfängers. Die Anfrage nennt den Empfänger und trägt einen undurchsichtigen Blob; die ID des Senders steckt in der Verschlüsselung. Der Seal Key kommt aus einem Ephemeral-X25519-Schlüssel und einer ML-KEM-1024-Encapsulation an den langlebigen Sealed-KEM-Schlüssel des Empfängers; der Ephemeral Key, der Identitätsschlüssel und der KEM-Schlüssel des Empfängers sind in die HKDF-Info gebunden.
Die Null-Nonce ist sicher, weil beide Eingaben des Seal Keys pro Nachricht frisch sind. Der KEM-Schlüssel des Empfängers ist unter dessen Ed25519-Signaturschlüssel signiert und reist im Prekey-Bundle mit, ein Relay kann also keinen eigenen unterschieben. Ein Umschlag ohne ML-KEM-Teil wird beim Entschlüsseln abgelehnt, und ein fehlender KEM-Schlüssel lässt das Senden scheitern; jeder Fallback würde einem Relay erlauben, den Post-Quanten-Teil durch Löschen eines Felds zu entfernen. Ohne diesen Teil würde ein Angreifer, der heute Direktnachrichten aufzeichnet und später X25519 bricht, den Sender jeder einzelnen zurückgewinnen.
Das Senden weist eine Lizenz mit einem zufälligen Handle nach, das jede Stunde wechselt, über einen eigenen Tor-Circuit (siehe Lizenz-Tokens unten). Der Relay füllt jeden gespeicherten Umschlag auf eine feste Bucket-Größe auf.
(eph_sk, eph_pk) = X25519.KeyGen()
dh = X25519(eph_sk, IK_recipient)
(ct, ss) = ML-KEM-1024.Encaps(SealedKEM_recipient)
header = 0x02 ‖ eph_pk ‖ ct 1 + 32 + 1568 B
seal_key = HKDF-SHA-256( salt = 0³², ikm = dh ‖ ss,
info = "Aphotic_SealedSender_v2" ‖ eph_pk ‖ IK_recipient ‖ SealedKEM_recipient,
L = 32 )
inner = len(sender_id) ‖ sender_id ‖ Ratchet-Nachricht
blob = header ‖ AES-256-GCM(seal_key, nonce = 0¹², aad = header, inner)fun seal(
recipientIdentityKey: ByteArray,
recipientSealedKemKey: ByteArray,
senderId: String,
innerCiphertext: String,
): ByteArray {
require(recipientIdentityKey.size == EPH_PUB_LEN) {
"recipient identity key must be $EPH_PUB_LEN bytes, got ${recipientIdentityKey.size}"
}
require(recipientSealedKemKey.size == MLKEM1024.PUBLIC_KEY_LEN) {
"recipient sealed-sender KEM key must be ${MLKEM1024.PUBLIC_KEY_LEN} bytes, " +
"got ${recipientSealedKemKey.size}"
}
val recipientPub = X25519.publicKeyFromBytes(recipientIdentityKey)
val ephemeral = X25519.generateKeyPair()
val ephPubBytes = X25519.publicKeyToBytes(ephemeral.public)
val dh = X25519.dh(ephemeral.private, recipientPub)
val (kemCt, kemSecret) = MLKEM1024.encapsulate(recipientSealedKemKey)
val header = byteArrayOf(VERSION_V2) + ephPubBytes + kemCt
val key = deriveKey(dh, kemSecret, ephPubBytes, recipientIdentityKey, recipientSealedKemKey)
CryptoUtils.zeroize(dh)
CryptoUtils.zeroize(kemSecret)
val senderBytes = senderId.toByteArray(Charsets.UTF_8)
val innerBytes = innerCiphertext.toByteArray(Charsets.UTF_8)
val plaintext = u32le(senderBytes.size) + senderBytes + innerBytes
val aead = CryptoUtils.aesGcmEncryptWithNonce(key, ZERO_NONCE, plaintext, header)
CryptoUtils.zeroize(key)
CryptoUtils.zeroize(plaintext)
CryptoUtils.zeroize(senderBytes)
CryptoUtils.zeroize(innerBytes)
return header + aead
}private fun deriveKey(
dh: ByteArray,
kemSecret: ByteArray,
ephPub: ByteArray,
recipientIdentityKey: ByteArray,
recipientSealedKemKey: ByteArray,
): ByteArray {
val ikm = dh + kemSecret
val info = INFO_PREFIX + ephPub + recipientIdentityKey + recipientSealedKemKey
val key = CryptoUtils.hkdf(HKDF_SALT, ikm, info, 32)
CryptoUtils.zeroize(ikm)
return key
}Grenzen
- Diese Schicht hat keine Forward Secrecy: Sie verschlüsselt an langlebige Empfängerschlüssel. Die liefert die Ratchet-Nachricht darin.
- Der Empfänger steht im Klartext, weil der Relay zustellen muss. Ein Relay sieht, wer wann was empfängt, und weil er den Moment wählt, in dem er die wartende Anfrage des Empfängers weckt, kann es ein Senden mit einem Empfangen paaren, während es passiert. Sealed Sender verbirgt die Identität des Senders vor dem Relay. Es hindert ein Relay nicht daran, die beiden Enden einer Unterhaltung über das Timing zu korrelieren, und wenn in derselben Stunde nur wenige Geräte Handles rotieren, ist die Menge, in der sich ein Sender versteckt, klein.
Implementiert in SealedSender.kt
Auf dem Telefon
Ein zufälliger Schlüssel unter zwei Hüllen
Nachrichten, Kontakte, Sitzungen und die langlebigen privaten Schlüssel sind eine SQLCipher-Datenbank. Ihr Schlüssel ist ein zufälliger 256-Bit-Wert, der nie aus der PIN abgeleitet wird. Er liegt unter zwei AES-256-GCM-Schichten: einer inneren, deren Schlüssel per Argon2id aus der PIN kommt, und einer äußeren mit einem nicht exportierbaren AES-Schlüssel im Android Keystore, in der StrongBox, wo das Telefon eine hat.
Eine falsche PIN lässt einen GCM-Tag fehlschlagen; in den Metadaten gibt es keinen separaten Prüfwert zum Angreifen. Fünf Fehlversuche löschen die Datenbank, ihre Metadaten und den entschlüsselten Medien-Cache. Die Panik-PIN ist ein zweiter umhüllter Blob mit einem Sentinel. Die beiden PINs leiten verschiedene Schlüssel ab, es öffnet also höchstens ein Blob, und ein gültiges Entschlüsseln des Sentinels löst denselben Wipe hinter einem normal aussehenden Entsperren aus.
Mit der optionalen Master-Passphrase wird der Schlüssel unter Argon2id der Passphrase mit 128 MiB neu umhüllt, und ein zeitlich begrenzter Keystore-Schlüssel öffnet ein PIN-Fenster (8 Stunden bis 7 Tage, 48 Stunden als Standard), in dem die sechsstellige PIN allein entsperrt. Das Fenster erzwingen die Gültigkeitsdauer des Keystore selbst, ein Ablauf in der App und ein monotoner Uhrzeit-Wächter gegen Zurückdrehen. Ein Neustart des Telefons fragt die Passphrase wieder ab.
Ein Backup ist eine Datei, neu verschlüsselt unter Argon2id einer Passphrase mit 128 MiB. Der Wechsel auf ein neues Telefon ist eine direkte Übertragung von Gerät zu Gerät. Vom Android-Backup meldet sich die App ab.
K_pin = Argon2id( PIN, salt; m = 64 MiB, t = 4, p = 1 )
K_hw = AES-256 im Android Keystore, nicht exportierbar, StrongBox bevorzugt
wrapped_dek = AES-256-GCM( K_hw, AES-256-GCM( K_pin, DEK ) )
Entsperren äußere Schicht mit K_hw entfernen (nur auf diesem Telefon), dann die innere mit K_pin
der GCM-Tag ist die PIN-Prüfung
Panik AES-256-GCM( K_hw, AES-256-GCM( K_panic, SENTINEL ) ) ein gültiges Entschlüsseln löscht
Passphrase K_master = Argon2id( Passphrase ≥ 12 Zeichen, salt; m = 128 MiB, t = 4 )
Backup Argon2id( Passphrase, salt; m = 128 MiB, t = 4 ), AES-256-GCMprivate fun wrap(payload: ByteArray, kPin: ByteArray): String {
val inner = CryptoUtils.aesGcmEncrypt(kPin, payload)
val outer = keystoreEncrypt(getWrapKey(), inner)
return CryptoUtils.toBase64(outer)
}Grenzen
- Eine kopierte Tresordatei ist ohne K_hw wertlos, und das macht eine sechsstellige PIN vertretbar. Code, der auf einem gerooteten Telefon als die App läuft, kann den Keystore bitten, die äußere Schicht zu entfernen, und dann die PIN offline raten. Für diese Bedrohung gibt es die Master-Passphrase.
- Ist der Keystore-Schlüssel weg (Werksreset, gelöschte App-Daten), lässt sich der Tresor nicht öffnen. Eine Backup-Datei ist der einzige Weg zurück, und eine vergessene Passphrase ist nicht wiederherstellbar.
- Argon2id nutzt für die tägliche PIN 64 MiB, weil schwachen Telefonen darüber der Speicher ausgeht. Die Hardware-Hülle ist dort die erste Barriere, die KDF-Kosten die zweite.
Implementiert in PinManager.kt · VaultDatabase.kt · VaultKeyStore.kt · VaultBackup.kt · Argon2id.kt
Lizenz
Blind signierte Lizenz-Tokens
Der Shop kennt die Lizenz und die Zahlung. Der Chat-Relay kennt die Identität. Niemand darf die beiden verbinden, auch ihr Betreiber nicht. Eine Lizenz weist sich mit RSA-Blindsignaturen aus: Die App erzeugt zufällige 32-Byte-Token-IDs, blendet sie und schickt die geblendeten Werte an den Lizenzdienst, der sie mit einem Schlüssel signiert, der eine Stunde lang existiert, und pro Lizenz zählt, wie viele er signiert hat. Eine ungeblendete ID sieht er nie.
Die App entblendet die Signaturen und gibt später ein Token beim Relay aus, der es unter dem Public Key dieser Stunde prüft, die ID gegen Wiederverwendung vermerkt und die Identität bis zum Ende der Stunde als lizenziert markiert. Der Relay erfährt, dass der Inhaber in dieser Stunde lizenziert ist, und nichts darüber, welche Lizenz.
Das Sealed-Sender-Handle wird mit einem zweiten Token auf einer Route ohne jede Authentifizierung lizenziert, ein Handle und eine Identität stehen also nie in einer Anfrage. Der Client betreibt drei Tor-SOCKS-Ports (Lizenzdienst, Identitätssitzung, Sealed Sends), damit die drei nie einen Circuit teilen, und einen vierten für Gast-Identitäten auf anderen Relays. Wechselt das Handle, werden die gepoolten Verbindungen seines Circuits verworfen, damit eine Verbindung nicht zwei Pseudonyme verkettet.
Suite RSA-2048, RSASSA-PSS, SHA-256, Salt 32 (RFC 9474)
Epoche floor(unix_time / 3600); ein Signaturschlüsselpaar pro Epoche, gelöscht nach Ablauf
Ausgabe lizenzierte Anfrage: geblendete IDs → Blindsignaturen; ≤ 10 × Seats + 2 pro Lizenz und Epoche
Einlösen ohne Credentials: (epoch, key_id, token_id, signature)
prüfen → SADD spent:{epoch} token_id → lizenziert bis zum Ende der Epoche
zwei Tokens eins für die Identitätssitzung, eins für das Sealed-Sender-Handlepub async fn verify_and_spend(
state: &AppState,
epoch: i64,
_key_id: &str,
token_id: &[u8],
signature: &[u8],
) -> Result<i64, AppError> {
let cur = current_epoch();
if epoch < cur - 1 || epoch > cur + 1 {
return Err(AppError::LicenseInvalid(
"redeem_required: token epoch out of range".into(),
));
}
if token_id.len() != 32 {
return Err(AppError::BadRequest("token_id must be 32 bytes".into()));
}
let key = get_key(state, epoch).await?;
let sig = Signature(signature.to_vec());
sig.verify(&key.pk, None, token_id, &opts())
.map_err(|_| AppError::LicenseInvalid("redeem_required: invalid token".into()))?;
let spent_key = format!("spent:{}", epoch);
let token_hex = hex::encode(token_id);
let added: i64 = state.redis.sadd(&spent_key, token_hex).await?;
let _: () = state
.redis
.expire(&spent_key, EPOCH_SECS + 600, None)
.await
.unwrap_or_default();
if added == 0 {
return Err(AppError::LicenseInvalid(
"redeem_required: token already redeemed".into(),
));
}
Ok(epoch_end(epoch))
}Grenzen
- Eine über ihre Seats hinaus geteilte Lizenz braucht ihr Stundenkontingent mitten in der Stunde auf. Das ist die ganze Durchsetzung; eine gepatchte App gewinnt nichts, weil das Urteil des Relays nur ein unverbrauchtes, korrekt signiertes Token setzen kann.
- Die zwei Einlösungen eines Geräts passieren in derselben Stunde, um 20 Sekunden versetzt. Die Menge, in der sich ein Gerät versteckt, ist die Zahl der Geräte, die in diesem Fenster einlösen. Bei wenigen Nutzern ist die Timing-Korrelation der beiden praktikabel.
Implementiert in blindToken.ts · token_verify.rs · token_keys.rs
Communities
MLS mit hybridem KEM
Communities mit bis zu 5.000 Mitgliedern laufen über RFC 9420 MLS mit TreeKEM, aus einer Clean-Room-Implementierung, die an den Testvektoren des RFC hängt. Communities nutzen eine Ciphersuite aus dem privaten Bereich, 0xF043: Der KEM ist X25519 kombiniert mit ML-KEM-768 über einen Combiner, der beide Ciphertexte bindet, das Hybrid hält also, wenn eine der beiden Komponenten hält; AEAD ist AES-256-GCM, Hash SHA-256, Signatur Ed25519.
Jedes Leaf Credential trägt die User-ID des Mitglieds und wird bei der Aufnahme geprüft, die Rangdurchsetzung hängt also am authentifizierten Sender, den die MLS-Entschlüsselung liefert. Der Schlüssel des Gründers ist der Vertrauensanker und reist im Einladungslink mit.
Ein Welcome trägt den Baum als SHA-256-Referenz, der Baum selbst wird einmal neben dem Commit geschickt, ein Welcome bleibt also bei jeder Mitgliederzahl gleich groß. Commits landen zusätzlich in einem dauerhaften Log, sodass ein Mitglied, das länger als die 7 Tage des Streams offline war, sie der Reihe nach unter seinem bestehenden Leaf nachspielt. Jedes Mitglied darf einen Self-Update-Commit ausgeben; so füllt sich der Baum, und jeder Commit bleibt logarithmisch in der Mitgliederzahl.
Suite 0xF043 KEM = X25519 + ML-KEM-768 (über beide Ciphertexte kombiniert) · AES-256-GCM · SHA-256 · Ed25519 Leaf Credential = User-ID des Mitglieds, geprüft bei der Aufnahme Welcome GroupInfo + GroupSecrets; Baum als SHA-256-Referenz, einmal mit dem Commit geschickt Historie Commits in einem dauerhaften Log; Inhalte auf einem 7-Tage-Stream
private fun combine(xDh: ByteArray, mlkemSs: ByteArray, enc: ByteArray): ByteArray {
val prk = CipherSuite.hkdfExtract(ByteArray(0), xDh + mlkemSs)
val info = "aphotic-hybrid-kem-v1-X25519-MLKEM768".toByteArray(Charsets.US_ASCII) + enc
return CipherSuite.hkdfExpand(prk, info, SHARED)
}Grenzen
- Der Relay sieht die Mitgliederliste und die Epochennummer jeder Community.
- Alltagsgruppen bis 20 bleiben paarweise: Jede Nachricht wird mit dem Ratchet oben für jedes Mitglied einzeln verschlüsselt, auf dem Relay existiert also kein Gruppenschlüssel.
Implementiert in CipherSuite.kt · HybridKem.kt · MlsGroup.kt · Welcome.kt · KeySchedule.kt
Auf dem Relay
Was gespeichert wird, und wie lange
Der Relay ist ein Tor Hidden Service ohne Clearnet-Endpunkt. Er speichert Public Keys, die daraus abgeleitete User-ID, Gruppen- und Community-Mitgliederlisten und Ciphertext in der Warteschlange für höchstens 7 Tage. Jeder gespeicherte Umschlag wird auf einen Bucket von 256, 512, 1024, 2048, 4096 oder 8192 Bytes aufgefüllt, darüber in 4-KiB-Schritten, mit 256 KB als Obergrenze. Die Tabellen haben keine Spalten für Anmelde- oder Zuletzt-gesehen-Zeiten. Lesebestätigungen, Tippanzeigen und Online-Status existieren auf dem Wire nicht.
Antworten tragen keinen Server- und keinen Versions-Header. Der Verkehr erreicht den Relay vom lokalen Tor-Prozess, keine Anfrage trägt also eine Client-IP, die sich loggen ließe.
pub fn padded_size(actual: usize) -> usize {
for &bucket in BUCKETS {
if actual <= bucket {
return bucket;
}
}
let last = *BUCKETS.last().unwrap();
last + ((actual - last + LARGE_BOUNDARY - 1) / LARGE_BOUNDARY) * LARGE_BOUNDARY
}Grenzen
- Timing überlässt die App Tor. Wer beide ersten Hops gleichzeitig beobachtet, kann sie korrelieren, und Tor nennt diese Grenze offen; eine Verzögerung in der App würde beide Enden gleich verschieben.
Implementiert in padding.rs · sealed.rs · longpoll.rs
Wogegen die Konstruktionen getestet sind
Zu jeder Konstruktion gehören ausführbare Tests, und die, die einer öffentlichen Spezifikation folgen, sind an deren Vektoren gebunden.
- PQXDH: HKDF-SHA-512-Vektoren aus der Konstruktion der Spezifikation selbst, plus Seed-zu-Public-Key-Pins für ML-KEM
- Double und Triple Ratchet: Round Trip, Zustellung außer der Reihe, Heilung nach Kompromittierung und Überleben eines Neustarts, auf der JVM und auf einem Telefon
- Sealed Sender: Round Trip, ein Umschlag ohne KEM-Teil abgelehnt, Manipulationserkennung auf jedem Header-Feld
- X25519: Ablehnung eines gesetzten High Bit, von u ≥ p und jedes Small-Order-Punkts, mit einem handgebauten Schlüssel pro Fall
- Argon2id: Known-Answer-Tests gegen die Referenzausgabe
- MLS: die offiziellen RFC-9420-Vektoren für Baum-Arithmetik, Crypto Basics, Key Schedule, Secret Tree, Tree Validation und Welcome, plus Round Trips mit mehreren Mitgliedern
- Blind-Tokens: der TypeScript-Client Byte für Byte gegen die auditierte Rust-Crate der Dienste geprüft
Eine Lücke gefunden?
Jede Nachricht über die Kontaktseite erreicht den Betreiber als eine E-Mail. Ein Fund, der standhält, bekommt eine Antwort und eine Zeile auf der Transparenzseite.