Code-Schau
Kein Repo — neun 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. Neun 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
/** 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): SecretKeySpec {
val salt = MessageDigest.getInstance("SHA-256").digest(sub.toByteArray(Charsets.UTF_8))
val spec: KeySpec = PBEKeySpec(pin.toCharArray(), salt, ITERATIONS, KEY_LENGTH_BITS)
val raw = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
return SecretKeySpec(raw, "AES")
600 000 Runden PBKDF2-HMAC-SHA256, AES-256. Der Salt ist berechenbar und darf es sein — Salts müssen eindeutig sein, nicht geheim; nur so lässt sich der Schlüssel auf einem neuen Gerät allein aus der PIN ableiten. Ein aufmerksamer Leser stolpert darüber, also erklärt die Seite es besser selbst.
…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.
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'],
};
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.
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.
Hop-Budget — kein Algorithmus, sondern Arithmetik
Behauptung: „Kein Algorithmus, sondern ein Hop-Budget."
// 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
Der Beweis ist die Kürze: kein Modell, keine Gewichtung, keine Telemetrie. Eine Subtraktion. Lässt sich in zehn Sekunden lesen.
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.
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.
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.