Vetrina del codice
Nessuna repo — 17 estratti dal codice sorgente reale, invariati
Nella home page si legge: "Ogni affermazione qui si può verificare riga per riga nel codice." O è verificabile, oppure è solo una frase — ecco la verifica. 17 punti dal vero codice sorgente di HIY (Android, iOS, Relay), originali e con i commenti originali, esattamente come stanno nel repository. Non abbreviati, non levigati.
Ciò che viene mostrato è la crittografia: generazione delle chiavi, crittografia, derivazione della chiave, formato della firma, logica di portata. È la parte che deve poter essere verificata — è sicura perché è nota. Ciò che deliberatamente non viene mostrato sta in fondo a questa pagina.
Crittografia end-to-end — il formato sul filo
Affermazione: "chat E2EE con 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)
Il formato sul filo è scritto nero su bianco nel commento (in tedesco — sono le note reali, non tradotte, dello sviluppatore). Chi lo conosce può ricalcolare a mano un blob intercettato — è esattamente questo che rende verificabile un'affermazione. Da notare di passaggio: chiavi separate per firmare e cifrare, con la motivazione indicata.
PIN di backup — la derivazione della chiave
Affermazione: "sbloccabile solo con il PIN (600.000 cicli di 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 cicli di PBKDF2-HMAC-SHA256, AES-256.
…e la lunghezza minima, senza la quale il numero di cicli non dice nulla
// 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
Anche 600.000 cicli non varrebbero nulla contro un PIN numerico a quattro cifre. Solo i due numeri insieme danno un'affermazione verificabile. La progressione 4 → 6 → 8 → 10 è annotata apposta accanto.
…e la derivazione vera e propria, oggi ancora 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",
)
}
La firma della funzione tradisce il passo successivo: un parametro verfahren ("metodo"), con ARGON2ID come valore predefinito. Oggi però i nuovi backup passano ancora dal ramo PBKDF2 — un interruttore altrove nello stesso file ce li tiene, finché l'app iOS non saprà leggere Argon2id. Passare troppo presto renderebbe illeggibili i backup destinati a essere aperti sull'altro sistema operativo. Anche un lavoro non ancora finito ha diritto a stare su questa pagina, purché sia dichiarato onestamente come tale.
Campi firmati — cosa copre una firma
Affermazione: "niente può essere sostituito senza essere notato."
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'],
};
Il commento documenta una falla TROVATA E CHIUSA: avatarUrl non era firmato, un relay poteva sostituire la foto profilo di un commento comunque validamente firmato e "✅ verificato". Mostrare apertamente la propria falla è più credibile di qualsiasi promessa di sicurezza. La riga più recente (gm, dal 31.08.2026) mostra che lo schema cresce insieme al prodotto: i messaggi di gruppo ricevono la stessa protezione dei messaggi diretti, non una propria e più debole.
La seconda falla nella stessa funzione, mesi dopo
/** 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) {
Due falle documentate nella stessa funzione — e l'ammissione onesta che la soluzione pulita (un prefisso di lunghezza) non è ancora arrivata. Questa trasparenza su un resto irrisolto è più credibile di una pagina dove tutto sembra finito.
Budget di portata — aritmetica, non un algoritmo
Affermazione: "nessun algoritmo, solo un budget di portata."
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);
}
Quattro righe. Nessun modello, nessuna ponderazione, nessuna telemetria — una moltiplicazione e un confronto. È il percorso da cui passa praticamente tutta la consegna: conta quanti dispositivi ricevono un post nel feed "Tutti", non quanti lo inoltrano. E la terza riga è la più importante dal punto di vista umano — audience !== 'all' lascia passare tutto il resto senza ostacoli: solo "Tutti" ha un limite. "Amici", "Locale" e "Solo io" sono indirizzati, non trasmessi, e arrivano a tutti i destinatari previsti. Scegliere una portata piccola non mette a tacere i propri amici — limita solo quanto un post si diffonde tra gli sconosciuti.
// 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
Solo sulla rete locale il calcolo è così: un contatore di hop che sale di 1 a ogni inoltro da dispositivo a dispositivo (e scende invece di salire per gli amici dell'autore). È il percorso sullo stesso Wi-Fi — l'eccezione, non la regola. Entrambi i percorsi sono aritmetica, non un algoritmo, solo diversa: uno conta gli inoltri, l'altro conta i ritiri.
Identità nel chip — Android
Affermazione: "la chiave privata non lascia mai il chip."
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 preferito, con ripiego sul Keystore normale. Questo ripiego ha senso — non tutti i dispositivi hanno StrongBox, e nasconderlo sarebbe esattamente il tipo di mezza verità che questa pagina rimprovera agli altri.
Identità nel 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 è l'unica riga che conta davvero. Che due implementazioni indipendenti producano lo stesso formato è di per sé un argomento — e anche qui il ripiego è dichiarato apertamente proprio accanto.
Cosa NON c'è in una notifica
// 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.
Riguarda un limite, non una capacità: una notifica push deve passare per forza da Apple o Google. L'unica domanda è cosa c'è scritto dentro. Google e Apple vedono CHE qualcosa è arrivato e QUANDO — non cosa, e non da chi.
I moduli di gruppo — una sola risposta per tutti
/**
* 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"
Quattro moduli che fanno una domanda — sondaggio, appuntamento, doodle, lista — e un'unica forma di risposta per tutti. È una decisione di design, non una riga di codice: le altre tre vie (un formato proprio per ogni modulo) sono state considerate e scartate. Il paragrafo "WARUM DAS OHNE ABSPRACHE FUNKTIONIERT" ("perché funziona senza accordarsi") risolve di passaggio un vero rompicapo — come può "cambiare" la risposta a un sondaggio se nessuno può modificare un messaggio cifrato già inviato: non si cambia nulla, lo si ridice, e vale l'ultima versione.
Chi scansiona, bussa
/**
* "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 codice QR sembra una chiave. Qui è un campanello. La differenza non è da poco: inoltrare un link d'invito non spalanca un gruppo — il messaggio va a esattamente una persona, che decide, e nessuno si iscrive da solo.
Cosa un server non può distinguere
} 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'estratto più onesto di tutta la raccolta. Mostra uno svantaggio della crittografia invece di un vantaggio: siccome il server non conosce il contenuto, non può nemmeno decidere se qualcosa merita una notifica. La soluzione non è un'eccezione al principio, ma uno spostamento della decisione dove sta il testo in chiaro — sul dispositivo. Chi vuole sapere se una crittografia è reale osserva esattamente questi punti: dove il server sa evidentemente meno di quanto dovrebbe sapere per essere comodo.
Anche il lato relay
Finora quasi tutti gli estratti venivano dalle app. Ma il relay conta di più per la credibilità, perché è lì che si annida il sospetto — "cosa raccoglie il server?" Quattro punti che non rivelano nulla di critico.
Cosa sa il relay di un gruppo — e che è scritto nero su bianco
/**
* 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) => {
L'ultima frase del commento è il punto centrale. Una debolezza dichiarata nel codice sorgente stesso è un altro tipo di affermazione rispetto a una trovata dall'esterno. E non è nuova: in un post "Solo amici" la stessa informazione sta già in encKeys.
Conservazione: sette giorni, e sta scritto nel codice
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();
}
}
I periodi di conservazione di solito stanno in un'informativa sulla privacy. Qui stanno nel file che li esegue, e si possono mettere fianco a fianco. Il commento in mezzo mostra un vero bug, trovato e corretto: il tetto massimo non veniva mai raggiunto, perché il conteggio si spostava durante l'eliminazione.
Un messaggio diretto si ottiene solo con una prova
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;
}
Il token d'accesso dell'app è integrato nel codice e quindi di fatto pubblico. Non basta per i messaggi diretti: per recuperarli bisogna firmare con la chiave privata che non lascia mai il chip hardware. La differenza tra "connesso" e "in possesso del dispositivo" — in un punto dove conta davvero.
Anche gli errori sono lì dentro
// 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'estratto più forte di tutta la raccolta. Qui c'è un vero bug — trovato, corretto, e descritto senza abbellimenti: ogni messaggio diretto andava live, via WebSocket, a ogni client connesso, con destinatario, chiave del mittente e orario. Lasciare una cosa così visibile pubblicamente significa non avere nulla da nascondere, oppure essere più coraggiosi del necessario. Entrambe le cose convincono più di qualsiasi promessa di sicurezza.
Cosa non viene mostrato deliberatamente: ciò che viene mostrato è la crittografia — generazione delle chiavi, crittografia, derivazione della chiave, formato della firma, logica di portata. È la parte che deve poter essere verificata; è sicura perché è nota.
Non viene mostrata la difesa dagli abusi: pattern di rilevamento bot, soglie di segnalazione, limitazione della frequenza. Lì l'apertura aiuta solo l'aggressore, che deve solo restare sotto la soglia. Questa distinzione non è una scusa, ma la differenza tra una serratura e un allarme.
Niente dal pannello di moderazione: mostra blocchi, segnalazioni e dossier, e anche un estratto innocuo rivelerebbe la struttura della moderazione.
E nessun parametro di derivazione della chiave usato come affermazione "ecco quanto è sicuro": i numeri stessi (cicli, memoria) possono restare, ma senza un calcolo comparativo del tipo "X anni per craccarlo". Numeri del genere invecchiano male e invitano alla contraddizione.
Questi estratti sono uno sguardo curato, non un repository completo né un invito a costruire da sé un client. Domande su un punto specifico: info@hiy.ch.