Code-Schau
Kein Repo — 17 Ausschnitte aus dem echten Quellcode, unverändert
Auf der Startseite steht: „Die Kryptografie lässt sich Zeile für Zeile nachlesen." Das lässt sich prüfen oder es ist nur ein Satz — hier ist die Prüfung. 17 Stellen aus dem echten HIY-Quellcode (Android, iOS, Relay), original und mit den Original-Kommentaren, so wie sie im Repository stehen. Nicht gekürzt, nicht geglättet.
Gezeigt wird die Kryptografie: Schlüsselerzeugung, Verschlüsselung, Schlüsselableitung, Signaturformat, Reichweitenlogik. Das ist der Teil, den man prüfen können muss — er ist sicher, weil er bekannt ist. Was bewusst nicht gezeigt wird, steht am Ende dieser Seite.
Ende-zu-Ende-Verschlüsselung — das Wire-Format
Behauptung: „E2EE-Chats mit X25519 + AES-256-GCM."
/**
* Ende-zu-Ende-Verschlüsselung für "Nur Freunde"-Posts, siehe
* `docs/E2E_ENCRYPTION.md`. Nutzt das separate X25519-Schlüsselpaar aus
* `EncryptionIdentity` (NICHT den Signier-Schlüssel — getrennte Schlüssel für
* getrennte Zwecke). Pendant zu `E2E.swift` — X25519 ist ein einziger,
* unzweideutiger Standard (RFC 7748), Tinks Primitiv hier liefert exakt
* dieselben Bytes wie CryptoKit dort; HKDF-SHA256 mit leerem Salt ist wegen
* HMACs eigenem Zero-Padding kurzer Schlüssel ebenfalls plattformunabhängig
* identisch (ein 0-Byte- und ein 32-Byte-Null-Schlüssel ergeben nach dem
* Padding auf die Blockgrösse dasselbe Ergebnis).
*
* Wire-Format pro versiegeltem Blob (`encText`, und jeder Wert in `encKeys`):
* base64(Nonce[12] || Ciphertext || Tag[16]) — identisch zu CryptoKit's
* `AES.GCM.SealedBox.combined`.
*/
object E2E {
private val HKDF_INFO = "hiy.e2e.v1".toByteArray(Charsets.UTF_8)
private const val NONCE_LEN = 12
data class Sealed(val encText: String, val encKeys: Map<String, String>, val encBild: String? = null)
Das Wire-Format steht im Kommentar. Wer es kennt, kann einen abgefangenen Blob selbst nachrechnen — genau das macht eine Behauptung prüfbar. Bemerkenswert nebenbei: getrennte Schlüssel für Signieren und Verschlüsseln, mit Begründung.
Backup-PIN — die Schlüsselableitung
Behauptung: „aufschliessbar nur mit der PIN (600'000 Runden PBKDF2)."
/**
* Verschlüsseltes Backup der Freundesliste — überlebt eine Neuinstallation/
* einen Gerätewechsel, anders als FriendsStore selbst (rein lokal,
* SharedPreferences). Der Relay sieht nie Klartext: der AES-Schlüssel kommt
* aus einer selbst gewählten PIN, nie vom Server — nur wer die PIN kennt,
* kann den Blob entschlüsseln, der Relay-Betreiber selbst nicht. Pendant zu
* FriendsBackup.swift.
*/
object FriendsBackup {
// OWASP-Empfehlung (2023+) für PBKDF2-HMAC-SHA256 ist 600k Runden — vorher
// 200k, zusammen mit der Mindestlänge von 10 Zeichen über die volle
// Tastatur (s. BackupPinDialog.kt) macht das Offline-Brute-Force gegen
// einen durchgesickerten Backup-Blob (liegt verschlüsselt auf dem Relay)
// praktisch aussichtslos.
private const val ITERATIONS = 600_000
private const val KEY_LENGTH_BITS = 256
600 000 Runden PBKDF2-HMAC-SHA256, AES-256.
…und die Mindestlänge, ohne die die Rundenzahl nichts aussagt
// MINDESTENS ZEHN ZEICHEN, UND BUCHSTABEN SIND ERLAUBT.
//
// Der verschluesselte Block liegt auf dem Relay, und darin steht die
// Freundesliste: Namen, Codes, Schluessel. Nicht der Inhalt von
// Nachrichten, aber das soziale Netz. Faellt er je jemandem in die Haende,
// zaehlt allein, wie teuer das Durchprobieren ist.
//
// Acht Ziffern waeren 100 Millionen Moeglichkeiten — trotz 600 000
// PBKDF2-Runden auf OWASP-Stand auf einer guten Grafikkarte in wenigen
// Stunden durch. Genau deshalb sind es zehn: zwei Stellen mehr
// verhundertfachen den Aufwand, Buchstaben vertausendfachen ihn.
// Erzwungen werden sie nicht — wer bei Ziffern bleibt, soll das koennen;
// die Tastatur bietet beides an.
//
// VERLAUF, damit die Zahlen unten nicht verwirren: 4 -> 6 -> 8 -> 10, die
// PBKDF2-Runden parallel von 200k auf 600k (s. FriendsBackup). Jede Stufe
// kam aus demselben Grund, nur mit besserer Rechnung.
//
// GILT NUR FUER NEUE PINs. Beim Wiederherstellen wird keine Laenge
// geprueft, sonst sperrten wir Bestandsnutzer:innen aus ihrem eigenen
// Backup aus. Die Kehrseite: Blobs, die noch mit einer kurzen PIN von
// frueher verschluesselt sind, bleiben schwach — dagegen hilft nur, nach
// erfolgreicher Wiederherstellung zur Neuvergabe aufzufordern.
val isValid = if (isRestore) pin.isNotEmpty() else pin.length >= BACKUP_PIN_MIN && pin == confirmPin
Bei einer vierstelligen Zahlen-PIN wären auch 600 000 Runden wertlos. Erst beide Zahlen zusammen ergeben eine prüfbare Aussage. Der Verlauf 4 → 6 → 8 → 10 steht bewusst dabei.
…und die eigentliche Ableitung, heute noch PBKDF2
/** Salt aus sub (nicht geheim — Salts müssen es nicht sein) statt fest
* verdrahtet, damit zwei verschiedene Konten mit zufällig gleicher PIN
* nicht denselben Schlüssel ableiten. */
fun deriveKey(pin: String, sub: String, verfahren: Verfahren = Verfahren.ARGON2ID): SecretKeySpec {
val salt = MessageDigest.getInstance("SHA-256").digest(sub.toByteArray(Charsets.UTF_8))
return when (verfahren) {
Verfahren.PBKDF2 -> {
val spec: KeySpec = PBEKeySpec(pin.toCharArray(), salt, ITERATIONS, KEY_LENGTH_BITS)
SecretKeySpec(
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded,
"AES",
)
}
Die Funktionssignatur verrät den nächsten Schritt: ein verfahren-Parameter, Standardwert ARGON2ID. Neue Sicherungen laufen heute trotzdem noch über den PBKDF2-Zweig — ein Schalter an anderer Stelle in derselben Datei hält sie dort, bis die iOS-App Argon2id lesen kann. Wer hier vor der Zeit umstellt, macht Sicherungen unlesbar, die auf dem jeweils anderen Betriebssystem entschlüsselt werden sollen. Auch ein Vorhaben, das noch nicht fertig ist, gehört auf diese Seite, solange es ehrlich als das benannt wird.
Signierte Felder — was eine Signatur abdeckt
Behauptung: „nichts lässt sich unbemerkt austauschen."
const SIGNING_FIELDS = {
post: ['id', 'author', 'handle', 'text', 'createdAt', 'originPeerId', 'audience', 'imageHasCameraExif', 'reachBudget', 'imageHash', 'avatarUrl', 'quotedPostId', 'encText', 'encKeys', 'encPubKey'],
// avatarUrl bei comment/friendreq nachgezogen (Sicherheits-Review vor
// Beta): vorher NICHT Teil der Signatur — ein Relay/MITM konnte das
// angezeigte Profilbild eines sonst gültig signierten, "✅ verifizierten"
// Kommentars/einer Freundschaftsanfrage unbemerkt austauschen. Gleicher
// Fix, der für WirePost schon früher gemacht wurde (s. dortiger Kommentar).
comment: ['id', 'postId', 'author', 'handle', 'text', 'createdAt', 'originPeerId', 'encPubKey', 'avatarUrl'],
report: ['id', 'targetId', 'targetType', 'reporterId', 'createdAt'],
friendreq: ['id', 'kind', 'fromId', 'fromName', 'toId', 'createdAt', 'encPubKey', 'avatarUrl'],
// Direktnachricht: anders als "post" gibt es hier NIE eine Klartext-Variante
// — kein `text`-Feld, nur der bestehende E2E-Mechanismus (encText/encKeys/
// encPubKey, exakt wie bei "Nur Freunde"-Posts). `toId` (Fingerprint/sub der
// Empfängerin) ist signiert mitgeführt, damit ein MITM nicht den Empfänger
// austauschen kann, ohne die Signatur ungültig zu machen — der Relay selbst
// braucht `toId` inhaltlich nicht (er matcht über encKeys), es ist reine
// Ziel-Absicherung.
dm: ['id', 'toId', 'author', 'createdAt', 'originPeerId', 'encPubKey', 'avatarUrl', 'encText', 'encKeys'],
// …
gm: ['id', 'gruppenId', 'author', 'createdAt', 'originPeerId', 'encPubKey', 'avatarUrl', 'encText', 'encKeys'],
};
Der Kommentar dokumentiert eine GEFUNDENE UND GESCHLOSSENE Lücke: avatarUrl war nicht signiert, ein Relay konnte das Profilbild eines „verifizierten“ Kommentars austauschen. Eine eigene Lücke offen zu zeigen, ist glaubwürdiger als jede Sicherheitszusage. Die neueste Zeile (gm, seit dem 31.08.2026) zeigt, dass das Muster mitwächst: Gruppennachrichten bekommen dieselbe Absicherung wie Direktnachrichten, nicht eine eigene, schwächere.
Der zweite Fund in derselben Funktion, Monate später
/** Felder, in denen ein senkrechter Strich erlaubt bleiben MUSS, je Typ.
* Genau eins pro Typ, und nur dort, wo Menschen frei schreiben. */
const TRENNZEICHEN_ERLAUBT = { post: 'text', comment: 'text' };
/**
* WARUM EIN SENKRECHTER STRICH IN FAST KEINEM FELD STEHEN DARF.
*
* signingString() haengt die signierten Felder mit '|' aneinander und
* escaped nichts. Enthaelt ein Feldwert selbst ein '|', sind die Feldgrenzen
* nicht mehr eindeutig: Aus author="Anna", handle="anna", text="hallo|welt"
* wird die Zeichenkette "…|Anna|anna|hallo|welt|…" — und dieselbe entsteht
* aus handle="anna|hallo", text="welt". Beide Belegungen ergeben BYTEWEISE
* dieselbe signierte Zeichenkette, also gilt dieselbe Signatur fuer beide.
*
* Wer eine Nachricht unterwegs veraendern kann — der Relay selbst, oder wer
* den Transportweg bricht —, koennte damit Inhalt ueber eine Feldgrenze
* schieben, ohne die Signatur zu brechen. Erfinden kann er nichts, nur
* vorhandene Bytes verruecken. Aber genau davor soll die Signatur schuetzen.
*
* DIESE PRUEFUNG MACHT DIE ZERLEGUNG WIEDER EINDEUTIG. Darf ausser dem
* Textfeld kein Feld ein '|' enthalten, dann muss jede abweichende Zerlegung
* ein '|' in ein anderes Feld schieben — und scheitert hier. Nachrechnen:
* Wer die Grenze VOR dem Text nach rechts schiebt, bekommt eins ins
* vorangehende Feld; wer sie danach nach links schiebt, eins ins folgende.
* Beides abgewiesen. Typen ohne Textfeld (dm, friendreq, report) sind damit
* vollstaendig eindeutig.
*
* DIE SAUBERE LOESUNG WAERE EIN LAENGENPRAEFIX statt eines Trennzeichens.
* Sie aendert aber JEDE signierte Zeichenkette und damit jede Signatur — jede
* installierte App wuerde ab dem Umstieg alles als "Signatur ungueltig"
* verwerfen. Das braucht eine versionierte Umstellung mit Uebergangszeit, wie
* bei der Attest-Pflicht. Diese Pruefung hier kostet nichts und schliesst die
* Luecke bis dahin.
*
* PREIS, bewusst bezahlt: Wer einen senkrechten Strich im Anzeigenamen fuehrt,
* wird abgewiesen. Der Client zeigt den Grund seit RelayAbgelehnt an.
*/
function trennzeichenProblem(type, p) {
Zwei dokumentierte Funde in derselben Funktion — und die ehrliche Angabe, warum die saubere Lösung (Längenpräfix) noch aussteht. Diese Offenheit über einen offenen Rest wirkt glaubwürdiger als eine Seite, auf der alles fertig aussieht.
Reichweiten-Budget — kein Algorithmus, sondern Arithmetik
Behauptung: „Kein Algorithmus, sondern ein Reichweiten-Budget."
function zustellGrenze(p) {
return budgetVon(p) * BROADCAST_K;
}
function darfNochReisen(p) {
if (!REICHWEITE_AKTIV) return true;
if ((p.t || 'post') !== 'post') return true;
if ((p.audience || 'all') !== 'all') return true;
return (zustellungen.get(p.id) || 0) < zustellGrenze(p);
}
Vier Zeilen. Kein Modell, keine Gewichtung, keine Telemetrie — eine Multiplikation und ein Vergleich. Das ist der Pfad, über den praktisch die gesamte Zustellung läuft: Er zählt, wie viele Geräte einen Beitrag im „Alle"-Feed abholen, nicht wie viele ihn weiterreichen. Und die dritte Zeile ist die menschlich wichtigste — audience !== 'all' lässt alles andere ungehindert durch: Nur „Alle" wird überhaupt begrenzt. „Nur Freunde", „Lokal" und „Ich" sind adressiert, nicht gestreut, und gehen an alle Gemeinten. Wer eine kleine Reichweite wählt, schaltet damit nicht die eigenen Leute stumm — er begrenzt nur, wie weit ein Beitrag unter Fremden streut.
// Resonanzreichweite: 1 Hop verbraucht, bei lokaler Resonanz (z. B.
// Autor ist Freund) einen kleinen, rein lokalen Rabatt gewähren.
// Diese Entscheidung wird nirgends gemeldet oder gezählt.
val discount = if (isResonantAuthor(post)) RESONANCE_DISCOUNT else 0
val forwarded = post.copy(hopCount = (post.hopCount + 1 - discount).coerceAtLeast(0))
if (forwarded.hopCount < forwarded.reachBudget) {
broadcast(forwarded.toJson().toString(), exclude = key)
} // sonst: Reichweite erschöpft, verebbt hier — keine Weitergabe
Nur im lokalen Netz sieht die Rechnung so aus: ein Hop-Zähler, der bei jeder Weitergabe von Gerät zu Gerät um 1 steigt (und für Freund:innen des Autors sinkt, statt zu steigen). Das ist der Pfad im selben WLAN — die Ausnahme, nicht die Regel. Beide Pfade sind Arithmetik, kein Algorithmus, nur unterschiedliche: Der eine zählt Weitergaben, der andere zählt Abholungen.
Identität im Chip — Android
Behauptung: „der private Schlüssel verlässt den Chip nie."
private fun ensureKeyPair() {
if (keyStore.containsAlias(ALIAS)) return
// StrongBox bevorzugen (API 28+), bei Fehler auf normalen Keystore zurückfallen.
val strongBoxSupported = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P
if (strongBoxSupported) {
try { generate(strongBox = true); Log.i(TAG, "Identität in StrongBox erzeugt"); return }
catch (e: Exception) { Log.w(TAG, "StrongBox nicht verfügbar: ${e.message}") }
}
generate(strongBox = false)
Log.i(TAG, "Identität im Keystore erzeugt")
}
private fun generate(strongBox: Boolean) {
val builder = KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
if (strongBox && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
StrongBox bevorzugt, mit Rückfall auf den normalen Keystore. Der Rückfall gehört dazu — nicht jedes Gerät hat StrongBox, und das zu verschweigen wäre genau die Sorte Halbwahrheit, die die Seite anderen vorwirft.
Identität im Chip — iPhone
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag,
kSecAttrAccessControl as String: access,
],
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
// Secure Enclave nicht verfügbar (z. B. manche Simulatoren) — Fallback
// auf einen software-gestützten Schlüssel im Keychain, weiterhin
// secp256r1 und damit drahtkompatibel, nur ohne Hardware-Schutz.
return generateSoftwareKey(tag: tag)
kSecAttrTokenIDSecureEnclave ist die eine Zeile, um die es geht. Dass zwei unabhängige Implementierungen dasselbe Format erzeugen, ist selbst ein Argument — und auch hier steht der Rückfall offen dabei.
Was NICHT in einer Benachrichtigung steht
// KEIN NAME UND KEIN KLARTEXT IN DER NUTZLAST — auch nicht dort, wo der
// Relay beides kennt.
//
// Bis hierher stand der Absendername im Titel und der Kommentartext im Body.
// Beides ist inhaltlich verzichtbar und reist an Apple und Google vorbei: aus
// "diese Person hat jener geschrieben, um 21:04" laesst sich der halbe
// Freundes-Graph rekonstruieren, ohne eine einzige Nachricht zu lesen. Genau
// dieselbe Korrektur wurde beim Freundschaftsanfrage-Push schon gemacht (s.
// dortiger Kommentar) — hier fehlten nur die drei anderen Faelle.
//
// Was der Relay stattdessen schickt, ist eine Kennung: `t` und `fromId`. Den
// Namen loest der Client aus der EIGENEN Freundesliste auf — dieselbe Quelle,
// aus der die iOS-Extension ihn schon fuer Direktnachrichten holt, und der
// einzigen, der man glauben darf.
//
// Die Texte hier bleiben trotzdem stehen und bleiben deutsch. Sie sind der
// Rueckfallschirm: installierte Apps aelterer Fassungen lesen `title`/`body`
// unbesehen, und auf iOS erscheinen sie, wenn die Extension nicht laeuft.
// Weglassen hiesse "HIY" mit leerer Zeile auf jedem Geraet, das noch nicht
// aktualisiert hat. Neue Clients ueberschreiben sie ohnehin — samt der
// richtigen Sprache, was die alte Loesung nie konnte.
Handelt von einer Grenze statt von einer Fähigkeit: Ein Push läuft zwingend über Apple oder Google. Die Frage ist nur, was drinsteht. Google und Apple sehen, DASS etwas ankam und WANN — nicht was und nicht von wem.
Gruppenmodule — eine Antwort für alle
/**
* DIE EINE ANTWORT FUER ALLE MODULE.
*
* Vier Module fragen etwas, und der naheliegende Weg waere, jedem seine
* eigene Antwortart zu geben: `stimme`, `zusage`, `verfuegbarkeit`,
* `haken`. Vier Formate auf der Leitung, vier Auswertungen, vier Stellen,
* an denen sich ein Fehler einnisten kann.
*
* Stattdessen EINE: `{bezug, art, wahl:[Zahlen]}`. Bei der Umfrage sind
* die Zahlen die angekreuzten Optionen, beim Termin genau eine (ja /
* vielleicht / nein), beim Doodle die passenden Vorschlaege, bei der Liste
* die erledigten Punkte. Die Auswertung steht damit an EINER Stelle
* (`GruppenModule.werte`), und ein fuenftes fragendes Modul braucht sie
* gar nicht mehr.
*
* WARUM DAS OHNE ABSPRACHE FUNKTIONIERT: Jede Antwort ist eine eigene
* Nachricht mit eigenem Zeitstempel. Wer zweimal antwortet, hat zweimal
* geantwortet — es gilt die spaetere. Niemand muss etwas zurueckziehen,
* niemand muss eine Nachricht aendern (was verschluesselt ohnehin nicht
* ginge), und ein Geraet, das eine Woche aus war, holt die Antworten in
* beliebiger Reihenfolge nach und kommt aufs selbe Ergebnis.
*/
const val MODUL_ANTWORT = "antwort"
Vier fragende Module — Umfrage, Termin, Doodle, Liste — und eine einzige Antwortform für alle. Das ist eine Entwurfsentscheidung, keine Zeile Code: Die drei anderen Wege (je Modul ein eigenes Format) wurden erwogen und verworfen. Der Absatz „WARUM DAS OHNE ABSPRACHE FUNKTIONIERT" löst dabei ein echtes Rätsel — wie eine Umfrage-Antwort sich „ändern" lässt, wenn niemand eine verschlüsselte Nachricht nachträglich verändern kann: Man ändert nichts, man sagt es noch einmal, und es gilt das Letzte.
Wer scannt, klopft an
/**
* "Ich habe euren QR-Code gescannt und moechte dazu."
*
* DER EINZIGE WEG, WIE JEMAND VON AUSSEN AN EINE GRUPPE HERANKOMMT — und
* er endet nicht im Beitritt, sondern in einer Frage. Wer scannt, ist
* damit noch kein Mitglied; die Nachricht geht ausschliesslich an die
* Person, deren Code im QR steht, und die entscheidet.
*
* WARUM DAS OHNE FREUNDSCHAFT FUNKTIONIERT: Der QR traegt den
* Verschluesselungsschluessel der Einladenden. Damit laesst sich eine
* ganz gewoehnliche Gruppennachricht bauen, deren `encKeys` genau EINEN
* Eintrag hat — den fuer sie. Der Relay stellt sie zu, ohne etwas ueber
* Gruppen zu wissen; alle anderen koennen sie nicht einmal oeffnen.
*
* WARUM DAS NICHT ZUM EINFALLSTOR WIRD: Es traegt sich niemand selbst
* ein. Die Nachricht landet als Frage im Verlauf, und erst ein Tippen auf
* "Aufnehmen" schreibt die Mitgliederliste fort — von einem Geraet, das
* ohnehin schon Mitglied ist.
*/
const val MODUL_BEITRITT = "beitritt"
Ein QR-Code sieht aus wie ein Schlüssel. Hier ist er eine Türklingel. Der Unterschied ist keine Kleinigkeit: Wer einen Einladungslink weiterreicht, reißt damit keine Gruppe auf — die Nachricht geht an genau eine Person, die entscheidet, und niemand trägt sich selbst ein.
Was ein Server nicht unterscheiden kann
} else if (type === 'gm' && stored.gruppenId && stored.encKeys && !stored.silent) {
// `silent` wie bei friendreq (s. oben): Eine Gruppennachricht ist nicht
// immer eine Nachricht. Nach jedem Geraetewechsel geht die aktualisierte
// Mitgliederliste an alle — Verwaltung, kein Gespraech. Ohne diese
// Unterdrueckung klingelte bei jeder Wiederherstellung "Neue
// Gruppennachricht" bei allen Mitgliedern, ohne dass jemand etwas
// geschrieben hatte (gemeldet von Benjamin am 31.08.2026).
//
// WARUM DER RELAY DAS NICHT SELBST SIEHT: Das Modul steckt im
// verschluesselten `encText`. Er sieht "eine gm" und sonst nichts — das
// ist der Sinn der Sache. Also entscheidet es die Absenderin.
// An jedes Mitglied ausser die Absenderin selbst.
Der ehrlichste Ausschnitt der ganzen Sammlung. Er zeigt eine Unbequemlichkeit der Verschlüsselung statt eines Vorteils: Weil der Server den Inhalt nicht kennt, kann er auch nicht entscheiden, ob etwas eine Meldung wert ist. Die Lösung ist keine Ausnahme vom Prinzip, sondern eine Verschiebung der Entscheidung dorthin, wo der Klartext liegt — aufs Gerät. Wer wissen will, ob eine Verschlüsselung echt ist, achtet auf genau solche Stellen: wo der Server offensichtlich weniger weiß, als er wissen müsste, um bequem zu sein.
Auch die Relay-Seite
Bisher stammen fast alle Ausschnitte aus den Apps. Der Relay ist für die Glaubwürdigkeit aber wichtiger, weil dort der Verdacht sitzt — „was sammelt der Server?" Vier Stellen, die alle nichts Kritisches preisgeben.
Was der Relay über eine Gruppe weiß — und dass es dasteht
/**
* Gruppennachrichten abholen. Zugestellt wird, wofuer ein Eintrag in `encKeys`
* vorliegt — es gibt kein Empfaengerfeld wie `toId` bei der Direktnachricht.
*
* DAMIT SIEHT DER RELAY, WER MIT WEM IN EINER GRUPPE IST. Das laesst sich nicht
* vermeiden: Irgendwoher muss er wissen, an wen er ausliefern soll. Neu ist es
* nicht — bei einem "Nur Freunde"-Beitrag erfaehrt er aus denselben `encKeys`
* schon heute, wer mit wem befreundet ist. Den INHALT sieht er in keinem der
* beiden Faelle. Das gehoert auf die Technologie-Seite, nicht unter den Tisch.
*/
app.post('/gm/fetch', (req, res) => {
Der letzte Satz im Kommentar ist der Punkt. Eine Schwäche, die im eigenen Quelltext benannt wird, ist eine andere Sorte Aussage als eine, die jemand von außen gefunden hat. Und sie ist nicht neu: Bei einem „Nur Freunde"-Beitrag steckt dieselbe Information längst in encKeys.
Aufbewahrung: sieben Tage, und zwar im Code
const RETENTION_DAYS = parseInt(process.env.RETENTION_DAYS || '7', 10);
const MAX_POSTS = parseInt(process.env.MAX_POSTS || '50000', 10);
// …
function prune() {
const cutoff = nowMs() - RETENTION_DAYS * 86400000;
let changed = false;
for (const [id, p] of posts) if ((p.serverTs || 0) < cutoff) { posts.delete(id); changed = true; }
// Obergrenze: älteste zuerst entfernen
if (posts.size > MAX_POSTS) {
const sorted = [...posts.values()].sort((a, b) => a.serverTs - b.serverTs);
// Die Zahl VOR der Schleife festhalten: `posts.size` schrumpft mit jedem
// Loeschen, die Bedingung wanderte also mit und es wurde nur etwa die
// Haelfte des Ueberhangs entfernt. MAX_POSTS wurde dadurch nie erreicht.
const zuViel = posts.size - MAX_POSTS;
for (let i = 0; i < zuViel; i++) { posts.delete(sorted[i].id); changed = true; }
}
if (changed) {
rewriteDisk();
// Die Reichweiten-Buchhaltung mit aufraeumen — sonst waechst sie ewig
// weiter, obwohl die Beitraege laengst weg sind.
for (const id of [...zustellungen.keys()]) if (!posts.has(id)) { zustellungen.delete(id); zustellGeaendert = true; }
for (const id of [...zustellHerkunft.keys()]) if (!posts.has(id)) zustellHerkunft.delete(id);
sichereZustellungen();
}
}
Aufbewahrungsfristen stehen sonst in einer Datenschutzerklärung. Hier stehen sie in der Datei, die sie ausführt, und man kann beides nebeneinanderlegen. Der Kommentar in der Mitte zeigt einen echten, gefundenen und behobenen Fehler: Die Obergrenze wurde nie erreicht, weil sich die Zählung während des Löschens mitverschob.
Eine Direktnachricht bekommt man nur mit Nachweis
const data = `${praefix}|${since}|${createdAt}|${typeof id === 'string' ? id : ''}`;
if (!verifyRawSignature(data, pubKey, signature)) {
// Fingerprint UND IP mitloggen: ohne die war eine abgelehnte Anfrage nicht
// zuzuordnen. Eine leere Signatur bekommt einen eigenen Hinweis, das ist
// der haeufigste Fall (gesperrtes iPhone, s. SecureEnclaveIdentity.sign).
const who = fingerprintOf(pubKey) || 'unbekannt';
const why = !signature ? 'Signatur LEER (Geraet gesperrt?)' : 'Signatur ungueltig';
console.warn(`[relay] /${praefix} abgelehnt: ${why} (fp=${who}, ip=${req.ip})`);
res.status(401).json({ error: 'Signatur ungueltig' }); return null;
}
Der Zugriffs-Token der App ist eingebaut und damit faktisch öffentlich. Für Direktnachrichten reicht er nicht: Wer sie abholen will, muss mit dem privaten Schlüssel unterschreiben, der den Hardware-Chip nie verlässt. Der Unterschied zwischen „eingeloggt" und „im Besitz des Geräts" — an einer Stelle, wo er zählt.
Auch die Fehler stehen drin
// Direktnachrichten gehen NIE an alle. `withinBudget` wird nur im
// post-Zweig oben angefasst und blieb fuer "dm" auf true — jede
// Direktnachricht ging damit live an jeden offenen Client, mit Empfaenger,
// Absender-Schluessel und Zeitpunkt. Der HTTP-Weg schliesst das laengst aus
// (GET /posts filtert dm heraus, s. dort), der WebSocket-Weg nicht. Wer
// wann wem schreibt, lag damit fuer jeden offen, der eine Verbindung
// offenhaelt — genau das Metadaten-Leck, das die DM-Auslieferung ueber
// POST /dm/fetch verhindern sollte.
if (stored.t === 'dm') withinBudget = false;
Der stärkste Ausschnitt der ganzen Sammlung. Hier steht ein echter Fehler — gefunden, behoben, und die Beschreibung ist nicht beschönigt: Jede Direktnachricht ging über den WebSocket-Weg live an jeden offenen Client, mit Empfänger, Absender-Schlüssel und Zeitpunkt. Wer so etwas öffentlich stehen lässt, hat entweder nichts zu verbergen oder ist mutiger als nötig. Beides überzeugt mehr als jedes Sicherheitsversprechen.
Was bewusst nicht gezeigt wird: Gezeigt wird die Kryptografie — Schlüsselerzeugung, Verschlüsselung, Schlüsselableitung, Signaturformat, Reichweitenlogik. Das ist der Teil, den man prüfen können muss; er ist sicher, weil er bekannt ist.
Nicht gezeigt wird die Missbrauchsabwehr: Erkennungsmuster für Bots, Meldeschwellen, Ratenbegrenzung. Dort hilft Offenheit nur dem Angreifer, der lediglich die Schwelle unterlaufen muss. Diese Unterscheidung ist keine Ausrede, sondern der Unterschied zwischen einem Schloss und einer Alarmanlage.
Nichts aus dem Moderations-Panel: Es zeigt Sperren, Meldungen und Dossiers, und selbst ein harmloser Ausschnitt würde die Struktur der Moderation verraten.
Und keine Parameter der Schlüsselableitung als „so sicher ist es"-Aussage: Die Zahlen selbst (Runden, Speicher) dürfen stehen, aber ohne eine Vergleichsrechnung à la „X Jahre zum Knacken". Solche Zahlen altern schlecht und laden zum Widerspruch ein.
Diese Ausschnitte sind ein kuratierter Blick, kein vollständiges Repository und keine Einladung, eigenständig einen Client zu bauen. Fragen zu einer bestimmten Stelle: info@hiy.ch.