fepli
EntwicklerREST API

Ende-zu-Ende-Verschlüsselung

Ein Mandant kann die personenbezogenen Daten seiner Teilnehmer:innen Ende-zu-Ende verschlüsseln. Die Browser des Teams ver- und entschlüsseln sie; der Server und damit die API haben sie nur versiegelt. Eine Integration, die Namen braucht, hat einen eigenen Schlüssel und übernimmt die Kryptografie selbst.

Alles ohne personenbezogene Daten der Teilnehmer:innen bleibt, wie es ist: Angebote, Saisons, Veranstalter:innen, Kategorien und die öffentlichen Endpunkte brauchen keine Kryptografie. Warum fepli so verschlüsselt und was das Team im Admin-Panel tut, erklärt das Handbuch.

Was sich für die API ändert

Ob ein Mandant verschlüsselt und welche Felder, sagt GET /e2e: encrypting ist true, sobald der Mandant die Verschlüsselung einschaltet, und sealedFields nennt die versiegelten Felder je Datensatztyp.

Mandant ohne VerschlüsselungMandant, der verschlüsselt
GET /participantsNamen in firstname und lastnameencrypted: true, die Felder in sealed, der Datensatzschlüssel für Ihren Schlüssel in keys; firstname und lastname fehlen, name ist das Pseudonym, z. B. Kind 487X
POST, PATCH /participantsNamen im Klartextuuid, sealed und keys; Namen im Klartext werden abgelehnt (422)
GET /participants?q=sucht in den Namenabgelehnt (400): Der Server hat keine Namen, in denen er suchen könnte
Reihenfolge von GET /participantsnach Nachname und Vornamenach Anlage
Teilnehmer:innen in anderen Ressourcenihr Nameihr Pseudonym

Ein Datensatzschlüssel erreicht Ihren Schlüssel erst, wenn ein Super-Admin des Mandanten ihn bestätigt hat. Bis dahin, und für jede Teilnehmer:in außerhalb dessen, was der Admin für Sie freigegeben hat, ist keys leer, und Sie sehen das Pseudonym.

Was Sie brauchen

  • Einen Service-Account mit den Rollen Admin und Teilnehmer:innen. Ein persönliches API-Token bekommt die Datensatzschlüssel seiner Kolleg:in, aber deren Schlüssel öffnet sich nur in ihrem Browser, mit ihrem Passkey.
  • Der Mandant verschlüsselt: GET /e2e antwortet "encrypting": true.
  • Ein Super-Admin des Mandanten, der im Team ist und mit Ihnen die Emojis vergleicht, am Telefon oder neben Ihnen.
  • X25519, Ed25519, HKDF-SHA256 und AES-256-GCM in Ihrer Sprache. In PHP genügen ext-sodium und ext-openssl. Eine Integration ohne sie nutzt den API-Connector, der die Kryptografie für sie übernimmt.

Einen Schlüssel einrichten

Ihre Integration erzeugt ihre Schlüsselpaare selbst und behält die privaten Hälften. fepli sieht sie nie.

  1. Erzeugen Sie ein X25519-Schlüsselpaar, für das Datensätze versiegelt werden, und ein Ed25519-Schlüsselpaar für Signaturen, und wählen Sie eine UUID der Version 4 als keyId. Speichern Sie die privaten Hälften so, dass nur die Integration sie lesen kann.
  2. Melden Sie die öffentlichen Hälften an: POST /e2e/key. Ein Token mit Nur lesen darf das. Der Schlüssel wartet dann auf seinen Vergleich; noch wird nichts für ihn versiegelt.
  3. Ein Super-Admin öffnet den Service-Account im Admin-Panel unter Einstellungen → Integrationen → API → Service-Accounts verwalten. Unter Verschlüsselung wählt er, was die Integration liest (Freigeben für: Alle Teilnehmenden oder Kinder von … einer Veranstalter:in), und dann Emojis vergleichen.
  4. Fragen Sie GET /e2e ab, bis request.status running ist, und beantworten Sie den Vergleich: Nehmen Sie ihn an mit einer Festlegung, schicken Sie Ihren Einmal-Schlüssel, sobald der des Admins da ist, und zeigen Sie die 7 Emojis der Person, die die Integration betreut. Sie vergleicht sie mit dem Admin, der dieselben 7 sieht. Stimmen alle überein, bestätigen Sie mit Ihrer MAC; sonst brechen Sie ab. Siehe Der Emoji-Vergleich.
  5. Prüfen Sie die MAC des Admins (request.exchange.macA). Sie deckt den Schlüssel des Admins, den Team-Schlüssel und den Team-Signaturschlüssel ab. Nur wenn sie stimmt, speichern Sie den Team-Schlüssel (request.group) und request.teamSignPub: Für diese Schlüssel versiegeln und mit ihnen prüfen Sie von nun an.

