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üsselung | Mandant, der verschlüsselt | |
|---|---|---|
GET /participants | Namen in firstname und lastname | encrypted: 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 /participants | Namen im Klartext | uuid, sealed und keys; Namen im Klartext werden abgelehnt (422) |
GET /participants?q= | sucht in den Namen | abgelehnt (400): Der Server hat keine Namen, in denen er suchen könnte |
Reihenfolge von GET /participants | nach Nachname und Vorname | nach Anlage |
| Teilnehmer:innen in anderen Ressourcen | ihr Name | ihr 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 /e2eantwortet"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-sodiumundext-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.
- 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. - 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. - 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.
- Fragen Sie
GET /e2eab, bisrequest.statusrunningist, 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. - 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) undrequest.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:
- Suchen Sie in
keysden Eintrag, dessenrecipientKeyIdIhrekeyIdist. Fehlt er, ist der Datensatz noch nicht für Sie freigegeben. - Öffnen Sie ihn mit Ihrem privaten X25519-Schlüssel: der 32 Byte lange Datensatzschlüssel
K. Siehe Versiegeln und öffnen. - Entschlüsseln Sie jeden Umschlag in
sealedmitKund 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:
- Wählen Sie eine UUID der Version 4 und einen neuen zufälligen Datensatzschlüssel
K(32 Byte). - Verschlüsseln Sie jedes Feld, das
sealedFields.participantnennt, und mindestensfirstnameundlastname, auch wenn es weniger nennt; ein leeres als leere Zeichenkette. - Versiegeln Sie
Kfü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. - Senden Sie
uuid,sealedundkeysmit 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.
| Was | Wie |
|---|---|
| Kanonisches JSON | Schlüssel der Objekte sortiert, keine Leerzeichen, / und Zeichen außerhalb von ASCII nicht maskiert; ein leeres Objekt bleibt {} |
| Auffüllen | Der 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> |
| Signatur | Ed25519 über das kanonische JSON des Werts |
| Fingerabdruck | Die 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:
- Erzeugen Sie ein X25519-Schlüsselpaar nur für diesen Vorgang.
shared= X25519(dessen privater Schlüssel, öffentlicher Schlüssel des Empfängers).kek= HKDF-SHA256(shared, Salt = dessen öffentlicher Schlüssel ‖ öffentlicher Schlüssel des Empfängers, Info =fepli/seal/v1, 32 Byte).ct= AES-256-GCM(kek, ein zufälligesivaus 12 Byte, das Geheimnis, die AAD), mit angehängtem Tag.- Senden Sie
enc(den öffentlichen Einmal-Schlüssel),ivundct.
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:
| Nachricht | Von | Inhalt |
|---|---|---|
start | Admin | txn, verifier und candidate (je keyId, userUuid; Ihre userUuid ist die UUID des Service-Accounts), scope, serviceAccount |
accept | Sie | commit = base64url(SHA-256(Ihr öffentlicher Einmal-Schlüssel ‖ kanonisches JSON von start)) |
keyA | Admin | pub: der öffentliche Einmal-Schlüssel des Admins (X25519) |
keyB | Sie | pub: Ihr öffentlicher Einmal-Schlüssel (X25519) |
macA | Admin | mac über die Schlüssel des Admins, den Team-Schlüssel und den Team-Signaturschlüssel |
macB | Sie | mac über Ihren Schlüssel |
cancel | beide | reason: 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, InfoFEPLI_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, InfoFEPLI_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 ausrequest.verifiermitstart.verifier.userUuid, der Team-Schlüssel ausrequest.groupundrequest.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 🐶 Hund | 1 🐱 Katze | 2 🦁 Löwe | 3 🐎 Pferd |
| 4 🦄 Einhorn | 5 🐷 Schwein | 6 🐘 Elefant | 7 🐰 Hase |
| 8 🐼 Panda | 9 🐓 Hahn | 10 🐧 Pinguin | 11 🐢 Schildkröte |
| 12 🐟 Fisch | 13 🐙 Oktopus | 14 🦋 Schmetterling | 15 🌷 Blume |
| 16 🌳 Baum | 17 🌵 Kaktus | 18 🍄 Pilz | 19 🌏 Globus |
| 20 🌙 Mond | 21 ☁️ Wolke | 22 🔥 Feuer | 23 🍌 Banane |
| 24 🍎 Apfel | 25 🍓 Erdbeere | 26 🌽 Mais | 27 🍕 Pizza |
| 28 🎂 Kuchen | 29 ❤️ Herz | 30 😀 Lächeln | 31 🤖 Roboter |
| 32 🎩 Hut | 33 👓 Brille | 34 🔧 Schraubenschlüssel | 35 🎅 Weihnachtsmann |
| 36 👍 Daumen hoch | 37 ☂️ Regenschirm | 38 ⌛ Sanduhr | 39 ⏰ Uhr |
| 40 🎁 Geschenk | 41 💡 Glühbirne | 42 📕 Buch | 43 ✏️ Bleistift |
| 44 📎 Büroklammer | 45 ✂️ Schere | 46 🔒 Schloss | 47 🔑 Schlüssel |
| 48 🔨 Hammer | 49 ☎️ Telefon | 50 🏁 Flagge | 51 🚂 Zug |
| 52 🚲 Fahrrad | 53 ✈️ Flugzeug | 54 🚀 Rakete | 55 🏆 Pokal |
| 56 ⚽ Ball | 57 🎸 Gitarre | 58 🎺 Trompete | 59 🔔 Glocke |
| 60 ⚓ Anker | 61 🎧 Kopfhörer | 62 📁 Ordner | 63 📌 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.