Spec errata¶
Canonical specification
This page is ProjectInfo/spec-errata.md on docs/encryption-modes-spec, rendered at build time. That file is the source of truth; the site holds no copy of it.
Where the specifications in this repository and Server/docs/ disagree with
the code that is running, as checked against the code on 2026-10-17.
Unless an entry says otherwise, the code is what is in effect — a
third-party client or server must follow the code's behaviour to interoperate.
This file is canonical for the discrepancies themselves. When one is fixed —
in the spec or in the code — delete its entry in the same commit, and if it
was the last entry for a published page, move that page's status to
canonical in site/sources.yml.
Code references are repo/path:line at the time of the audit. CF is
Client-Flutter/lib/features.
E2EE core¶
The Dart client is the de-facto protocol: the Go X3DH in
Server/pkg/crypto/v1/x3dh.go is called only from tests and takes X25519
identity keys directly, so it does not work with the published Ed25519
bundles as it stands.
Parameters in effect¶
| Item | Value | Code |
|---|---|---|
| Identity key (IK) | Ed25519 (libsodium). Public 32 bytes; secret 64 bytes = seed ‖ public | CF/e2ee/device_keys.dart:46 |
| Signed prekey (SPK) | X25519. Id is always 1; not rotated. Signature = Ed25519 over the raw 32-byte SPK public key. The server checks lengths 32/32/64 and verifies | device_keys.dart:47-53; keyserver bundle/service.go:42-53 |
| IK used for DH | crypto_sign_ed25519_{pk,sk}_to_curve25519 |
CF/e2ee/x3dh.dart:106-107 |
| X3DH key material | DH1 = IK_A × SPK_B, DH2 = EK × IK_B, DH3 = EK × SPK_B, optional DH4 = EK × OPK_B, concatenated. No 32×0xFF prefix |
x3dh.dart:115-120 |
| X3DH KDF | HKDF-SHA256(ikm = KM, salt = 32 zero bytes, info = "zipp-chat-x3dh-v1", L = 32) |
x3dh.dart:6,123 |
| HKDF | RFC 5869: PRK = HMAC(key = salt, msg = ikm); T(i) = HMAC(PRK, T(i-1) ‖ info ‖ i) | CF/crypto/crypto_engine.dart:216-234 |
| KDF_RK | HKDF(ikm = DH output, salt = RK, info = "zipp-chat-dr-ratchet-v1", L = 64) → [0:32] new RK, [32:64] chain key |
CF/e2ee/double_ratchet.dart:6,74-77 |
| KDF_CK | MK = HMAC-SHA256(key = CK, 0x01); next CK = HMAC-SHA256(key = CK, 0x02) |
double_ratchet.dart:80-84 |
| Sender init | Fresh DHs; (RK, CKs) = KDF_RK(SK, DH(DHs, SPK_B)); DHr = SPK_B | double_ratchet.dart:86-106 |
| Receiver init | RK = SK; DHs = its own SPK key pair; DHr = none | double_ratchet.dart:108-126 |
| Ratchet header | UTF-8 JSON {"dh":<b64 public key>,"pn":<int>,"n":<int>} — this key order, no whitespace |
double_ratchet.dart:131-132 |
| Message AEAD | XChaCha20-Poly1305-IETF, key = MK, random 24-byte nonce, AD = the header bytes exactly as received (never re-serialised). Wire: nonce(24) ‖ ciphertext ‖ tag(16) | double_ratchet.dart:133-149 |
| MAX_SKIP | 1000 per chain (Nr + 1000 < until → reject), applied to pn and n. No overall cap; skipped keys do not expire. Stored as "<dh b64>:<n>" |
double_ratchet.dart:7,152-164 |
| X3DH header | JSON {"eph","spk_id","otp_id"(0 = none),"did","ik"(sender's Ed25519 IK)}. Receivers take IK_A from ik, and ignore did and spk_id |
x3dh.dart:58-64; CF/dm/dm_manager.dart:320-379 |
| Base64 | Standard alphabet, padded, everywhere. Key-server uploads also accept raw and URL-safe; backup and DM endpoints accept standard only | keyserver bundle/handler.go:20-32 |
| Bundle response | A JSON array, one item per device: device_id, identity_key, signed_prekey, signed_prekey_id, signed_prekey_signature, and flat one_time_prekey + one_time_prekey_id, omitted (not null) when none is left. ?consume=false does not consume one. 404 when the user has no devices |
bundle/model.go:74-82; service.go:89-122 |
| One-time prekeys | Consumed oldest first. Uploads take 1-100 keys of 32 bytes; a duplicate id is silently ignored. The reference client tops up to 50 when fewer than 10 remain, on login | bundle/repository.go:105,120-134; CF/e2ee/key_manager.dart:48-49,102-108 |
| Device id | Generated by the client: 32 lowercase hex characters, sent as device_id on login and register. The server accepts 8-64 of [A-Za-z0-9_-], defaults to "default", and returns it as the JWT did claim |
core/device/device_identity.dart:66-69; Server/pkg/middleware/device.go:15-22 |
| DM envelope | ciphertext = nonce ‖ ct for the primary peer device; ratchet_header = header JSON bytes; x3dh_header = X3DH JSON bytes or null; device_ciphertexts[] = {device_id, ciphertext} where each non-primary ciphertext is base64 of UTF-8 JSON {"ct":b64,"hdr":b64,"x3dh":{…}?} (so double-base64 on the wire, and x3dh is an embedded object here but bytes at top level). Primary = first peer device, sessions before fresh claims |
dm_manager.dart:116-150; data/backend/backend_api.dart:1366-1378 |
| Own-device copy | Key = HKDF(ikm = the 64-byte Ed25519 secret, salt = 32 zero bytes, info = "zipp-dm-self-v1", L = 32); crypto_secretbox (XSalsa20-Poly1305), nonce(24) ‖ ct, filed under the sender's own device_id (falling back to "default"). One key per identity, not per channel |
dm_manager.dart:22-25,61-62,439-468 |
| Sessions | Keyed by (peer user, channel, peer device). X3DH runs only when no session exists — a new x3dh_header on an existing session is ignored. Sessions are not in the PIN backup |
CF/dm/dm_session_store.dart:23-24; dm_manager.dart:318-324 |
| Safety number | fp = SHA-512(0x00 0x00 ‖ IK_pub(32) ‖ UTF-8 user id), then 5199 × fp = SHA-512(fp ‖ IK_pub); keep the first 30 bytes; six 5-byte big-endian chunks, each mod 100000, zero-padded to 5 digits. The byte-wise smaller fingerprint comes first → 60 digits, shown as 12 groups of 5. Computed per pair of devices (mine × each of theirs). User id = the server's UUID string |
CF/dm/safety_number.dart:21-80; dm_providers.dart:185-191 |
| Trust | Trust-on-first-use pin per (peer user, peer device), local only. A changed key blocks sending and shows a placeholder on receive | dm_manager.dart:189-201,336-347 |
| PIN | Exactly 8 digits; 18-entry deny list in Server/pkg/crypto/v1/pin.go:27-34 |
pin.go:14-34; CF/e2ee/key_backup.dart:41-46 |
| Backup master key | Argon2id(UTF-8 PIN, 16-byte salt, t = 3, m = 64 MiB, p = 1, 32 bytes) | pin.go:71-78; key_backup.dart:19-27 |
| Backup verifier | Argon2id(PIN ‖ ":verifier", same salt, t = 1, m = 16 MiB, p = 1, 16 bytes), stored raw |
pin.go:117-119 |
| Backup blob | AES-256-GCM, empty AD, nonce(12) ‖ ct ‖ tag(16). Plaintext, big-endian: u32 len ‖ IK secret(64) ‖ u32 len ‖ SPK secret(32) ‖ u32 n ‖ n × (u64 id ‖ u32 len ‖ OTP secret). The known-answer vector in e2ee.md is checked by the client's startup self-test |
pin.go:196-211; CF/crypto/crypto_selftest.dart:69,235 |
| Restore | The client fetches the blob with GET /api/keys/backup and re-derives the public keys (IK = secret[32:], SPK/OTP = scalarmult_base); SPK id resets to 1. Contains keys only — no sessions, trust pins or device id |
key_manager.dart:155-174; device_keys.dart:95-116 |
Discrepancies¶
- KDF_RK info string.
e2ee.md:111says"ratchet-root"; the code uses"zipp-chat-dr-ratchet-v1". The spec value does not interoperate. - X3DH header field names.
e2ee.md:68-73,api-docs/api/dm.md:68,Server/docs/client-technical-design.md:1633-1637andServer/docs/api.md:2495-2499useephemeral_key/signed_prekey_id/one_time_prekey_id/sender_device_idand omit the identity key. The code useseph/spk_id/otp_id/did/ik. - Where IK_A comes from.
e2ee.md:84andclient-technical-design.md:1649say the receiver fetches it from the key server; the code takes it from the header'sik. - Bundle response shape.
api-docs/keyserver/bundles.md:44-56,data-types.md:376-379,client/e2ee-implementation.md:111,134andclient-technical-design.md:943show a nestedone_time_prekey: {id, key}or null and nodevice_id.Server/docs/api.md:2013-2027shows a single object with nulls. The server returns an array with flat, omitted-when- absent fields. (The client's ownPreKeyBundle.fromJsonparses the nested form; it is unused.) - Bundle auth.
bundles.md:40says no auth; every key-server route needs a JWT. - Verifier derivation is described four different ways
(
e2eefunc.md:430,433,client/e2ee-implementation.md:436-439,client-technical-design.md:1742-1754,api.md:2536). None matches the code row above. - Argon2 parallelism.
e2eefunc.md:20,424,server/encryption.md:164,api-docs/keyserver/backup.md:25,50,54,client/e2ee-implementation.md:457,client-technical-design.md:1008,1036,1735andapi.md:2088,2125,2535say p = 4. The code uses 1 (libsodium supports nothing else). - Salt size.
api.md:2084,2534says 32 bytes; it is 16, and the server does not check it. - Backup AD.
client-technical-design.md:1751says AD = user id; it is empty. - Weak-PIN list.
client/e2ee-implementation.md:496-498,backup.md:75-77andclient-technical-design.md:1717-1720differ frompin.go:27-34, which is the list in effect. - Restore flow.
client/e2ee-implementation.md:468,e2eefunc.md:453-473,client-technical-design.md:1020-1045andapi.md:2542restore withPOST /api/keys/backup/restore, which is deprecated; the client usesGET /api/keys/backup, which neitherbackup.mdnorapi.mddocuments.api.md:2102says the server runs the Argon2id KDF (it runs only the verifier);api.md:2120listspin_verifierin the response (it is not returned). The deprecated endpoint answers a lockout with 429 andlocked_until, not 401. - Own-device copy.
e2eefunc.md:373-378,549,client/e2ee-implementation.md:344-352andclient/direct-messages.md:103describe a per-channel key from the X25519 IK with the channel id as salt, info"zipp-chat-self-v1"and an AEAD. The code row above is what runs. - Multi-device payload.
e2ee.md:134,e2eefunc.md:392,client-technical-design.md:1688andclient/direct-messages.md:57,164say the message key is encrypted per device and leave the format open. The code sends a complete ratchet message per device in the envelope above. - Decryption and session keying.
client/direct-messages.md:98-116,client/e2ee-implementation.md:26,43ande2eefunc.md:95key sessions per channel and say own-device messages cannot be decrypted. Sessions are per channel and device, and copies for the sender's other devices are ratchet messages like any other. - One-time prekey deletion.
e2eefunc.md:191andclient/e2ee-implementation.md:144say the receiver deletes a used OTP secret. The client keeps it, so it stays in later backups. - Signed prekey rotation.
e2ee.md:26,526,bundles.md:27,31,66-73,client/e2ee-implementation.md:63andclient-technical-design.md:906,922require weekly rotation with an incrementing id. There is no rotation and the id is 1. Adding it is not a drop-in change: secure-channel epoch grants are sealed to the SPK, not the IK (CF/e2ee/epoch_keys.dart:189-190,262), contrary toe2ee.md:146,159-161andclient/e2ee-implementation.md:379-389. - One-time prekey batch.
e2ee.md:517,525,api-docs/keyserver/prekeys.md:30,43,e2eefunc.md:117,127,139,client-technical-design.md:175,206,971andapi.md:2449,2555-2558say 100 keys, refilled below 20. The client refills to 50 below 10.api.md:2047-2058describes a 500-key cap per user; there is no cap, only 100 per upload, and counts are per device. - Device id origin.
e2ee.md:523,e2eefunc.md:94,137andclient/e2ee-implementation.md:42say the device id is read from the JWT. The client generates it and sends it at login (api.md:246-266is right). - Safety numbers.
e2ee.md:490andencryption-modes.md:445say one per peer; it is one per pair of devices. - What a restore rebuilds.
e2ee.md:362ande2eefunc.md:470say ratchet sessions can be rebuilt from the backup; sessions are not in it. - DM cipher.
client-technical-design.md:1589andapi.md:2443say AES-256; DMs use XChaCha20-Poly1305. Server/docs/api.md§20 (lines 2460-2523) is wrong throughout: it sends DMs to/api/channels/{id}/messagesas base64content, skips the Ed25519→X25519 conversion and the HKDF salt and info, and has the receiver compute DH1 with EK instead of IK_A. DMs use/api/dm, whichapi.mddoes not document (seeapi-docs/api/dm.md).- Stale states.
e2eefunc.md:30,32,557say multi-device uses onlybundles[0]and secure channels are not implemented;e2eefunc.md:226gives HKDF assalt || zero32;client-technical-design.md:1667suggests a standard Double Ratchet library, which will not interoperate (custom info strings, JSON header, SPK as the first ratchet key). api-docs/api/dm.md:83sayslimitis 1-100; over 100 becomes 50. It also omits the 403 returned when either side has blocked the other.
Channel encryption¶
Parameters in effect¶
| Item | Value | Code |
|---|---|---|
| Mode values | persistent, secure. The rename to server_escrowed / rolling_key was decided but not done. A mode cannot be changed after creation |
Server migrations/000013; services/api/internal/server/model.go:41-42 |
| Default | secure — column default since 000021, and the create handler turns any value other than persistent into secure |
migrations/000021; server/handler.go:236-237 |
| Message envelope (both modes) | DEK = 32 random bytes. ciphertext = AES-256-GCM(DEK, UTF-8 text, AD = none). encrypted_dek = AES-256-GCM(K, DEK, AD = UTF-8 kek_version). Each is nonce(12) ‖ ct ‖ tag(16), base64 on the wire |
Server/pkg/crypto/v1/envelope.go:15-33, aes.go:13-28; Client lib/features/e2ee/message_crypto.dart:36-57 |
| K, persistent | The channel KEK from Vault KV v2 secret/data/channels/<id>/kek/v1; kek_version is always "v1" (created lazily, not rotated) |
message/kek.go:59-80 |
| K, secure | The 32-byte epoch key; kek_version = "secure", plus epoch_id, which must be the newest epoch (400/409 otherwise) |
backend_providers.dart:1100,1263; message/service.go:99-111 |
| Escrow key delivery (persistent) | GET /api/channels/{id}/escrow-key → crypto_box_seal(KEK, the device's signed prekey) |
channel/service.go:63-113 |
| Epoch grants (secure) | crypto_box_seal(epoch key, the device's signed prekey) — the SPK, not the identity key — one per (user, device) |
CF/e2ee/epoch_keys.dart:255-265,349-358; migrations/000015 |
member_hash |
hex(SHA-256(concatenation of the sorted server member ids)) | channel/service.go:312-317,361 |
Rotation (POST /api/channels/{id}/epoch) |
Requires epoch_num = current + 1 (409 otherwise), a matching member_hash, and a grant for every published device of every member. Clients rotate on MEMBER_JOIN / MEMBER_LEAVE and at channel creation; there is no server-side rotation |
channel/service.go:331-407; CF/gateway/gateway_providers.dart:358-415 |
| Grant top-up | A client that opens the current epoch seals it for members' devices that lack a grant | epoch_keys.dart:160-176; channel/service.go:489-537 |
| Old epochs | GET /api/channels/{id}/epoch/{epochID} returns only existing grants; each message is opened with its own epoch_id |
channel/service.go:452-484 |
| Moderation decrypt | POST /api/messages/{id}/decrypt {reason} — Administrator only, refused for kek_version == "secure", audit row written first |
message/service.go:241-306 |
| Attachments | The same envelope under the channel or epoch key. The thumbnail (256 px JPEG, quality 75) is sealed under the same DEK. POST /api/media/sealed (JSON, base64); the download returns raw ciphertext with X-Encrypted-DEK, X-KEK-Version and X-Original-Content-Type headers |
backend_providers.dart:1146-1205; media/handler.go:413-510 |
| Media limits | 50 MB per file, 64 KiB thumbnail, declared thumbnail dimensions ≤ 512; per-user quota MEDIA_USER_QUOTA_BYTES (default 1 GiB, ciphertext only, thumbnails exempt) → 507 |
media/model.go:124-142; config/config.go:57 |
| Avatars and banners | Encrypted by the server under the media KEK (secret/media/kek), AD "media-dek" / "media-file" / "media-thumb", thumbnailed server-side |
media/service.go:238-288,453-480 |
| Search chunk | JSON {"v":1,"p":{token:[message ids]},"x":{message id: preview ≤ 160 chars}}, sealed with the message envelope. chunk_key = current epoch id (secure) or the upload time's YYYY-MM (persistent). Optimistic version counter, 409 on conflict, expected_version: 0 creates. Tokens: lower-cased, split on [^a-z0-9]+, length ≥ 2. Hits are verified by decrypting the messages (?ids=, up to 100) and requiring every query token |
search_index.dart:29-39,80-84; search_manager.dart:49-70,185-219 |
| Voice room key | 32 random bytes generated by the voice service, stored in Vault at secret/channels/<id>/voice_key, returned as room_key to anyone with Connect; replaced only when LiveKit reports room_finished |
voice/service.go:68-113,227-232 |
| Voice frame encryption | LiveKit BaseKeyProvider defaults: shared key, key index 0, setRawKey(base64Decode(room_key)), ratchet salt "LKFrameEncryptionKey", ratchet window 16, key ring 16. On web the frame key is PBKDF2-SHA256 (100 000 iterations) → AES-GCM-128. A join aborts if room_key is empty or the E2EE manager does not start |
CF/voice/voice_controller.dart:196-236 |
Discrepancies¶
- Secure-channel cipher.
e2ee.md:209-237andclient/messaging.md:8,28-39,225-233specifycrypto_secretboxwithcontent = base64(nonce ‖ ct), ande2ee.md:30-31XChaCha20-Poly1305 for epoch and file keys. Both modes use the AES-256-GCM envelope above. - What grants are sealed to.
e2ee.md:146,158-161,client/messaging.md:148,185,server/encryption.md:120andencryption-modes.md:147say the identity key; it is the signed prekey (Server/docs/api.md:547is right). See E2EE discrepancy 16 for why that blocks SPK rotation. encrypted_dekin secure channels.api.md:1243(and the comment atmessage/model.go:107-110) says it is empty; the client sends the DEK wrapped under the epoch key.- Edits after a rotation. The client encrypts an edit under the
current epoch, but edit requests carry no
epoch_idand the server keeps the original one, so an edit made after a rotation cannot be decrypted by readers.client/messaging.md:102describes the intent. - History for new members.
encryption-modes.md:84ande2ee.md:241say a joiner gets none. Through grant top-up a joiner receives the current epoch, and so can read messages since the last rotation — which only happens when a member's client is online to do it (e2ee.md:197-207half-says this). - Voice encryption is undocumented, and described wrongly where it is
mentioned:
api.md:1586calls the key "AES-256"; the frame key is AES-128. Nothing inProjectInfo/says the server generates and stores the key.client/voice.md:184says LiveKit refuses publishing withoutSpeak; the token always grants publishing. encryption-modes.md§9 is stale: per-device identities (000018), client-side thumbnails and the moderation decrypt route all exist. The §2 divergence table (lines 31-41) no longer applies, and §4 line 171 gives Argon2 p = 4 (it is 1). Lines 191-195 and 208-210 say server thumbnailing was removed; it remains for avatars and banners.- Media docs.
client/media-files.md,server/encryption.md:15-16,152andapi-docs/media/*describekey_type public/dm,public_dek,media_file_grantsand XChaCha, none of which exist (key_typeisserver/channel, migration 000014).Server/docs/client-media.md:60,93,310documents only the plaintext upload path;POST /api/media/sealedandGET /api/media/sealed/{id}are undocumented (only/sealed/thumbis, atapi.md:1829).api.md:1817says thumbnail dimensions are recorded; they are stored as NULL. - Plaintext reads.
client/messaging.md:7,47-49andserver/encryption.md:61say the server returns plaintext; reads return only the envelope. (The deprecated plaintextcontentfield is still accepted on send.) - Stale code comments:
server/model.go:42("channel key per member") andmessage/kek.go:8-9("server decrypts at read time").
REST API¶
Server/docs/api.md is the canonical REST reference and is accurate on
encryption, devices, 2FA, push and sealed media. It does not yet document:
- direct messages —
/api/dm,/api/dm/{id},/api/dm/{id}/messages,/api/dm/{id}/messages/{mid}(documented inapi-docs/api/dm.md); - friends (5 routes) and blocks (3) under
/api/users/@me/{friends,blocks}; - bans (3); read state (
GET /api/servers/{id}/read-states,GET/PUT /api/channels/{id}/read-state); GET/PUT /api/channels/{id}/keysandPOST /api/channels/{id}/epoch/{eid}/grants(mentioned atapi.md:582);GET /api/users/search,PATCH /api/auth/password,DELETE /api/users/@me,POST /api/reports/csam;POST/GET /api/media/sealedand the older/media/*routes;POST /api/auth/refreshhas no section, and the register and login examples omitrefresh_token, which both return. Login may instead return{two_factor_required, two_factor_token}.
Older notes under api-docs/ that are wrong and not published:
api-docs/api/channels.md:33,37andmessages.md:5-9use the design namesserver_encrypted | rolling_key | peer_providedand a plaintextcontentsend body. The wire values arepersistentandsecure, and any other value is silently treated assecure(server/handler.go:236-237). The send body is{ciphertext, encrypted_dek, kek_version, epoch_id, reply_to_id, attachment_ids};contentis deprecated.api-docs/api/voice.md:19-24documentsroom_nameand noroom_key. The response is{token, livekit_url, room_key}; a client built from that page would join calls unencrypted.api-docs/api/device-tokens.md— the table was dropped in migration 000022; push uses/api/push/*.api-docs/keyserver/bundles.md— see E2EE discrepancies 4 and 5.api-docs/media/upload.mdandfiles.md— the legacy upload flow, a nonexistentGET /media/raw/{key}, and a typo'd/api/channels/{}/msgs.api-docs/api/auth.md— no 2FA, nodevice_id, and advice to keep tokens in localStorage.api-docs/api/friends.md:70documentsPOST /api/users/{id}/friends, which does not exist.
Gateway¶
websocket.md is not published: it lists 10 of the 23 events, with wrong
scopes and shapes. The protocol in effect:
- Connect:
GET /gateway, JWT in theAuthorizationheader or?token=. A bad token gets 401 before the upgrade. Frames are at most 4 KiB; a slow client's 256-frame buffer drops silently. - Ops, client → server:
subscribe,unsubscribe,typing,heartbeat. Server → client:hello(no data),event({"op":"event","type":…,"data":…}),heartbeat_ack,error(services/api/internal/gateway/events.go:11-25). - Liveness is WebSocket ping/pong: the server pings every 54 s and drops
the connection 60 s after the last pong. The application-level
heartbeatonly gets aheartbeat_ackback and does not keep a connection alive —websocket.md:57,server/gateway.mdandclient/gateway.mdall say it does. - No resume, no session id. On every
hello, discard cached messages, refetch over REST and subscribe again. user.<your id>is subscribed automatically beforehello; channels, servers and DMs need an explicitsubscribe.unsubscribeignoresserver_ids.- Events by subject. Channel:
MESSAGE_CREATE,MESSAGE_UPDATE,MESSAGE_DELETE,REACTION_ADD,REACTION_REMOVE,PIN_UPDATE,TYPING_START,VOICE_STATE_UPDATE. Server:CHANNEL_CREATE,CHANNEL_UPDATE,CHANNEL_DELETE,ROLE_UPDATE,MEMBER_UPDATE,MEMBER_JOIN,MEMBER_LEAVE. User:DM_OPEN,FRIEND_REQUEST,FRIEND_ACCEPT,FRIEND_REMOVE.PRESENCE_UPDATEandUSER_UPDATEgo to the user and every server they share.MESSAGE_*carry the full REST message object. server/gateway.md:93-99gives the connection lifecycle in the wrong order and:123the wrongPublishsignature.client/gateway.md:138saysMESSAGE_CREATEcarriesencryption_mode; it does not.
Push notifications¶
push-notifications.md is canonical for the design, with these differences
from the implementation:
PUT /api/push/registrationreturns{transport, encrypted, created_at, updated_at}; theencryptedfield is not in the spec.POST /api/push/drainreturns[]for an unregistered device. Device iddefaultis rejected on every push route.- The spec says the client fetches from
first_message_id; the client fetches the latest 50 messages per channel and slices. - UnifiedPush without encryption keys is described as acceptable; the client
drops wakes it cannot decrypt (
push_platform_io.dart:150). - The UnifiedPush instance name is
zippchat:<apiBase>. - FCM
SENDER_ID_MISMATCHis also treated as a dead token. - Operator variables:
FCM_PROJECT_ID(api and worker),FCM_CREDENTIALS_JSONandPUSH_UP_ALLOW_PRIVATE(worker).server/deployment.md:118-122listsFCM_CREDENTIALS_FILEandAPNS_*, which nothing reads. api-docs/api/device-tokens.md,server/services/worker.md:40-77andserver/database.md:438describe the APNs device-token design that was replaced.
For reference, the wake payload is the constant {"v":"1","t":"wake"}.
UnifiedPush wakes are RFC 8291 aes128gcm when the registration has keys,
with TTL: 3600, Topic: wake and Urgency: high|normal; FCM wakes are
data-only with collapse_key "wake", TTL 3600 s and priority HIGH or NORMAL.
Priority is high when any pending message is a DM. Wakes coalesce per
(registration, channel): at most one outstanding per 15 s, a re-wake after
15 minutes, and the registration is dropped after 10 failures.
Deployment docs¶
development.md(Tauri, npm, MinIO,VITE_*) anddeployment.md(a nonexistentscripts/deploy.sh, a removed db-migrator Job, the wrong media KEK path — it issecret/media/kek— and Forgejo CI, where CI is Drone) are stale and not published.server/deployment.md:149-166says Vault is initialised separately; thedocker-compose.ymlit describes runs Vault in-devmode and loses every key on restart.Server/docs/production-setup.md(kubeadm, Redis 7, MinIO) andServer/DEPLOY.md:10are stale;Server/docs/deployment-k3s.mdis the deploy guide.