Sobald der Admin bestätigt hat, ist key.status confirmed, und key.scope sagt, was Sie lesen (team: alle Teilnehmer:innen; host: die Kinder mit einer Anmeldung bei den Angeboten einer Veranstalter:in). Die Datensatzschlüssel kommen im Hintergrund: Ein entsperrter Browser des Teams versiegelt sie für Ihren Schlüssel, neue Teilnehmer:innen erreichen Sie also mit Verzögerung.

Ein Vergleich, der 10 Minuten keine Nachricht bekommt, läuft ab; der Admin startet dann einen neuen.

Teilnehmer:innen lesen

Für eine Teilnehmer:in mit encrypted: true:

  1. Suchen Sie in keys den Eintrag, dessen recipientKeyId Ihre keyId ist. Fehlt er, ist der Datensatz noch nicht für Sie freigegeben.
  2. Öffnen Sie ihn mit Ihrem privaten X25519-Schlüssel: der 32 Byte lange Datensatzschlüssel K. Siehe Versiegeln und öffnen.
  3. Entschlüsseln Sie jeden Umschlag in sealed mit K und entfernen Sie das Auffüllen.

Eine Teilnehmer:in eines Mandanten, der verschlüsselt (Auszug)

{
  "uuid": "01a0c56d-4127-70a1-9e49-3c5a1230e23d",
  "name": "Kind 487X",
  "encrypted": true,
  "sealed": {
    "firstname": { "v": 1, "iv": "oaKjpKWmp6ipqqus", "ct": "PfQxLTuSdlo38idbxbrMutFvfYVqBS0Q_-Zs08lZZQ9776a7F608S8dYWNFgeQ6q" },
    "lastname": { "v": 1, "iv": "…", "ct": "…" }
  },
  "keys": [
    {
      "recipientKeyId": "3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
      "enc": "dqX5jkqpbuODviC2z6_7A6C87VnvDN-WFPipjE7LEyU",
      "iv": "sbKztLW2t7i5uru8",
      "ct": "htNAi9gaHsdyTih1aLMp5OM5g31izerShOVWJS7mtYwos-AFW9KBigB-1ZldSAeq"
    }
  ]
}

Um eine Teilnehmer:in nach Namen zu finden, rufen Sie die Liste ab, entschlüsseln sie und filtern in der Integration.

Teilnehmer:innen schreiben

Eine neue Teilnehmer:in bekommt ihre UUID von Ihnen, weil die Umschläge an sie gebunden sind:

  1. Wählen Sie eine UUID der Version 4 und einen neuen zufälligen Datensatzschlüssel K (32 Byte).
  2. Verschlüsseln Sie jedes Feld, das sealedFields.participant nennt, und mindestens firstname und lastname, auch wenn es weniger nennt; ein leeres als leere Zeichenkette.
  3. Versiegeln Sie K für den Team-Schlüssel, den Sie beim Vergleich gespeichert haben, und für Ihren eigenen Schlüssel, damit Sie den Datensatz wieder lesen können.
  4. Senden Sie uuid, sealed und keys mit den übrigen Attributen: Eine Teilnehmer:in anlegen.

