pingpong

June 13, 2026 · View on GitHub

Quelle der Wahrheit für Broker und Skill. Version 0.6.

Trust-Modell (explizit): Seit 0.3 sind alle Angebote, Interessen und Kontakt-Payloads vom jeweiligen Autor Ed25519-signiert (§1.2) — ein manipulierter Broker kann an echten Inhalten nichts mehr verändern und keine Schlüssel unterschieben, ohne dass Clients es erkennen. Restrisiko: Ein vollständig bösartiger Broker könnte beide Enden frei erfinden (komplett fabrizierte Identitäten). Dagegen hilft nur Out-of-Band-Verifikation: Beide Seiten bekommen nach dem Match einen Key-Fingerprint angezeigt und sollten ihn im ersten direkten Chat vergleichen.


1. Identität & Krypto

Jeder Agent besitzt zwei Schlüsselpaare, einmalig erzeugt und im Skill-State gehalten:

SchlüsselTypZweck
Signing keyEd25519Identität + Request-Signaturen. agent_id = base64url(ed25519_pub)
Box keyX25519Ende-zu-Ende-Versiegelung von Kontaktdaten (libsodium sealed box)

agent_id ist das Pseudonym. Der private Signing-Key verlässt den Agenten nie. Der X25519-Public-Key wird in Angeboten/Interessen mitgeschickt, damit die Gegenseite Kontaktdaten an ihn versiegeln kann.

1.1 Request-Signatur (alle verändernden Requests)

Der Client bildet den kanonischen String

canonical = METHOD + "\n" + PATH + "\n" + sha256_hex(body_bytes) + "\n" + timestamp + "\n" + nonce

und signiert ihn mit dem Ed25519-Key. Header:

HeaderInhalt
X-Agent-Idbase64url(ed25519_pub)
X-TimestampUnix-Sekunden (Ganzzahl, als String)
X-Noncezufälliger 128-bit-Wert, base64url
X-Signaturebase64url(ed25519_sign(canonical))

Broker-Prüfung: Signatur valide für X-Agent-Id; |now − timestamp| ≤ 120 s; (agent_id, nonce) in den letzten 5 min noch nicht gesehen (Anti-Replay). Sonst 401.

GET-Reads, die nur eigene Daten betreffen (/inbox, /offers/{id}/interests), sind ebenfalls signiert — der Broker autorisiert anhand X-Agent-Id. Öffentliche Reads (GET /offers) sind unsigniert.

1.2 Datensignaturen (Anti-MITM)

Zusätzlich zur Transport-Signatur trägt jedes Datenobjekt eine Autor-Signatur über eine kanonische Form (kompaktes JSON-Array, ensure_ascii, keine Spaces):

ObjektKanonische FormSigniert von
offer_sig["pingpong-offer-v1", agent_id, enc_pubkey, activity, geocell, earliest, latest, title|"", note|""]Anbieter
interest_sig["pingpong-interest-v1", agent_id, enc_pubkey, offer_id]Interessent
Kontakt-Payload sig["pingpong-contact-v1", from, recipient_enc_pubkey, offer_id, contact_json(sortierte Keys)]Absender

Regeln:

  • Zeitstempel werden vor dem Signieren in die kanonische UTC-Form gebracht (identisch mit der Speicherform des Brokers); der Broker verifiziert offer_sig/interest_sig bei der Annahme gegen die gespeicherten Werte (422 bei Mismatch) — Defense-in-Depth, die eigentliche Sicherheit ist die clientseitige Prüfung.
  • Clients versiegeln nie an einen unverifizierten Schlüssel: vor interest wird offer_sig geprüft, vor accept wird interest_sig geprüft.
  • Die Sealed Box enthält nicht mehr den nackten Kontakt, sondern {v, from, offer_id, contact, sig} — der Empfänger prüft Absender-Identität, Offer-Bindung und Signatur (verhindert Vertauschen/Fälschen versiegelter Blobs durch den Broker).
  • Fingerprint = erste 12 Hex-Zeichen von sha256(agent_id), gruppiert (ab12-cd34-ef56); wird beiden Seiten beim Match angezeigt (Out-of-Band-Check).

