Vitrine du code
Pas de dépôt — 17 extraits du code source réel, non modifiés
La page d'accueil affirme : « Chaque affirmation ici se vérifie ligne par ligne dans le code. » Ça se vérifie, ou ce n'est qu'une phrase — voici la vérification. 17 endroits du vrai code source de HIY (Android, iOS, Relay), à l'original et avec les commentaires d'origine, tels qu'ils apparaissent dans le dépôt. Ni raccourcis, ni lissés.
Ce qui est montré, c'est la cryptographie : génération de clés, chiffrement, dérivation de clé, format de signature, logique de portée. C'est la partie qui doit pouvoir être vérifiée — elle est sûre parce qu'elle est connue. Ce qui n'est délibérément pas montré figure en bas de cette page.
Chiffrement de bout en bout — le format sur le fil
Affirmation : « des discussions E2EE avec 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)
Le format sur le fil est écrit noir sur blanc dans le commentaire (en allemand — ce sont les notes réelles, non traduites, du développeur). Quiconque le connaît peut recalculer à la main un blob intercepté — c'est exactement ce qui rend une affirmation vérifiable. À noter en passant : des clés séparées pour signer et chiffrer, avec la raison donnée.
PIN de sauvegarde — la dérivation de clé
Affirmation : « déverrouillable uniquement avec le PIN (600 000 tours de 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 tours de PBKDF2-HMAC-SHA256, AES-256.
…et la longueur minimale, sans laquelle le nombre de tours ne dit rien
// 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
Même 600 000 tours ne vaudraient rien face à un PIN numérique à quatre chiffres. Seuls les deux chiffres ensemble donnent une affirmation vérifiable. La progression 4 → 6 → 8 → 10 est délibérément notée à côté.
…et la dérivation elle-même, encore en PBKDF2 aujourd'hui
/** 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",
)
}
La signature de la fonction trahit la suite : un paramètre verfahren (« méthode »), avec ARGON2ID comme valeur par défaut. Aujourd'hui pourtant, les nouvelles sauvegardes passent encore par la branche PBKDF2 — un interrupteur ailleurs dans le même fichier les y maintient, en attendant que l'app iOS sache lire l'Argon2id. Basculer trop tôt rendrait illisibles les sauvegardes destinées à être ouvertes sur l'autre système. Même un chantier pas encore terminé a sa place sur cette page, tant qu'il est nommé honnêtement comme tel.
Champs signés — ce qu'une signature couvre
Affirmation : « rien ne peut être substitué sans être détecté. »
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'],
};
Le commentaire documente une faille TROUVÉE ET CORRIGÉE : avatarUrl n'était pas signé, un relais pouvait substituer la photo de profil d'un commentaire pourtant signé et « ✅ vérifié ». Montrer sa propre faille ouvertement est plus crédible que n'importe quelle promesse de sécurité. La ligne la plus récente (gm, depuis le 31.08.2026) montre que ce schéma grandit avec le produit : les messages de groupe reçoivent la même protection que les messages directs, pas une protection propre et plus faible.
La seconde faille dans la même fonction, des mois plus tard
/** 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) {
Deux failles documentées dans la même fonction — et l'aveu honnête que la solution propre (un préfixe de longueur) n'est pas encore là. Cette transparence sur un reste inachevé est plus crédible qu'une page où tout semble fini.
Budget de portée — de l'arithmétique, pas un algorithme
Affirmation : « pas d'algorithme, juste un budget de portée. »
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);
}
Quatre lignes. Aucun modèle, aucune pondération, aucune télémétrie — une multiplication et une comparaison. C'est le chemin par lequel passe pratiquement toute la distribution : il compte combien d'appareils récupèrent une publication dans le flux « Tout le monde », pas combien la retransmettent. Et la troisième ligne est la plus importante humainement — audience !== 'all' laisse passer tout le reste sans entrave : seul « Tout le monde » est limité. « Ami·e·s », « Local » et « Moi seul·e » sont adressés, pas diffusés, et vont à tous les destinataires prévus. Choisir une petite portée ne réduit pas au silence ses propres ami·e·s — cela limite seulement la propagation parmi les inconnu·e·s.
// 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
Ce n'est que sur le réseau local que le calcul ressemble à ça : un compteur de sauts qui monte de 1 à chaque transmission d'appareil à appareil (et qui baisse au lieu de monter pour les ami·e·s de l'auteur·ice). C'est le chemin sur le même Wi-Fi — l'exception, pas la règle. Les deux chemins sont de l'arithmétique, pas un algorithme, juste différente : l'un compte les retransmissions, l'autre compte les récupérations.
Identité dans la puce — Android
Affirmation : « la clé privée ne quitte jamais la puce. »
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 privilégié, avec repli sur le Keystore normal. Ce repli a sa place — tous les appareils n'ont pas de StrongBox, et le cacher serait exactement le genre de demi-vérité que cette page reproche aux autres.
Identité dans la puce — 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 est la seule ligne qui compte vraiment. Que deux implémentations indépendantes produisent le même format est en soi un argument — et ici aussi, le repli est affiché ouvertement juste à côté.
Ce qui n'est PAS dans une notification
// 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.
Une question de limite, pas de capacité : une notification push passe forcément par Apple ou Google. La seule question est ce qu'elle contient. Google et Apple voient QUE quelque chose est arrivé et QUAND — pas quoi, et pas de qui.
Les modules de groupe — une seule réponse pour tous
/**
* 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"
Quatre modules qui posent une question — sondage, événement, doodle, liste — et une seule forme de réponse pour tous. C'est une décision de conception, pas une ligne de code : les trois autres voies (un format propre à chaque module) ont été envisagées, puis écartées. Le paragraphe « WARUM DAS OHNE ABSPRACHE FUNKTIONIERT » (« pourquoi ça marche sans concertation ») résout au passage une vraie énigme — comment une réponse à un sondage peut « changer » quand personne ne peut modifier un message chiffré déjà envoyé : on ne change rien, on le redit, et c'est la dernière version qui compte.
Qui scanne, frappe à la porte
/**
* "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"
Un QR code a l'air d'une clé. Ici, c'est une sonnette. La différence n'est pas mince : faire suivre un lien d'invitation n'ouvre pas un groupe en grand — le message va à exactement une personne, qui décide, et personne ne s'inscrit soi-même.
Ce qu'un serveur ne peut pas distinguer
} 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.
L'extrait le plus honnête de toute la collection. Il montre un inconvénient du chiffrement plutôt qu'un avantage : parce que le serveur ne connaît pas le contenu, il ne peut pas non plus décider si quelque chose mérite une notification. La solution n'est pas une exception au principe, c'est un déplacement de la décision là où se trouve le texte en clair — sur l'appareil. Qui veut savoir si un chiffrement est réel guette exactement ce genre d'endroits : là où le serveur en sait visiblement moins qu'il ne le faudrait pour être pratique.
Le relais aussi
Presque tous les extraits jusqu'ici venaient des applications. Or le relais compte davantage pour la crédibilité, car c'est là que loge le soupçon — « qu'est-ce que le serveur collecte ? » Quatre endroits qui ne dévoilent tous rien de critique.
Ce que le relais sait d'un groupe — et que c'est écrit noir sur blanc
/**
* 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) => {
La dernière phrase du commentaire est le point essentiel. Une faiblesse nommée dans le code source lui-même est une tout autre sorte d'affirmation que celle trouvée de l'extérieur. Et ce n'est pas nouveau : sur une publication « Ami·e·s uniquement », la même information se trouve déjà dans encKeys.
Conservation : sept jours, écrit dans le 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();
}
}
Les délais de conservation figurent d'ordinaire dans une politique de confidentialité. Ici, ils figurent dans le fichier qui les applique, et on peut poser les deux côte à côte. Le commentaire au milieu montre un vrai bug, trouvé et corrigé : le plafond n'était jamais atteint, parce que le comptage se décalait pendant la suppression.
Un message direct ne s'obtient qu'avec une preuve
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;
}
Le jeton d'accès de l'app est intégré au code et donc de facto public. Il ne suffit pas pour les messages directs : pour les récupérer, il faut signer avec la clé privée qui ne quitte jamais la puce matérielle. La différence entre « connecté » et « en possession de l'appareil » — à un endroit où elle compte vraiment.
Les bugs aussi sont là
// 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;
L'extrait le plus fort de toute la collection. Voici un vrai bug — trouvé, corrigé, et décrit sans le moindre enjolivement : chaque message direct partait en direct, via le WebSocket, vers chaque client connecté, avec destinataire, clé de l'expéditeur·ice et horodatage. Laisser une chose pareille visible publiquement, c'est soit n'avoir rien à cacher, soit être plus courageux que nécessaire. Les deux sont plus convaincants que n'importe quelle promesse de sécurité.
Ce qui n'est délibérément pas montré : ce qui est montré, c'est la cryptographie — génération de clés, chiffrement, dérivation de clé, format de signature, logique de portée. C'est la partie qui doit pouvoir être vérifiée ; elle est sûre parce qu'elle est connue.
N'est pas montrée la défense contre les abus : schémas de détection des bots, seuils de signalement, limitation de débit. L'ouverture n'y aide que l'attaquant, qui n'a plus qu'à rester sous le seuil. Cette distinction n'est pas une excuse, c'est la différence entre une serrure et une alarme.
Rien du panneau de modération : il montre les blocages, les signalements et les dossiers, et même un extrait anodin trahirait la structure de la modération.
Et aucun paramètre de dérivation de clé présenté comme une preuve de sécurité : les chiffres eux-mêmes (tours, mémoire) peuvent rester, mais sans calcul comparatif du type « X années pour casser ». Ce genre de chiffres vieillit mal et appelle la contradiction.
Ces extraits sont un regard sélectionné, pas un dépôt complet ni une invitation à construire son propre client. Questions sur un endroit précis : info@hiy.ch.