Um Namen zu ändern, senden Sie sealed mit einem Umschlag für jedes versiegelte Feld. Unter dem bisherigen Schlüssel des Datensatzes senden Sie keine keys; unter einem neuen Schlüssel ersetzt keys den Datensatzschlüssel je Empfänger. Der Team-Schlüssel muss immer unter den Empfängern sein. Erzeugen Sie für jede Änderung einen neuen Datensatzschlüssel, versiegelt für den gespeicherten Team-Schlüssel und Ihren eigenen Schlüssel, statt einen Schlüssel weiterzuverwenden, den fepli Ihnen gibt: Dann lesen die neuen Namen nur Schlüssel, die Sie bestätigt haben. Den neuen Schlüssel geben Browser des Teams im Hintergrund wieder an die Gruppen der Veranstalter-Profile weiter.

Richten Sie sich nach dem, was Sie beim Vergleich festgehalten haben, nicht danach, was fepli später sagt. Sobald Sie einen Team-Schlüssel haben, senden Sie Namen versiegelt oder gar nicht: Schlägt GET /e2e fehl oder antwortet es mit encrypting: false, lehnen Sie das Schreiben ab, statt auf den Klartext auszuweichen. So arbeitet der Connector.

Eine Teilnehmer:in mit versiegelten Namen anlegen

curl -X POST https://example.fepli.eu/api/participants \
  -H "Authorization: Bearer $FEPLI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "uuid": "01a0c56d-4127-70a1-9e49-3c5a1230e23d",
    "sealed": {
      "firstname": { "v": 1, "iv": "…", "ct": "…" },
      "lastname": { "v": 1, "iv": "…", "ct": "…" }
    },
    "keys": [
      { "recipientKeyId": "<team keyId>", "enc": "…", "iv": "…", "ct": "…" },
      { "recipientKeyId": "<your keyId>", "enc": "…", "iv": "…", "ct": "…" }
    ],
    "account": "familie.mueller@example.org"
  }'

Was das Team im Admin-Panel speichert, öffnet Ihre Integration, und was Ihre Integration schreibt, öffnet das Team: Beide nutzen dasselbe Format, Byte für Byte.

Einen Schlüssel widerrufen

Ein Super-Admin widerruft den Schlüssel auf der Seite des Service-Accounts mit Schlüssel widerrufen. Der Server löscht dann sofort jeden Datensatzschlüssel, der für ihn versiegelt war; was die Integration bis dahin gelesen hat, behält sie. Das Löschen des Service-Accounts widerruft seinen Schlüssel ebenso. Ein neuer Schlüssel braucht einen neuen Vergleich: POST /e2e/key lehnt einen zweiten Schlüssel ab, solange ein bestätigter aktiv ist (409).

Das Protokoll der Betroffenenrechte verzeichnet Schlüssel einer Integration freigegeben und Schlüssel einer Integration widerrufen, mit dem Service-Account, dem Fingerabdruck des Schlüssels und dem, was er liest.

Das Format

Bytes werden als base64url ohne Auffüllen übertragen. Zeichenketten sind UTF-8.

WasWie
Kanonisches JSONSchlüssel der Objekte sortiert, keine Leerzeichen, / und Zeichen außerhalb von ASCII nicht maskiert; ein leeres Objekt bleibt {}
AuffüllenDer Wert, dann das Byte 0x80, dann Null-Bytes bis zu einem Vielfachen von 32 Byte (mindestens 32). Zum Entfernen die Null-Bytes am Ende und das 0x80 abschneiden.
Umschlag eines Felds{v: 1, iv, ct}: AES-256-GCM unter dem Datensatzschlüssel K, ein zufälliges iv aus 12 Byte, der aufgefüllte Wert, AAD fepli/record/v1|<type>|<uuid>|<field>; ct ist der Geheimtext, gefolgt vom 16 Byte langen Tag
Datensatzschlüssel für einen Empfänger{recipientKeyId, enc, iv, ct}: K, versiegelt für den X25519-Schlüssel des Empfängers mit AAD fepli/record/v1|<type>|<uuid>|key|<recipientKeyId>
SignaturEd25519 über das kanonische JSON des Werts
FingerabdruckDie ersten 12 Byte von SHA-256 über den rohen Ed25519-Schlüssel (32 Byte), hexadezimal in Großbuchstaben, in Vierergruppen: F6F0 F48E D485 AF87 5737 9FBE