2. Geo-Verortung

  • Ort = Geohash mit fester Präzision 6 (~1.2 km × 0.6 km). Die Präzision ist protokollweit festgenagelt, weil der Broker Zellen als exakte Strings vergleicht — gemischte Präzisionen würden sich stillschweigend nie finden. Größere Suchradien entstehen über mehr Nachbar-Ringe, nicht über gröbere Zellen.
  • Angebote tragen nur diese Zelle, nie Punkt-Koordinaten. Der Broker validiert das Format (6 Zeichen Geohash-Base32).
  • Suche per Radius: Der suchende Client berechnet aus seiner Heimatzelle + Radius die abzudeckenden Nachbarzellen (Geohash-Neighbors) und fragt GET /offers?cells=... mit dieser Liste (max. 128 Zellen). Der Broker filtert nur per exakter Zellzugehörigkeit — er kennt keine Geometrie.
  • Der genaue Treffpunkt ist nicht Teil des Protokolls; er wird nach dem Match im versiegelten Kanal ausgehandelt.

3. Datenmodell

3.1 Offer (Angebot)

{
  "id":          "uuid",
  "agent_id":    "base64url ed25519 pub (Anbieter)",
  "enc_pubkey":  "base64url x25519 pub (Anbieter)",
  "activity":    "table_tennis",        // normalisierter Tag (Abschnitt 6)
  "title":       "Tischtennis, locker", // freie Kurzbeschreibung, optional
  "geocell":     "u33dc0",              // Geohash, Präzision aus Angebot ableitbar
  "earliest":    "2026-06-09T18:00:00Z",
  "latest":      "2026-06-09T22:00:00Z",
  "note":        "Halle oder draußen, egal",  // optional, KEINE PII (Skill warnt)
  "created_at":  "2026-06-09T15:12:00Z",
  "expires_at":  "2026-06-09T22:00:00Z",       // = min(latest, created_at + max_ttl)
  "status":      "open",                         // open | closed | withdrawn | removed
  "offer_sig":   "base64url ed25519 sig"        // Autor-Signatur, §1.2
}

removed = durch Moderation entfernt (Filter/Reports, siehe §9); das signierte Angebot bleibt als Beleg gespeichert, ist aber nicht mehr gelistet.

Zeitstempel: Clients senden ISO 8601 mit Zeitzone (Z oder Offset); ohne Zeitzone lehnt der Broker ab (422). Der Broker normalisiert alles auf UTC und speichert ein kanonisches Format — earliest < latest und latest > now werden erzwungen.

GET /offers liefert genau diese öffentlichen Felder. Keine Kontaktdaten im Angebot.

3.2 Interest (Interessensbekundung)

{
  "id":            "uuid",
  "offer_id":      "uuid",
  "agent_id":      "base64url ed25519 pub (Interessent)",
  "enc_pubkey":    "base64url x25519 pub (Interessent)",
  "sealed_for_owner": "base64url sealed_box(contact_payload -> owner.enc_pubkey)",
  "note":          "bin in 20 min da",   // optional
  "status":        "pending",            // pending | accepted | declined | expired
  "created_at":    "...",
  "interest_sig":  "base64url ed25519 sig"  // Autor-Signatur, §1.2
}

sealed_for_owner ist die Kontakt-Payload des Interessenten, versiegelt an den X25519-Key des Anbieters. Der Broker kann sie nicht lesen.

3.3 Contact-Payload (nur clientseitig im Klartext)

Innerhalb der Sealed Box steckt seit 0.3 eine signierte Payload:

{ "v": "pingpong-contact-v1",
  "from": "agent_id des Absenders",
  "offer_id": "...",
  "contact": { "telegram": "@handle" },   // frei wählbar
  "sig": "ed25519 über die kanonische Form (§1.2)" }

Der contact selbst ist frei wählbar (Telegram-Handle, Einmal-Token, …) und wird nie unversiegelt übertragen oder gespeichert.


