fepli
DevelopersREST API

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 encryptionTenant that encrypts
GET /participantsnames in firstname and lastnameencrypted: 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 /participantsnames in the clearuuid, sealed and keys; names in the clear are refused (422)
GET /participants?q=searches the namesrefused (400): the server holds no names to search
Order of GET /participantsby last name and first nameby creation
Participants in other resourcestheir namestheir 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 /e2e answers "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-sodium and ext-openssl are 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.

  1. 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.
  2. 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.
  3. 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.
  4. Poll GET /e2e until request.status is running, 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.
  5. 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) and request.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:

  1. Find the entry in keys whose recipientKeyId is your keyId. Without one, the record is not shared with you yet.
  2. Open it with your X25519 private key: the 32-byte record key K. See Seal and open.
  3. Decrypt every envelope in sealed with K, 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:

  1. Choose a version 4 UUID and a new random record key K (32 bytes).
  2. Encrypt every field that sealedFields.participant names, and at least firstname and lastname even if it names fewer; an empty one as the empty string.
  3. Seal K for the team key you stored in the comparison, and for your own key, so you can read the record back.
  4. Send uuid, sealed and keys along 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.

WhatHow
Canonical JSONObject keys sorted, no whitespace, / and non-ASCII characters unescaped; an empty object stays {}
PaddingThe 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>
SignatureEd25519 over the canonical JSON of the value
FingerprintThe 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:

  1. Make an ephemeral X25519 key pair.
  2. shared = X25519(ephemeral private key, recipient public key).
  3. kek = HKDF-SHA256(shared, salt = ephemeral public key ‖ recipient public key, info = fepli/seal/v1, 32 bytes).
  4. ct = AES-256-GCM(kek, a random 12-byte iv, the secret, the AAD), with the tag appended.
  5. Send enc (the ephemeral public key), iv and ct.

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:

MessageFromContent
startadmintxn, verifier and candidate (each keyId, userUuid; your userUuid is the service account's UUID), scope, serviceAccount
acceptyoucommit = base64url(SHA-256(your ephemeral X25519 public key ‖ canonical JSON of start))
keyAadminpub: the admin's ephemeral X25519 public key
keyByoupub: your ephemeral X25519 public key
macAadminmac over the admin's keys, the team key and the team signing key
macByoumac over your key
canceleitherreason: 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, info FEPLI_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, info FEPLI_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 from request.verifier with start.verifier.userUuid, the team key from request.group, and request.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 🐶 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

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.

Was this page helpful?