<type> ist der Datensatztyp, participant; <uuid> die UUID des Datensatzes in ihrer üblichen Form mit Bindestrichen.

Versiegeln und öffnen

Um ein Geheimnis für den öffentlichen X25519-Schlüssel eines Empfängers zu versiegeln:

  1. Erzeugen Sie ein X25519-Schlüsselpaar nur für diesen Vorgang.
  2. shared = X25519(dessen privater Schlüssel, öffentlicher Schlüssel des Empfängers).
  3. kek = HKDF-SHA256(shared, Salt = dessen öffentlicher Schlüssel ‖ öffentlicher Schlüssel des Empfängers, Info = fepli/seal/v1, 32 Byte).
  4. ct = AES-256-GCM(kek, ein zufälliges iv aus 12 Byte, das Geheimnis, die AAD), mit angehängtem Tag.
  5. Senden Sie enc (den öffentlichen Einmal-Schlüssel), iv und ct.

Zum Öffnen berechnen Sie shared = X25519(Ihr privater Schlüssel, enc) und dasselbe kek, mit Ihrem eigenen öffentlichen Schlüssel im Salt.

Die Aussage des Mandanten

GET /e2e liefert die Aussage des Mandanten, statement, die das Team beim Einschalten der Verschlüsselung signiert hat, und statementSig, eine Ed25519-Signatur über ihr kanonisches JSON. Prüfen Sie statementSig mit dem teamSignPub, das Sie beim Vergleich gespeichert haben, und dass statement.teamKeyId die keyId des Team-Schlüssels ist, für den Sie versiegeln. Schlägt eine der Prüfungen fehl, versiegeln Sie nichts.

Der Emoji-Vergleich

Der Vergleich folgt der Kurzauthentifizierung von Matrix (m.sas.v1). Der Browser des Admins bestätigt, Ihre Integration ist die neue Seite. Ihre Nachrichten laufen in request.exchange über den Server, der dabei kein Geheimnis erfährt:

NachrichtVonInhalt
startAdmintxn, verifier und candidate (je keyId, userUuid; Ihre userUuid ist die UUID des Service-Accounts), scope, serviceAccount
acceptSiecommit = base64url(SHA-256(Ihr öffentlicher Einmal-Schlüssel ‖ kanonisches JSON von start))
keyAAdminpub: der öffentliche Einmal-Schlüssel des Admins (X25519)
keyBSiepub: Ihr öffentlicher Einmal-Schlüssel (X25519)
macAAdminmac über die Schlüssel des Admins, den Team-Schlüssel und den Team-Signaturschlüssel
macBSiemac über Ihren Schlüssel
cancelbeidereason: mismatch, cancelled, commitment, mac oder key

Sie legen sich auf Ihren Einmal-Schlüssel fest, bevor Sie den des Admins sehen. Ein Server zwischen beiden hat deshalb genau einen Versuch, passende Emojis zu treffen.

Mit shared = X25519(Ihr privater Einmal-Schlüssel, keyA.pub):

  • Emojis. HKDF-SHA256(shared, leeres Salt, Info FEPLI_SAS|<verifier keyId>|<keyA pub>|<candidate keyId>|<keyB pub>|<txn>, 6 Byte). Lesen Sie die 48 Bit ab dem höchsten Bit des ersten Bytes; die ersten 42 Bit, in 7 Gruppen zu 6, sind die Indizes der 7 Emojis in der Tabelle unten.
  • MAC-Schlüssel. HKDF-SHA256(shared, leeres Salt, Info FEPLI_SAS_MAC|<txn>, 32 Byte).
  • Ihre MAC. HMAC-SHA256(MAC-Schlüssel, kanonisches JSON von {"key": {"keyId", "encPub", "signPub", "userUuid"}}), mit Ihren öffentlichen Schlüsseln und der UUID des Service-Accounts.
  • Die MAC des Admins. Dasselbe über {"key": {"keyId", "encPub", "signPub", "userUuid"}, "group": {"keyId", "encPub", "signPub"}, "teamSignPub"}: der Schlüssel des Admins aus request.verifier mit start.verifier.userUuid, der Team-Schlüssel aus request.group und request.teamSignPub. Vergleichen Sie sie in konstanter Zeit.