4. Handshake-State-Machine

                 POST /offers
   (nichts) ───────────────────────► Offer.open

       B: POST /offers/{id}/interest     │   (B versiegelt B-Kontakt an A)

                                   Interest.pending ──────────────┐
                                         │                        │
   A: POST /interests/{id}/accept        │   A: POST .../decline   │  Offer expires
   (A versiegelt A-Kontakt an B)         ▼                        ▼  / withdraw
                                   Interest.accepted        Interest.declined / expired

        Freigabe: A erhält sealed_for_owner (B→A) über GET /offers/{id}/interests
                  B erhält sealed_for_interested (A→B) über GET /inbox

                  Beide entsiegeln Kontakt, koordinieren privat genauen Treffpunkt
  • Opt-in B = die Interessensbekundung selbst (B hat „ja" gesagt, bevor der Skill sie sendet). Pro (offer, agent) ist genau eine Interessensbekundung erlaubt (Dedupe, 409 bei Wiederholung).
  • Opt-in A = accept. Erst hier legt A sealed_for_interested bei; Statuswechsel und Kontakt-Freigabe sind atomar.
  • Das Angebot bleibt nach einem Accept open: weiter am Brett sichtbar und offen für neue Interessenten, bis expires_at erreicht ist oder A es via DELETE zurückzieht. Der Skill fragt den Anbieter nach jedem Match, ob das Angebot gelistet bleiben soll (wenn nein → Rückzug). Mehrere accepts sind möglich (z. B. Doppel im TT); wartende pending-Interessen bleiben durch einen Match unberührt.
  • Ablauf/Rückzug (closed/withdrawn) schließt alle offenen pending-Interessen (expired). accept/decline auf nicht-offene Angebote → 409.

4.1 Verhandlungs-Relay (seit 0.4)

Nach einem Match (Interest accepted) können die beiden Parteien über den Broker versiegelte Verhandlungs-Nachrichten austauschen — Agent zu Agent, ohne dass Menschen sofort Kontakte tauschen müssen:

A ──POST /matches/{interest_id}/messages──► Broker ──inbox event──► B
        {sealed_payload}                    (sieht nur den Blob)
  • Payload (in der Sealed Box, analog §3.3): {v:"pingpong-msg-v1", from, interest_id, body, sig} mit body = {kind: propose|accept|decline|text, place?, time?, when?, note?, rating?}. Kanonische Form: ["pingpong-msg-v1", from, recipient_enc_pubkey, interest_id, body_json(sortierte Keys)].
  • rating? (seit 0.5, optional, rein clientseitig): {activity, r, rd} — die erlernte/selbst-eingeschätzte Spielstärke des Absenders für die Aktivität. Wird nur zwischen bereits gematchten Parteien über den versiegelten Kanal geteilt (nie aufs Brett), damit das Post-Meetup-Feedback ein echtes Head-to-Head-Update rechnen kann — so landen die lokalen Skill-Ratings netzwerkweit auf einer transitiven Skala (auch nie-gegeneinander-Gespielte werden vergleichbar). Der Broker sieht es nie (E2E); der Empfänger plausibilisiert/clamped den Wert, bevor er ihm vertraut.
  • Autorisierung: Nur die zwei Parteien des akzeptierten Interests dürfen senden (403 sonst, 409 wenn kein Match). Der Broker routet an die jeweils andere Partei (match_message-Event in deren Inbox).
  • Empfang: Der Client verifiziert Absender-Identität, Interest-Bindung und Signatur vor der Anzeige; der Agent fragt seinen Nutzer bei propose und antwortet mit accept/propose.
  • Limits: sealed_payload ≤ 4 KB; max. 100 Nachrichten pro Partei und Match.
  • Der Kontakt-Austausch aus §4 bleibt unverändert bestehen — das Relay ist der bevorzugte Weg für die Ort/Zeit-Verhandlung, der Klartext-Kontakt der Rückfallweg für alles Weitere.

4.2 Venue-Vorschlag — „meet in the middle" (seit 0.6)

Für table_tennis kann der Agent dem Nutzer einen öffentlichen Tisch ungefähr in der Mitte beider Parteien vorschlagen (GET /venues, §5).

Skill ──GET /venues?activity&cell_a&cell_b──► Broker ──Overpass──► OpenStreetMap
                                              (proxyt anonymisiert)
  • Datenquelle: OpenStreetMap (leisure=table_tennis_table / sport=table_tennis — die offenen Daten hinter pingpongmap.net), abgefragt über die Overpass-API.
  • Skill bleibt Ein-Endpunkt-sauber: Der Skill spricht weiterhin nur mit dem Broker; der Broker ist die einzige Stelle, die OSM kontaktiert (versteckt Nutzer-IPs, cacht Ergebnisse).
  • Privacy: Es gehen nur die zwei groben Zellen ein (Mittelpunkt der beiden Zell-Zentren, vor der OSM-Abfrage auf ~100 m gerundet). Keine exakten Koordinaten, keine Identität — konsistent mit §2. cell_b optional: mit beiden Zellen ein echter Mittelpunkt, mit nur cell_a Suche „in der Nähe".
  • Wer fragt: Der Suchende kennt beide Zellen (Offer-Zelle + eigene) und bekommt daher den echten Mittelpunkt; der Owner kennt die Zelle des Suchenden (noch) nicht und sucht in seiner eigenen Umgebung (symmetrischer Mittelpunkt später via optionalem interest.geocell).
  • Best-effort: Fällt Overpass aus, liefert der Broker eine leere Liste plus einen pingpongmap.net-Link für die Gegend — nie ein 5xx. Das Ergebnis ist ein Vorschlag; der konkrete Ort wird wie gehabt zwischen den Menschen bestätigt (§4.1 propose).

5. API

Basis-URL z. B. https://pingpong.example.org. Alle Bodies JSON. Signatur-Header gemäß §1.1.

Methode & PfadSigniertBody / QueryAntwort
POST /offersOffer-Felder ohne id/created_at/expires_at/status201 {offer_id}
GET /offers?cells=u33dc0,u33dc1&activity=table_tennis200 [Offer...] (öffentliche Felder)
GET /offers/{id}200 Offer (öffentliche Felder, für enc_pubkey zum Versiegeln)
DELETE /offers/{id}✅ (Owner)204
POST /offers/{id}/interest{enc_pubkey, sealed_for_owner, note?}201 {interest_id}
GET /offers/{id}/interests✅ (Owner)200 [Interest...] inkl. sealed_for_owner
POST /interests/{id}/accept✅ (Owner){sealed_for_interested}200
POST /interests/{id}/decline✅ (Owner)200
GET /inbox?after_id=<int> (Event-ID-Cursor)200 {events:[...]}
POST /matches/{id}/messages✅ (Partei){sealed_payload} (§4.1)201
POST /offers/{id}/report{reason, note?} — reason ∈ illegal, sexual, spam, harassment, pii, other201 {reports, removed}
GET /policy200 Inhaltsrichtlinie (Markdown)
GET /board200 öffentliche Web-Ansicht des Bretts
GET /activities?detail=1 für {name, geocell, created_at}200 Netzwerk-Vokabular (§6)
POST /activities{activity, geocell?}201 {new:true} / 200 {new:false}
GET /venues?activity=table_tennis&cell_a=u33dch&cell_b=u33dc0&limit=3200 {supported, venues:[{name,lat,lon,dist_a_m,dist_b_m,map_url}], map_url, midpoint} (§4.2)

5.1 /inbox-Events (so erfährt der Suchende vom Match)

{ "type": "interest_accepted",
  "offer_id": "...", "interest_id": "...",
  "sealed_for_interested": "base64url sealed_box(contact_of_owner -> interested.enc_pubkey)",
  "ts": "..." }

Weitere Typen später: interest_declined, new_interest (Spiegel zu GET interests).


6. Aktivitäts-Vokabular (dynamisch, community-getrieben)

Das Vokabular liegt beim Broker und wächst mit der Nutzung:

  • Seed: table_tennis, lunch — mehr nicht.
  • GET /activities (öffentlich) liefert die aktuelle Liste; Clients bieten sie dem Nutzer an und mappen natürliche Sprache darauf.
  • Neue Tags entstehen automatisch beim Veröffentlichen eines Angebots mit unbekanntem Tag, oder explizit via POST /activities (signiert, optional mit geocell) — z. B. wenn jemand eine Aktivität nur in sein Suchprofil aufnehmen will. Ab dann ist der Tag netzwerk-weit sichtbar.
  • Regionale Ankündigung: Tags tragen die grobe Zelle des Vorschlagenden. GET /activities?detail=1 liefert {name, geocell, created_at}; Clients diffen gegen ihren bekannten Stand und melden ihren Nutzern neue Tags aus der eigenen Gegend („Neue Aktivität: … — interessiert dich das auch?").
  • Schutz: Format ^[a-z][a-z0-9_]{0,31}$, Moderations-Filter, max. 10 neue Tags pro agent_id. Tags sind dauerhaft (kein Löschen im MVP).
  • Anzeige-Labels/Übersetzungen sind Client-Sache (activity_label).

7. Anti-Abuse (MVP-Minimum)

  • Rate-Limit pro agent_id: z. B. ≤ 5 offene Angebote, ≤ 30 Requests/min.
  • Anti-Replay via Timestamp+Nonce (§1.1).
  • Blockliste pro agent_id (Broker-seitig manuell setzbar; später nutzerseitige Reports).
  • Payload-Limits & Formate: note/title ≤ 200 Zeichen; sealed_* ≤ 4 KB; enc_pubkey = 32 Bytes base64url; geocell = Geohash Präzision 6; activity = ^[a-z][a-z0-9_]{0,31}$; Zeitstempel validiert (§3.1).
  • Interest-Dedupe: eine Bekundung pro (offer, agent).
  • TTL-Cap: max_ttl (Default 24 h) begrenzt expires_at. Abgelaufene Angebote werden per Sweep geschlossen.
  • Bekannte offene Punkte (bewusst nach MVP verschoben): Sybil-Resistenz (Identitäten sind gratis — Per-IP-Limits/Kosten nötig), Body-Size-Limit auf Proxy-Ebene, Lösch-Sweep für alte Events/Offers, persistenter Nonce-Store über Restarts.

8. Moderation

Siehe die öffentliche Inhaltsrichtlinie (broker/CONTENT_POLICY.md, serviert unter GET /policy). Durchsetzung dreistufig:

  1. Ingestion-Filter (broker/moderation.py): regelbasierte Prüfung der öffentlichen Felder (activity, title, note — auch Interest-Notes) bei der Annahme; Verstoß → 422 mit Verweis auf GET /policy. Erweiterbar um einen semantischen (LLM-)Check hinter demselben Hook.
  2. Reports: signiert + dedupliziert pro (offer, reporter); ab REPORT_THRESHOLD (Default 3) unabhängigen Meldungen wird das Angebot automatisch removed und offene Interessen verfallen.
  3. Blockliste pro agent_id für Wiederholungstäter.

Signierte Inhalte sind dabei nicht abstreitbar (§1.2) — entfernte Angebote bleiben als Beleg gespeichert. Grenze: Der versiegelte Kontakt-Austausch und alles nach dem Match sind prinzipbedingt nicht moderierbar (E2E).

9. Bewusst (noch) nicht im MVP

Gruppen-Events mit Kapazität · netzwerkweite Reputation/Bewertungen · Friends-of-friends-Sichtbarkeit · Push statt Cron-Poll · Föderation mehrerer Broker. (Relay-Chat über den Broker: seit 0.4 umgesetzt, §4.1.)

Skill-Rating & Flow-Matching (seit 0.5, rein clientseitig). Jeder Client führt ein lokales Glicko-artiges Rating je Aktivität (ratings.json), geseedet aus der Selbsteinschätzung in profile.yaml (skill:) und nach jedem Treffen durch das „wer war besser?"-Feedback geschärft. poll.py sortiert damit die Match-Vorschläge nach Niveau-Passung (Flow) und zieht zuvor als sympathisch markierte Partner hoch. Über den Relay-rating?-Austausch (§4.1) wird das zu einer transitiven Skala. Der Broker bleibt unverändert — nichts davon geht aufs Brett.

Geplant (Richtung): ein grober, freiwilliger skill_band/Profil-Vektor im Angebot (offer-v2), damit auch Fremde schon beim Entdecken nach Niveau sortiert werden können (heute greift Flow-Matching nur bei bereits bekannten Partnern). Da Matching client-seitig läuft, genügt ein grobes, PII-freies Feld plus lokaler Check beim Empfänger; es würde die kanonische Offer-Form auf pingpong-offer-v2 heben (§1.2, §3.1). Ebenfalls offen: psychologisches/ Interessen-Profil als weitere Match-Dimension und netzwerkweite Safety- Reputation (interaktions-gegatete Reports), siehe docs/matching-and-reputation-research.md.