End-to-end encryption
A tenant can encrypt the personal data of its participants end to end. The team's browsers encrypt and decrypt it; the server, and so the API, only ever holds it sealed. An integration that needs names holds a key of its own and does the cryptography itself.
Everything without personal data of participants stays as it is: offers, editions, hosts, offer categories and the public endpoints need no cryptography. Why fepli encrypts this way, and what the team does in the admin, is explained in the manual.
What changes for the API
Whether a tenant encrypts, and which fields, says GET /e2e: encrypting is true from the moment the tenant switches the encryption on, and sealedFields names the sealed fields per record type.
| Tenant without encryption | Tenant that encrypts | |
|---|---|---|
GET /participants | names in firstname and lastname | encrypted: true, the fields in sealed, the record key for your key in keys; firstname and lastname are missing, name is the pseudonym, e.g. Kind 487X |
POST, PATCH /participants | names in the clear | uuid, sealed and keys; names in the clear are refused (422) |
GET /participants?q= | searches the names | refused (400): the server holds no names to search |
Order of GET /participants | by last name and first name | by creation |
| Participants in other resources | their names | their pseudonym |
A record key reaches your key only after a super admin of the tenant has confirmed it. Until then, and for every participant outside what the admin shared with you, keys is empty and you see the pseudonym.
Before you start
- A service account with the roles Admin and Teilnehmer:innen. A personal access token gets the record keys of its colleague, but the colleague's key opens only in their browser, with their passkey.
- The tenant encrypts:
GET /e2eanswers"encrypting": true. - A super admin of the tenant who is in the team and takes part in the emoji comparison with you, on the phone or next to you.
- X25519, Ed25519, HKDF-SHA256 and AES-256-GCM in your language. In PHP,
ext-sodiumandext-opensslare enough. An integration without them uses the API connector, which does the cryptography for it.
Set up a key
Your integration makes its key pairs itself and keeps the private halves. fepli never sees them.
- Make an X25519 key pair (records are sealed for it) and an Ed25519 key pair (for signatures), and choose a version 4 UUID as
keyId. Store the private halves where only the integration can read them. - Register the public halves:
POST /e2e/key. A read-only token may do this. The key now waits for its comparison; nothing is sealed for it yet. - A super admin opens the service account in the admin under Einstellungen → Integrationen → API → Service-Accounts verwalten. Under Verschlüsselung, they choose what the integration reads (Freigeben für: Alle Teilnehmenden or Kinder von … one host) and select Emojis vergleichen.
- Poll
GET /e2euntilrequest.statusisrunning, and answer the comparison: accept it with a commitment, send your ephemeral key once the admin's is in, and show the 7 emojis to whoever runs the integration. They compare them with the admin, who sees the same 7. If all match, confirm with your MAC; if not, cancel. See The emoji comparison. - Check the admin's MAC (
request.exchange.macA). It covers the admin's key, the team key and the team signing key. Only if it matches, store the team key (request.group) andrequest.teamSignPub: these are the keys you seal for and verify with from now on.
Once the admin has confirmed, key.status is confirmed and key.scope says what you read (team: every participant; host: the children with an attendance at one host's offers). The record keys arrive in the background: an unlocked browser of the team seals them for your key, so new participants reach you with a delay.
A comparison that gets no message for 10 minutes expires; the admin starts a new one.
Read participants
For a participant with encrypted: true:
- Find the entry in
keyswhoserecipientKeyIdis yourkeyId. Without one, the record is not shared with you yet. - Open it with your X25519 private key: the 32-byte record key
K. See Seal and open. - Decrypt every envelope in
sealedwithK, and remove the padding.
A participant of a tenant that encrypts (excerpt)
{
"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"
}
]
}
To find a participant by name, fetch the list, decrypt it and filter it in the integration.
Write participants
A new participant gets its UUID from you, because the envelopes are bound to it:
- Choose a version 4 UUID and a new random record key
K(32 bytes). - Encrypt every field that
sealedFields.participantnames, and at leastfirstnameandlastnameeven if it names fewer; an empty one as the empty string. - Seal
Kfor the team key you stored in the comparison, and for your own key, so you can read the record back. - Send
uuid,sealedandkeysalong with the other attributes: Create a participant.
To change names, send sealed with an envelope for every sealed field. Under the record's existing key you send no keys; under a new key, keys replaces the record key for each recipient. The team key must always be among the recipients. Make a new record key for every change, sealed for the team key you stored and for your own key, rather than reusing a key fepli hands you: then only keys you confirmed read the new names. The team's browsers share the new key with the hosts' groups again in the background.
Go by what you pinned in the comparison, not by what fepli says later. Once you hold a team key, send names sealed or not at all: if GET /e2e fails or answers encrypting: false, refuse the write instead of falling back to the clear. The connector works this way.
Create a participant with sealed names
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"
}'
What the team saves in the admin, your integration opens, and what your integration writes, the team opens: both use the same format, byte for byte.
Revoke a key
A super admin revokes the key on the service account's page with Schlüssel widerrufen. The server then deletes every record key sealed for it at once; what the integration has read so far, it keeps. Deleting the service account revokes its key as well. A new key needs a new comparison: POST /e2e/key refuses a second key while a confirmed one is active (409).
The protocol of the data subject rights records Schlüssel einer Integration freigegeben and Schlüssel einer Integration widerrufen, with the service account, the key's fingerprint and what it reads.
Wire format
Bytes travel as base64url without padding. Strings are UTF-8.
| What | How |
|---|---|
| Canonical JSON | Object keys sorted, no whitespace, / and non-ASCII characters unescaped; an empty object stays {} |
| Padding | The value, then the byte 0x80, then zero bytes up to a multiple of 32 bytes (at least 32). To remove it, strip the trailing zeros and the 0x80. |
| Field envelope | {v: 1, iv, ct}: AES-256-GCM under the record key K, a random 12-byte iv, the padded value, AAD fepli/record/v1|<type>|<uuid>|<field>; ct is the ciphertext followed by the 16-byte tag |
| Record key for a recipient | {recipientKeyId, enc, iv, ct}: K sealed for the recipient's X25519 key with AAD fepli/record/v1|<type>|<uuid>|key|<recipientKeyId> |
| Signature | Ed25519 over the canonical JSON of the value |
| Fingerprint | The first 12 bytes of SHA-256 over the raw 32-byte Ed25519 key, in upper-case hex, in groups of four: F6F0 F48E D485 AF87 5737 9FBE |
<type> is the record type, participant; <uuid> the record's UUID in its usual form with hyphens.
Seal and open
To seal a secret for a recipient's X25519 public key:
- Make an ephemeral X25519 key pair.
shared= X25519(ephemeral private key, recipient public key).kek= HKDF-SHA256(shared, salt = ephemeral public key ‖ recipient public key, info =fepli/seal/v1, 32 bytes).ct= AES-256-GCM(kek, a random 12-byteiv, the secret, the AAD), with the tag appended.- Send
enc(the ephemeral public key),ivandct.
To open, compute shared = X25519(your private key, enc) and the same kek, with your own public key in the salt.
The tenant statement
GET /e2e hands out the tenant's statement, the declaration the team signed when it switched the encryption on, and statementSig, an Ed25519 signature over its canonical JSON. Check statementSig with the teamSignPub you stored in the comparison, and that statement.teamKeyId is the keyId of the team key you seal for. If either check fails, seal nothing.
The emoji comparison
The comparison follows the short authentication string of Matrix (m.sas.v1). The admin's browser is the verifier, your integration the candidate. Their messages travel through the server in request.exchange, which holds no secret:
| Message | From | Content |
|---|---|---|
start | admin | txn, verifier and candidate (each keyId, userUuid; your userUuid is the service account's UUID), scope, serviceAccount |
accept | you | commit = base64url(SHA-256(your ephemeral X25519 public key ‖ canonical JSON of start)) |
keyA | admin | pub: the admin's ephemeral X25519 public key |
keyB | you | pub: your ephemeral X25519 public key |
macA | admin | mac over the admin's keys, the team key and the team signing key |
macB | you | mac over your key |
cancel | either | reason: mismatch, cancelled, commitment, mac or key |
You commit to your ephemeral key before you see the admin's. A server between the two therefore gets exactly one attempt at matching emojis.
With shared = X25519(your ephemeral private key, keyA.pub):
- Emojis. HKDF-SHA256(
shared, empty salt, infoFEPLI_SAS|<verifier keyId>|<keyA pub>|<candidate keyId>|<keyB pub>|<txn>, 6 bytes). Read the 48 bits from the first byte's highest bit on; the first 42 bits, in 7 groups of 6, are the indexes of the 7 emojis in the table below. - MAC key. HKDF-SHA256(
shared, empty salt, infoFEPLI_SAS_MAC|<txn>, 32 bytes). - Your MAC. HMAC-SHA256(MAC key, canonical JSON of
{"key": {"keyId", "encPub", "signPub", "userUuid"}}), with your public keys and the service account's UUID. - The admin's MAC. The same over
{"key": {"keyId", "encPub", "signPub", "userUuid"}, "group": {"keyId", "encPub", "signPub"}, "teamSignPub"}: the admin's key fromrequest.verifierwithstart.verifier.userUuid, the team key fromrequest.group, andrequest.teamSignPub. Compare it in constant time.
The emojis are those of the Matrix table, in its order. The admin shows them with these names:
| 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 |
Test vectors
Fixed inputs and their outputs, the same in fepli's browsers and on its server. Bytes are hex where a name ends in Hex, base64url elsewhere. An implementation that reproduces every value speaks the same format.
Test vectors
{
"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"
}
}
The Ed25519 key in signature comes from the 32-byte seed seedHex.