Die Emojis sind die der Matrix-Tabelle, in ihrer Reihenfolge. Das Admin-Panel zeigt sie mit diesen Namen:

0 🐶 Hund1 🐱 Katze2 🦁 Löwe3 🐎 Pferd
4 🦄 Einhorn5 🐷 Schwein6 🐘 Elefant7 🐰 Hase
8 🐼 Panda9 🐓 Hahn10 🐧 Pinguin11 🐢 Schildkröte
12 🐟 Fisch13 🐙 Oktopus14 🦋 Schmetterling15 🌷 Blume
16 🌳 Baum17 🌵 Kaktus18 🍄 Pilz19 🌏 Globus
20 🌙 Mond21 ☁️ Wolke22 🔥 Feuer23 🍌 Banane
24 🍎 Apfel25 🍓 Erdbeere26 🌽 Mais27 🍕 Pizza
28 🎂 Kuchen29 ❤️ Herz30 😀 Lächeln31 🤖 Roboter
32 🎩 Hut33 👓 Brille34 🔧 Schraubenschlüssel35 🎅 Weihnachtsmann
36 👍 Daumen hoch37 ☂️ Regenschirm38 ⌛ Sanduhr39 ⏰ Uhr
40 🎁 Geschenk41 💡 Glühbirne42 📕 Buch43 ✏️ Bleistift
44 📎 Büroklammer45 ✂️ Schere46 🔒 Schloss47 🔑 Schlüssel
48 🔨 Hammer49 ☎️ Telefon50 🏁 Flagge51 🚂 Zug
52 🚲 Fahrrad53 ✈️ Flugzeug54 🚀 Rakete55 🏆 Pokal
56 ⚽ Ball57 🎸 Gitarre58 🎺 Trompete59 🔔 Glocke
60 ⚓ Anker61 🎧 Kopfhörer62 📁 Ordner63 📌 Stecknadel

Testvektoren

Feste Eingaben und ihre Ergebnisse, gleich in den Browsern von fepli und auf seinem Server. Bytes sind hexadezimal, wo ein Name auf Hex endet, sonst base64url. Eine Implementierung, die jeden Wert nachbildet, spricht dasselbe Format.

Testvektoren

{
  "canonicalJson": {
    "value": { "b": [2, 1], "a": { "z": null, "y": "ä/ö" }, "c": true },
    "json": "{\"a\":{\"y\":\"ä/ö\",\"z\":null},\"b\":[2,1],\"c\":true}"
  },
  "pad": {
    "valueUtf8": "Mia",
    "paddedHex": "4d69618000000000000000000000000000000000000000000000000000000000",
    "long": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "longPaddedLength": 32
  },
  "field": {
    "recordKeyHex": "0f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a6978",
    "type": "participant",
    "uuid": "01a0c56d-4127-70a1-9e49-3c5a1230e23d",
    "field": "firstname",
    "value": "Mia",
    "aad": "fepli/record/v1|participant|01a0c56d-4127-70a1-9e49-3c5a1230e23d|firstname",
    "ivHex": "a1a2a3a4a5a6a7a8a9aaabac",
    "envelope": {
      "v": 1,
      "iv": "oaKjpKWmp6ipqqus",
      "ct": "PfQxLTuSdlo38idbxbrMutFvfYVqBS0Q_-Zs08lZZQ9776a7F608S8dYWNFgeQ6q"
    }
  },
  "seal": {
    "recipientPrivHex": "6c17ea4713886578bdba0d1c820f1f3ff3c8473541fc9fb12777c3a4b7ee04ae",
    "recipientPub": "ER0ZP0Nvhdmt1HnwTD5pDCPBpt_YMHXfiQyQWTuB8zs",
    "recipientKeyId": "3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
    "ephemeralPrivHex": "c487fb9a3ca6ffc95a6461f90fb29515d49f926de1a8aaca802c79f8ee7eb057",
    "ivHex": "b1b2b3b4b5b6b7b8b9babbbc",
    "secretHex": "0f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a69780f1e2d3c4b5a6978",
    "aad": "fepli/record/v1|participant|01a0c56d-4127-70a1-9e49-3c5a1230e23d|key|3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
    "info": "fepli/seal/v1",
    "sealed": {
      "enc": "dqX5jkqpbuODviC2z6_7A6C87VnvDN-WFPipjE7LEyU",
      "iv": "sbKztLW2t7i5uru8",
      "ct": "htNAi9gaHsdyTih1aLMp5OM5g31izerShOVWJS7mtYwos-AFW9KBigB-1ZldSAeq"
    }
  },
  "signature": {
    "seedHex": "7eb4d713d9e557961d25e83e21cbfb4d38559ceb3d07cdf794aa848d6da7a6b7",
    "signPub": "yumW6j3VfgKQ42w3izlE0nIs9Wwp9i6h3zZsjCswOdM",
    "value": {
      "keyId": "3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
      "kind": "service",
      "ownerType": "service_account",
      "ownerId": "01a17727-e80b-78c7-a53e-f5789325794e",
      "encPub": "ER0ZP0Nvhdmt1HnwTD5pDCPBpt_YMHXfiQyQWTuB8zs",
      "signPub": "yumW6j3VfgKQ42w3izlE0nIs9Wwp9i6h3zZsjCswOdM",
      "createdAt": "2026-10-26T10:05:00.000Z"
    },
    "sig": "edpPWa6ziJT92dFmDetLMdpKEC_gdE2S5y3gN2IVKoZQatWCVdOynQlid6et9lVz6_RjMoJROH6QAuIRgk3MDQ"
  },
  "fingerprint": {
    "signPub": "yumW6j3VfgKQ42w3izlE0nIs9Wwp9i6h3zZsjCswOdM",
    "fingerprint": "F6F0 F48E D485 AF87 5737 9FBE"
  },
  "sas": {
    "start": {
      "txn": "6d1f3a52-0b1e-4c7a-9d2e-5f4b3a291807",
      "target": "service_account",
      "verifier": { "keyId": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "userUuid": "01a0c56d-4126-7f64-a6be-9aa9ebcdfb39" },
      "candidate": { "keyId": "3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f", "userUuid": "01a17727-e80b-78c7-a53e-f5789325794e" },
      "scope": { "kind": "team" },
      "startedAt": "2026-10-26T10:00:00+00:00"
    },
    "verifierEphemeralPrivHex": "530dce487c6ef76ed81e47ed54f24e8fc313097c4cbe86d4e06e60fa0ab9b11a",
    "keyA": "Tc-bOWkjSb-HdhHa6HmN67ImbyWGayCr7SZVqdoxfSA",
    "candidateEphemeralPrivHex": "173053aa900de2010578af7fe20775ddf1c740e9ef1aa3106e70f4468e9c4618",
    "keyB": "iPdR1AZknGX95mTA3b7GloMdLVXgwEYCPm0Mslh1p34",
    "sharedHex": "cfc01b15f9d63c14799cecb2b23b76640a1b8262729b2230619de800aeac7363",
    "commitment": "shmSD_06nVsrjXtdH0TeEgrfbIlA5k_nfKahJmAfdvc",
    "emojis": ["Baum", "Telefon", "Uhr", "Pizza", "Blume", "Feuer", "Erdbeere"],
    "candidateKeys": {
      "key": {
        "keyId": "3f0c2c1e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
        "encPub": "ER0ZP0Nvhdmt1HnwTD5pDCPBpt_YMHXfiQyQWTuB8zs",
        "signPub": "yumW6j3VfgKQ42w3izlE0nIs9Wwp9i6h3zZsjCswOdM",
        "userUuid": "01a17727-e80b-78c7-a53e-f5789325794e"
      }
    },
    "macB": "VUhqONFtbXZXxeTg49Vt-9mlbfTsd5Ulw03VTtSxS6k"
  }
}

Der Ed25519-Schlüssel in signature entsteht aus dem 32 Byte langen Seed seedHex.

War diese Seite hilfreich?