on master help sign in
Update docs: PROTOCOL.md rewrite for wire format accuracy, CLAUDE.md persistence notes, README client runbook
commit1ef8c138534d06c69ba27040fe4d3114c4952b61 authoriko <mail@iko.soy> dateMon 11 May 2026, 14:52 (5 months ago) parent7a97d10
3 files changed+273−357
CLAUDE.md+17−12 PROTOCOL.md+244−343 README.md+12−2
CLAUDE.md+17−12
@@ -6,23 +6,28 @@ Decentralized E2E encrypted messenger. No accounts, no home servers — identity
66
77- **Client:** F# / .NET 10 / Fabulous (Elm-style MVU) + Avalonia UI
88- **Server:** Knot (custom functional language, compiled via Rust toolchain)
9−- **Crypto:** libsodium (Ed25519 + X25519 + XChaCha20-Poly1305)
10−- **Persistence:** SQLite via Dapper.FSharp (client), in-memory (server)
9+- **Crypto:** libsodium (Ed25519 + X25519 + XChaCha20-Poly1305), LZ4 plaintext compression
10+- **Persistence:** SQLite — client via Donald + Microsoft.Data.Sqlite; server via the Knot runtime, which transparently persists every `*ref` to SQLite
1111
1212## Project Layout
1313
14−- `client/` — F# cross-platform app (macOS, iOS)
15− - `App.fs` — Main state machine (Model/Msg/CmdMsg/update/view)
16− - `Crypto.fs` — Encryption/decryption, key derivation
14+- `client/` — F# cross-platform app (macOS desktop, iOS)
15+ - `App.fs` — Top-level state machine (Model / Msg / CmdMsg / update)
16+ - `AppTypes.fs` — Shared model/msg/envelope types and helpers
17+ - `Constants.fs` — App-wide constants (HKDF info, poll timing, profile/settings IDs)
18+ - `Crypto.fs` — Encryption/decryption, key derivation, signing
1719 - `ApiClient.fs` — HTTP transport (auth, poll, send)
18− - `Store.fs` — SQLite persistence
20+ - `Protocol.fs` — Envelope serialize/parse + send orchestration
21+ - `Store.fs` — SQLite persistence (contacts, messages, settings, profile, outbox)
1922 - `Phonemic.fs` — Urbit @p syllable encoding for human-readable keys
20− - `Buttons.fs` — Animated button component
21− - `Platform/Desktop/` — macOS entry point
22− - `Platform/iOS/` — iOS entry point + native photo picker
23−- `server.knot` — Complete server implementation (routes, federation, gossip)
24−- `servers.json` — Bootstrap server list
25−- `install.sh` — Linux deployment (systemd + Caddy)
23+ - `Buttons.fs`, `Labels.fs`, `TextFields.fs`, `Styles.fs`, `Keyboard.fs` — UI primitives
24+ - `ViewSetup.fs`, `ViewConversations.fs`, `ViewChat.fs`, `ViewAddContact.fs`, `ViewSettings.fs`, `ViewEditProfile.fs` — Per-page views
25+ - `Platform/Desktop/` — macOS entry point + `.app` bundle wiring
26+ - `Platform/iOS/` — iOS entry point, native photo picker, native QR scanner
27+- `server.knot` — Complete server implementation (routes, federation, gossip, presence, retries)
28+- `servers.json` — Public relay list (flat JSON array of URLs)
29+- `install.sh` — Linux deployment (downloads release binary, sets up systemd + Caddy)
30+- `PROTOCOL.md` — Wire protocol specification
2631
2732## Build & Run
2833
PROTOCOL.md+244−343
@@ -12,15 +12,15 @@ Skrepka is a decentralized, end-to-end encrypted messaging protocol. It has no u
1212
1313- **No accounts.** A user's identity is their cryptographic keypair. No registration, no passwords, no servers storing credentials.
1414- **No home server.** Users connect to any server in the mesh and can switch freely. Servers are disposable relays.
15−- **End-to-end encryption.** Servers see only envelope metadata. Message content is opaque.
15+- **End-to-end encryption.** Servers see only routing metadata. Message content — including the sender's identity — is opaque to the server.
1616- **Open federation.** Any server can join the mesh. Operators may optionally blocklist specific peers.
17−- **Ephemeral.** All messages expire after 30 days — on servers and on devices. No permanent history.
17+- **Ephemeral.** Server mailboxes expire after 30 days. No permanent server-side history.
1818- **Simplicity.** Plain HTTP transport, no session negotiation, no ratchets.
1919
2020### Non-Goals
2121
2222- Forward secrecy (per-message ephemeral keys provide partial protection, but no ratchet)
23−- Metadata privacy (servers see sender, recipient, and timestamp)
23+- Recipient metadata privacy (servers see the recipient and timestamps; the sender is not visible to servers)
2424- Durable message storage (servers queue temporarily, not permanently)
2525- Peer-to-peer delivery (at least one server must be reachable)
2626
@@ -38,32 +38,20 @@ Each device generates its own independent keypair and is treated as a separate i
3838
3939### Key Format
4040
41−Public keys are encoded as **base64url** (RFC 4648 §5, no padding).
41+Public keys, signatures, and encrypted blobs are encoded as **lowercase hexadecimal** on the wire and in storage. An Ed25519 public key is 32 bytes (64 hex chars); a signature is 64 bytes (128 hex chars).
4242
43−### Identity URI
43+### Sharing an Identity
4444
45−```
46−skrepka://<base64url(ed25519_pubkey)>@<server_host>
47−```
48−
49−Example:
45+Identities are shared out-of-band as plain text or QR codes. The client displays public keys in two forms:
5046
51−```
52−skrepka://dGhpcyBpcyBhIHRlc3Q@relay.example.com
53−```
47+- **Hex** — 64 lowercase hex characters (the canonical wire form).
48+- **@p (phonetic)** — the public key rendered as Urbit-style syllables (e.g. `~ridler-binzod-mavpub-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
5449
55−The URI contains everything needed to reach someone:
56−
57−- **Public key** — who they are
58−- **Server host** — a server where they were last seen or any known relay
59−
60−Since there is no home server, the server component is a hint, not an authority. The recipient may be connected to a different server by the time a message is sent. The gossip layer resolves current location.
61−
62−Identity URIs are designed to be shared out-of-band: copied as text, embedded in links, or scanned as QR codes.
50+Servers are addressed separately. A client stores a single server URL (e.g. `https://relay.example.com`) in local settings; this is purely a transport choice and has nothing to do with identity. Two users on different servers can still talk via federation.
6351
6452### Trust Model
6553
66−**Trust-on-First-Use (TOFU).** The first time a client encounters a public key, it is accepted and cached. Optional out-of-band verification (e.g., comparing fingerprints in person) is supported but not required by the protocol.
54+**Trust-on-First-Use (TOFU).** The first time a client encounters a public key, it is accepted and cached. Optional out-of-band verification (e.g., comparing fingerprints or @p syllables in person) is supported but not required by the protocol.
6755
6856### Contacts
6957
@@ -81,61 +69,85 @@ There is no global directory. Users assign local nicknames to public keys on the
8169| Key agreement | X25519 |
8270| Key derivation | HKDF-SHA256 |
8371| Symmetric cipher | XChaCha20-Poly1305 |
72+| Compression | LZ4 (pre-encryption) |
8473
8574### Per-Message Encryption
8675
8776Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
8877
78+The wire-visible "envelope" carries only the recipient and the opaque blob. The sender's public key and signature are placed **inside** the AEAD ciphertext, so the server learns nothing about who sent a given message. The signature is bound to the recipient's public key, preventing a captured ciphertext from being replayed against a different recipient.
79+
8980**Encryption flow:**
9081
91821. Generate an ephemeral X25519 keypair:
9283 ```
9384 ephemeral_private, ephemeral_public = x25519_generate()
9485 ```
95−2. Compute the raw shared secret using the ephemeral private key and the recipient's long-term X25519 public key:
86+2. Derive the recipient's X25519 public key from their Ed25519 public key, and compute the raw shared secret:
9687 ```
88+ recipient_x25519_public = ed25519_pk_to_curve25519(recipient_ed25519_public)
9789 raw_secret = x25519(ephemeral_private, recipient_x25519_public)
9890 ```
99−3. Derive the encryption key using HKDF-SHA256:
91+3. Derive the symmetric encryption key using HKDF-SHA256:
10092 ```
10193 key = hkdf_sha256(
102− ikm = raw_secret,
94+ ikm = raw_secret,
10395 salt = ephemeral_public || recipient_x25519_public,
10496 info = "skrepka-v1",
10597 len = 32
10698 )
10799 ```
108−4. Generate a random 24-byte nonce:
100+4. LZ4-compress the plaintext payload (see §4):
109101 ```
110− nonce = random_bytes(24)
102+ compressed = lz4_pickle(plaintext_bytes)
111103 ```
112−5. Encrypt the plaintext message:
104+5. Sign the recipient pubkey concatenated with the compressed plaintext. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
113105 ```
114− ciphertext = xchacha20_poly1305_encrypt(key, nonce, plaintext)
106+ signature = ed25519_sign(sender_ed25519_private,
107+ recipient_ed25519_public || compressed)
115108 ```
116−6. Sign the ciphertext with the sender's Ed25519 private key:
109+6. Build the inner buffer that will be encrypted:
117110 ```
118− signature = ed25519_sign(sender_ed25519_private, ciphertext)
111+ inner = sender_ed25519_public || signature || compressed
112+ ```
113+7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
114+ ```
115+ nonce = random_bytes(24)
116+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner) // no associated data
117+ ```
118+8. Emit the on-wire blob:
119+ ```
120+ blob = ephemeral_public || nonce || ciphertext
119121 ```
120122
121123**Decryption flow:**
122124
123−1. Verify the signature against the sender's Ed25519 public key.
124−2. Compute the raw shared secret using the recipient's long-term X25519 private key and the ephemeral public key from the message.
125−3. Derive the encryption key using HKDF-SHA256 with the same parameters.
126−4. Decrypt the ciphertext using the derived key and nonce.
125+1. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
126+2. Derive the recipient's X25519 private key from the recipient's Ed25519 private key, compute the raw shared secret with `ephemeral_public`, and HKDF-derive the same `key`.
127+3. AEAD-decrypt the ciphertext to recover the inner buffer.
128+4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, remainder is `compressed`.
129+5. Verify the signature over `recipient_ed25519_public || compressed` using `sender_ed25519_public`. Reject the message on failure.
130+6. LZ4-decompress to recover the plaintext payload.
127131
128132### Encrypted Blob Format
129133
134+On the wire (cleartext, server-visible):
135+
130136```
131−ephemeral_x25519_pubkey (32 bytes)
132−nonce (24 bytes)
133−ciphertext (variable)
134−sender_ed25519_pubkey (32 bytes)
135−signature (64 bytes)
137+ephemeral_x25519_pubkey (32 bytes)
138+nonce (24 bytes)
139+ciphertext (variable, includes 16-byte AEAD tag)
136140```
137141
138−Total overhead: 152 bytes per message.
142+After AEAD decryption, the inner buffer is:
143+
144+```
145+sender_ed25519_pubkey (32 bytes)
146+signature (64 bytes)
147+compressed_plaintext (variable, LZ4)
148+```
149+
150+The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes.
139151
140152---
141153
@@ -143,73 +155,81 @@ Total overhead: 152 bytes per message.
143155
144156### Envelope
145157
146−The envelope is what servers see. It is transmitted in cleartext (not encrypted) so servers can route messages.
158+The envelope is what servers see. It carries only the recipient and the opaque encrypted blob — there is **no `from` field**. The sender's identity is recoverable only by the recipient, from inside the decrypted blob.
147159
148160```json
149161{
150− "to": "<base64url(recipient_ed25519_pubkey)>",
151− "from": "<base64url(sender_ed25519_pubkey)>",
152− "encrypted_blob": "<base64url(encrypted_blob)>",
153− "timestamp": 1679000000
162+ "to": "<hex(recipient_ed25519_pubkey)>",
163+ "encryptedBlob": "<hex(encrypted_blob)>"
154164}
155165```
156166
157−| Field | Type | Description |
158−|------------------|--------|--------------------------------------------------|
159−| `to` | string | Recipient's Ed25519 public key (base64url) |
160−| `from` | string | Sender's Ed25519 public key (base64url) |
161−| `encrypted_blob` | string | The encrypted blob from §3 (base64url) |
162−| `timestamp` | number | Unix timestamp (seconds) when the message was sent |
167+| Field | Type | Description |
168+|-----------------|--------|--------------------------------------------------|
169+| `to` | string | Recipient's Ed25519 public key (64 lowercase hex)|
170+| `encryptedBlob` | string | Hex-encoded blob from §3 |
163171
164−Servers route based on `to` and `from`. They never inspect `encrypted_blob`.
172+Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender.
165173
166174### Plaintext Payload
167175
168−After decryption, the plaintext is a JSON object with a `type` field:
176+After decryption (and LZ4 decompression), the plaintext is a UTF-8 JSON object with a `type` field:
169177
170178```json
171179{
172180 "type": "<message_type>",
173− "id": "<unique_message_id>",
174− "timestamp": 1679000000,
181+ "id": "<unique_message_id>",
182+ "ts": 1679000000000,
175183 ...
176184}
177185```
178186
179−The `id` field is a client-generated UUID v4. Clients MUST deduplicate incoming messages by `id` — the same message may arrive more than once due to presence fan-out (see §7).
180−
181−Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
187+- `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
188+- `id` is a client-generated unique ID (e.g. UUID v4). Clients MUST deduplicate incoming messages by `id`.
189+- Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
182190
183191### Message Types
184192
185−| Type | Description | Additional Fields |
186−|-------------------|------------------------------------------|-------------------------------------------------|
187−| `text` | A text message | `body` (string) |
188−| `media` | An inline media attachment | `media_type` (MIME), `filename`, `data` (base64)|
189−| `group.invite` | Invite to a group (see §8) | `group_id`, `group_name`, `members` |
190−| `group.leave` | Leave a group | `group_id` |
191−| `group.update` | Update group metadata | `group_id`, `group_name`, `members` |
192−| `delivery.ack` | Delivery acknowledgement | `ack_ids` (array of message IDs) |
193−| `read` | Read receipt | `read_ids` (array of message IDs) |
194−| `profile` | Share display name and/or photo | `display_name` (string), `photo` (base64, optional) |
193+The current implementation defines three plaintext message types:
195194
196−Media files are embedded inline in the encrypted payload as base64-encoded `data`. There is no separate media hosting or upload endpoint.
195+| Type | Description | Additional Fields |
196+|-----------------|------------------------------------------|------------------------------------------------|
197+| `text` | A text message | `body` (string) |
198+| `delivery.ack` | Sender-side delivery confirmation | `ack_ids` (array of message IDs) |
199+| `profile` | Share display name, bio, and/or photo | `display_name`, `bio` (strings), `photo` (base64-encoded image, optional) |
200+
201+Future message types (media attachments, read receipts, group messages) are not part of this revision.
197202
198203### Profiles
199204
200−Users can share a display name and photo with contacts by sending a `profile` message. This is sent as a regular encrypted message — servers never see profile data.
205+Users share a display name, bio, and photo with contacts by sending a `profile` message. This is a regular encrypted message — servers never see profile data.
201206
202207```json
203208{
204209 "type": "profile",
205210 "id": "msg_456",
206− "timestamp": 1679000000,
211+ "ts": 1679000000000,
207212 "display_name": "Alice",
213+ "bio": "writing things",
208214 "photo": "<base64(jpeg_data)>"
209215}
210216```
211217
212−Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name or photo. The recipient's client caches the profile locally. The `photo` field is optional and MAY be omitted to update only the display name.
218+Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo. The recipient's client caches the profile locally. `photo` is optional and MAY be omitted.
219+
220+### Delivery Acks
221+
222+When a recipient successfully decrypts an incoming `text` message, the recipient's client SHOULD send a `delivery.ack` message back to the sender:
223+
224+```json
225+{
226+ "type": "delivery.ack",
227+ "ack_ids": ["msg_123", "msg_124"],
228+ "ts": 1679000000000
229+}
230+```
231+
232+This is the only delivery-confirmation channel in the protocol: the server itself does **not** report delivery status to senders. See §9 for the full flow.
213233
214234### Blocking
215235
@@ -219,48 +239,44 @@ Blocking is client-side only. A blocked sender's messages are still received and
219239
220240## 5. Transport
221241
222−All communication uses **plain HTTP**.
242+All communication uses **plain HTTP**, served over TLS in production.
223243
224244| Direction | Mechanism |
225245|--------------------|----------------------|
226246| Client → Server | HTTP POST |
227−| Server → Client | Long polling |
247+| Server → Client | Long polling (POST) |
228248| Server → Server | HTTP POST |
229249
230250**Why HTTP + long polling:**
231251- No special protocols. Works through proxies, load balancers, and CDNs.
232−- Long polling provides near-real-time delivery over plain HTTP request/response cycles.
252+- Long polling provides near-real-time delivery over plain request/response cycles.
233253- No persistent connections, no WebSocket upgrade, no gRPC toolchain, no raw TCP.
234254
235255### Server Discovery
236256
237−New clients discover servers via a well-known URL:
238−
239−```
240−https://skrepka.org/servers.json
241−```
242−
243−The response is a JSON array of server hostnames:
257+Skrepka has no central directory. Clients are configured with a single server URL (stored locally) and stay on it until the user changes it. The repository ships a `servers.json` file listing public relays as a convenience:
244258
245259```json
246−{
247− "servers": [
248− { "host": "relay1.example.com", "region": "eu" },
249− { "host": "relay2.example.com", "region": "us" },
250− { "host": "relay3.example.com", "region": "ap" }
251− ]
252−}
260+[
261+ "https://1.skrepka.lol",
262+ "https://2.skrepka.lol",
263+ "https://3.skrepka.lol"
264+]
253265```
254266
255−The client picks a server from the list (e.g., by latency or region preference) and connects. This list is a bootstrap mechanism only — once connected, the client may learn about other servers through the mesh.
267+This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list.
256268
257269### Content Type
258270
259271All request and response bodies use `application/json`.
260272
273+### Federation Transport
274+
275+Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. See §7.
276+
261277### Message Size Limit
262278
263−The maximum size of an encrypted blob is **20 MB**. Servers MUST reject messages exceeding this limit. This applies to the entire encrypted payload, including inline media.
279+The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). Servers reject larger messages.
264280
265281---
266282
@@ -273,185 +289,122 @@ When a client connects, it proves ownership of its keypair:
2732891. Client sends its public key:
274290 ```
275291 POST /auth/challenge
276− { "pubkey": "<base64url(ed25519_pubkey)>" }
292+ { "pubkey": "<hex(ed25519_pubkey)>" }
277293 ```
278294
279−2. Server responds with a random challenge (valid for 60 seconds):
295+2. Server responds with a random challenge string (valid for 60 s):
280296 ```json
281− { "challenge": "<base64url(random_32_bytes)>" }
297+ { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
282298 ```
283299
284−3. Client signs the challenge and sends it back:
300+3. Client signs the **UTF-8 bytes of the challenge string** with its Ed25519 private key and submits:
285301 ```
286302 POST /auth/verify
287303 {
288− "pubkey": "<base64url(ed25519_pubkey)>",
289− "challenge": "<base64url(challenge)>",
290− "signature": "<base64url(ed25519_sign(private_key, challenge))>"
304+ "pubkey": "<hex(ed25519_pubkey)>",
305+ "challenge": "<random_hex>",
306+ "signature": "<hex(ed25519_sign(private_key, utf8(challenge)))>",
307+ "revokeOthers": false
291308 }
292309 ```
310+ Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices").
293311
2943124. Server verifies the signature. On success, returns a session token valid for **1 hour**:
295313 ```json
296− { "token": "<session_token>", "expires_at": 1679003600 }
314+ { "token": "<session_token>", "expiresAt": 1679003600000 }
297315 ```
298316
299−The session token is included in all subsequent requests as a bearer token:
317+The session token is included in subsequent authenticated requests as a bearer token:
300318```
301319Authorization: Bearer <session_token>
302320```
303321
304−When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow. Servers SHOULD broadcast an `offline` gossip event only if the client does not re-authenticate within a grace period (e.g., 60 seconds).
305−
306−### Endpoints
307−
308−#### `POST /auth/challenge` — Request auth challenge
309−
310−**Request:**
311−```json
312−{ "pubkey": "<base64url(ed25519_pubkey)>" }
313−```
314−
315−**Response:**
316−```json
317−{ "challenge": "<base64url(random_32_bytes)>" }
318−```
319−
320−#### `POST /auth/verify` — Complete auth
322+Sessions are also bound to the client's source IP (read from `X-Forwarded-For` when the server runs behind a reverse proxy). A request from a different IP is rejected as unauthorized.
321323
322−**Request:**
323−```json
324−{
325− "pubkey": "<base64url(ed25519_pubkey)>",
326− "challenge": "<base64url(challenge)>",
327− "signature": "<base64url(signature)>"
328−}
329−```
324+When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
330325
331−**Response:**
332−```json
333−{ "token": "<session_token>", "expires_at": 1679003600 }
334−```
326+A successful `/auth/verify` also installs the pubkey in the local presence table and broadcasts an `online` gossip event to known peers (§7).
335327
336328### Error Responses
337329
338−All endpoints return errors in a consistent format:
330+Errors return a non-2xx HTTP status with a body of:
339331
340332```json
341−{
342− "error": "<error_code>",
343− "message": "<human-readable description>"
344−}
333+{ "error": "<code>" }
345334```
346335
347336| HTTP Status | Error Code | Meaning |
348337|-------------|----------------------|--------------------------------------|
349−| 400 | `bad_request` | Malformed request body |
350−| 401 | `unauthorized` | Missing, invalid, or expired token |
351−| 403 | `challenge_expired` | Auth challenge has expired (>60s) |
352−| 404 | `not_found` | Unknown pubkey or resource |
353−| 413 | `payload_too_large` | Encrypted blob exceeds 20 MB |
354−| 429 | `rate_limited` | Too many requests |
355−| 500 | `internal_error` | Server error |
338+| 400 | `self_send` | A message in `/messages` is addressed to the sender |
339+| 400 | `invalid_request` | Malformed federation request |
340+| 401 | `unauthorized` | Missing, invalid, or expired token, or wrong IP |
341+| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
342+| 413 | (HTTP only) | Encrypted blob exceeds limit |
343+| 429 | (HTTP only) | Per-route rate limit exceeded |
344+| 503 | `capacity` | Server-side resource cap reached |
356345
357−#### `GET /poll` — Long poll for new events
346+### `POST /poll` — Long poll for new events
358347
359−The client makes a blocking GET request. The server holds the connection open until new events are available or a timeout is reached.
348+The client makes a blocking POST. The server holds the connection open until new events are available or the long-poll timeout (25 s) elapses.
360349
361350**Headers:** `Authorization: Bearer <token>`
362351
363−**Query parameters:**
352+**Request:**
353+```json
354+{ "cursor": 0 }
355+```
364356
365−| Parameter | Type | Description |
366−|--------------|--------|--------------------------------------------------|
367−| `after` | string | Cursor from the previous poll response (optional) |
357+`cursor` is the maximum `receivedAt` value the client has already seen (`0` on first poll).
368358
369359**Response:**
370360```json
371361{
372362 "events": [
373− {
374− "type": "message",
375− "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
376− "id": "evt_001"
377− }
363+ { "encryptedBlob": "<hex>" }
378364 ],
379− "cursor": "evt_001"
365+ "cursor": 1679000000123
380366}
381367```
382368
383−- If events are available immediately, the server responds right away.
384−- If no events are available, the server holds the connection for up to 20 seconds.
385−- On timeout with no events, the server returns `{ "events": [], "cursor": "..." }`.
386−- The client should immediately issue the next poll after receiving a response.
369+- `cursor` is a server-side monotonic millisecond timestamp.
370+- Advancing past a message acts as an **implicit ack**: when the next poll arrives with a `cursor` greater than or equal to a stored message's `receivedAt`, the server drops the stored message **and any pending federation forwards for it**.
371+- If events are available immediately, the server responds right away. Otherwise it parks the connection on a row-level wait against the mailbox and returns when a new matching message lands or after 25 s, whichever comes first.
372+- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
373+- A single response is capped at 50 events; the client should poll again immediately after receiving any response.
387374
388−**Event types:**
389−
390−| Type | Data |
391−|------------|-------------------------------------|
392−| `message` | Envelope JSON (see §4) |
393−
394−#### `POST /messages` — Send a message
375+### `POST /messages` — Send messages
395376
396377**Headers:** `Authorization: Bearer <token>`
397378
379+Sends are **batched** and all-or-nothing. Up to 100 messages per request:
380+
398381**Request:**
399382```json
400383{
401− "to": "<base64url(recipient_ed25519_pubkey)>",
402− "encrypted_blob": "<base64url(encrypted_blob)>",
403− "timestamp": 1679000000
384+ "messages": [
385+ { "to": "<hex(recipient_pubkey)>", "encryptedBlob": "<hex>" }
386+ ]
404387}
405388```
406389
407−The server fills in `from` from the authenticated session.
408−
409390**Response:**
410−```json
411−{
412− "status": "delivered" | "federated" | "queued",
413− "message_id": "<server_assigned_id>"
414−}
415−```
391+- `200 {}` on success — every message in the batch has been enqueued for delivery (locally, via federation, or both).
392+- `400 { "error": "self_send" }` if any message targets the authenticated pubkey.
393+- `401 / 429 / 503` per the table above.
416394
417−- `delivered` — recipient is online locally, message is available for polling.
418−- `federated` — recipient is known to be online on a remote server; message has been accepted for federation forwarding. This does **not** guarantee delivery to the remote server — the forward is attempted asynchronously and retried on failure.
419−- `queued` — recipient is offline or unknown, message is queued for later delivery.
395+The server learns the sender from the bearer token, uses it only to reject self-sends and to enforce per-session rate limits, and does **not** persist the sender alongside the stored message.
420396
421−**`message_id` field:** If the client sends a non-empty `messageId` in the request, the server uses it for deduplication (idempotent sends). If omitted or empty, the server generates a unique ID and returns it. Clients that do not need idempotency may omit it.
397+Whether a recipient is currently reachable, offline, or known via federation is internal to the server. The sender's UI learns "they got it" only via a returning `delivery.ack` (§4) from the recipient — there is no server-issued delivery status.
422398
423−#### `POST /messages/ack` — Acknowledge receipt
399+There is **no `/messages/ack` endpoint**: poll cursor advance is the only acknowledgement signal a recipient sends to the server.
424400
425−**Headers:** `Authorization: Bearer <token>`
401+### Endpoints Not Present
426402
427−**Request:**
428−```json
429−{ "message_ids": ["id1", "id2"] }
430−```
403+The following endpoints exist in earlier drafts of this spec but are **not** implemented:
431404
432−**Response:**
433−```json
434−{ "ok": true }
435−```
436−
437−The server deletes acknowledged messages from its queue. If the message arrived via federation, the ACK is forwarded to the origin server so it can also clean up. This is a server-side queue management operation — delivery notification to the sender is handled by encrypted `delivery.ack` messages (see §4).
438−
439−#### `GET /lookup/:pubkey` — Look up a user's presence
440−
441−**Response (online):**
442−```json
443−{
444− "online": true,
445− "server": "other-server.example.com"
446−}
447−```
448−
449−**Response (offline):**
450−```json
451−{
452− "online": false
453−}
454−```
405+- `POST /messages/ack` — replaced by implicit ack on poll cursor advance.
406+- `GET /lookup/:pubkey` — presence information is internal; clients have no way to query it.
407+- `POST /federation/ack` — federation cleanup is driven by the receiving peer's poll-ack, not a back-channel.
455408
456409---
457410
@@ -459,141 +412,87 @@ The server deletes acknowledged messages from its queue. If the message arrived
459412
460413### Mesh Formation
461414
462−Federation is open. A server joins the mesh by connecting to one or more known peers. No approval is required.
415+Federation is open. A server joins the mesh by being configured (at build/start time) with its own public hostname; peers learn of each other through gossip and through messages flowing across them. There is no central registry.
416+
417+Operators may apply an inbound blocklist; outbound federation is filtered through a built-in **SSRF deny-list** that rejects:
463418
464−Server operators configure a list of initial peers. When a server starts, it announces itself to its peers, which propagate the announcement. Over time, each server builds a view of the full mesh.
419+- `localhost`, IP-literal forms (`127.0.0.0/8`, `10/8`, `192.168/16`, `169.254/16`, `0/8`)
420+- IPv6 literals (`::`, `::1`, bracketed `[…]` syntax)
421+- `.localhost`, `.local`, `.internal`, `.arpa`, `.onion` suffixes
422+- Anything containing characters outside `[a-z0-9-.:]`
465423
466−Operators may maintain an optional **blocklist** of server hostnames or IPs to refuse connections from.
424+This applies both to outbound `fetch` calls and to the `fromServer` field on inbound federation requests.
467425
468426### Gossip (Presence Announcements)
469427
470−When a user connects to or disconnects from a server, the server broadcasts a presence update to all known peers:
428+When a user authenticates or polls, the server marks them locally online and broadcasts an `online` event. When a session expires or the polled presence row times out, an `offline` event is sent.
471429
472−**Online:**
473−```json
474−{
475− "type": "online",
476− "pubkey": "<base64url(ed25519_pubkey)>",
477− "server": "this-server.example.com",
478− "timestamp": 1679000000,
479− "ttl": 3600
480−}
481−```
430+**Event format (one entry per pubkey):**
482431
483−**Offline:**
484432```json
485−{
486− "type": "offline",
487− "pubkey": "<base64url(ed25519_pubkey)>",
488− "server": "this-server.example.com",
489− "timestamp": 1679000000
490−}
433+{ "eventType": "online", "pubkey": "<hex>" }
434+{ "eventType": "offline", "pubkey": "<hex>" }
491435```
492436
493−Each server maintains a **presence table** — a mapping of public keys to a **set of servers** where they are currently connected. A single pubkey may appear at multiple servers simultaneously (e.g., multiple devices with different keypairs connecting independently, or stale gossip). Presence entries expire after `ttl` seconds if not refreshed.
437+Events carry no timestamp, no server name, and no TTL on the wire. Each receiver anchors a local TTL (90 minutes by default) at the moment of receipt; the originating server is taken from the transport-level `fromServer` field, not the event body.
494438
495−Gossip messages are forwarded to peers with a decrementing hop counter to prevent infinite propagation.
439+Gossip is **single-hop**: when a server receives a gossip batch, it updates its presence table and (for newly-online keys) forwards any locally-queued messages to the originator, but it does **not** propagate the events to its own peers. There is no hop counter.
496440
497−### Gossip Endpoint
441+A server may hold multiple presence rows for the same pubkey — one per peer that has reported the pubkey online — to handle multi-device or in-flight handoff between servers.
498442
499−```
500−POST /federation/gossip
501−```
443+### `POST /federation/gossip` — Ingest peer presence events
502444
503445**Request:**
504446```json
505447{
506448 "events": [
507− { "type": "online", "pubkey": "...", "server": "...", "timestamp": ..., "ttl": ... },
508− { "type": "offline", "pubkey": "...", "server": "...", "timestamp": ... }
449+ { "eventType": "online", "pubkey": "<hex>" },
450+ { "eventType": "offline", "pubkey": "<hex>" }
509451 ],
510− "hops_remaining": 3
452+ "fromServer": "peer.example.com"
511453}
512454```
513455
514−### Message Forwarding
456+- Up to 100 events per request.
457+- Rejected with `400 invalid_request` if `fromServer` equals this server's identity, fails the SSRF deny-list, or the event count exceeds the cap.
458+- `online` events for keys not previously seen at `fromServer` trigger an immediate forward sweep: any locally-queued messages addressed to those keys are scheduled for `/federation/forward`.
515459
516−When a server receives a message for a recipient, it checks the presence table. If the pubkey appears at multiple servers, the message is forwarded to **all** of them. This ensures delivery even with stale gossip or multi-server presence.
517−
518−```
519−POST /federation/forward
520−```
521−
522−**Request:**
523−```json
524−{
525− "to": "<base64url(recipient_ed25519_pubkey)>",
526− "from": "<base64url(sender_ed25519_pubkey)>",
527− "encrypted_blob": "<base64url(encrypted_blob)>",
528− "timestamp": 1679000000,
529− "origin_server": "sender-server.example.com",
530− "message_id": "<id>"
531−}
532−```
533−
534−The receiving server delivers the message to the connected client on its next poll.
535−
536−### ACK Forwarding
537−
538−When a recipient ACKs a message, the ACK is forwarded back to the origin server so it can delete the queued blob:
539−
540−```
541−POST /federation/ack
542−```
460+### `POST /federation/forward` — Receive a federated message
543461
544462**Request:**
545463```json
546464{
547− "message_ids": ["id1", "id2"],
548− "origin_server": "sender-server.example.com"
465+ "toKey": "<hex(recipient_pubkey)>",
466+ "encryptedBlob": "<hex>"
549467}
550468```
551469
552−---
553−
554−## 8. Group Messaging
555−
556−Groups are a **client-side concept**. Servers are entirely unaware of groups — they see only individual messages between pairs of public keys.
470+There is no `from`, no origin-server hint, and no sender timestamp — the receiving server simply records the blob for the recipient.
557471
558−### Group Structure
472+The receiver **requires the recipient to be currently locally online** (i.e. have an unexpired local presence row). If not, it returns `404 no_presence` and the origin retries (see below) or holds the message in its own mailbox.
559473
560−A group is defined locally on each member's device:
474+### Forward Retries
561475
562−```json
563−{
564− "group_id": "<random_uuid>",
565− "group_name": "Weekend Plans",
566− "members": [
567− "<base64url(pubkey_alice)>",
568− "<base64url(pubkey_bob)>",
569− "<base64url(pubkey_carol)>"
570− ]
571−}
572−```
476+Forwards that fail (network error or `no_presence`) remain queued at the origin and are retried by a background loop:
573477
574−### Sending to a Group
478+- Linear per-peer backoff: `retryBackoffBase × failures`, capped at `retryBackoffMax` (defaults: base 30 s, cap 8 min).
479+- Up to `maxForwardRetries` attempts (default 10), then the forward is dropped.
480+- Per-peer failure counters age out after 24 h, after which the next attempt is treated as fresh.
481+- A single successful call to a peer clears its failure counter (no half-open state).
575482
576−When a user sends a message to a group, the client **fans out** — it encrypts and sends the message individually to each group member. Each fan-out message includes the `group_id` in the plaintext payload so the recipient's client can display it in the correct conversation:
483+When the recipient finally polls a server holding their mail (local or federated), the implicit ack on cursor advance flushes the stored blob and prunes any remaining outbound forwards for it.
577484
578−```json
579−{
580− "type": "text",
581− "id": "msg_123",
582− "timestamp": 1679000000,
583− "group_id": "550e8400-e29b-41d4-a716-446655440000",
584− "body": "See you Saturday!"
585−}
586−```
485+### What's NOT Federated
587486
588−From the server's perspective, a group message of N members is N separate 1:1 messages.
487+- **Message ack roundtrips.** There is no `/federation/ack`; the origin server learns "the message landed" by observing that its outbound forward succeeded and that no other forwards for the same blob remain pending.
488+- **Multi-hop gossip.** Each server only sees the presence events of peers it directly federates with.
489+- **Cross-server presence queries.** Servers do not ask each other "is this key online?" — they wait for a gossip update.
589490
590−### Membership Management
491+---
591492
592−- **Invite:** Send a `group.invite` message to the new member containing the group ID, name, and member list.
593−- **Leave:** Send a `group.leave` message to all remaining members.
594−- **Update:** Send a `group.update` message to all members when the name or membership changes.
493+## 8. Group Messaging
595494
596−All membership operations are performed by the client. There is no server-side group state, no group admin role enforced by the protocol, and no consensus mechanism. Clients trust the membership updates they receive from other members.
495+Group messaging is **not implemented** in the current version. Future revisions may add it as a client-side concept (encrypting one message per group member, with a shared `group_id` in the plaintext payload). Servers would remain entirely unaware of groups.
597496
598497---
599498
@@ -601,50 +500,51 @@ All membership operations are performed by the client. There is no server-side g
601500
602501### Queuing
603502
604−When a server receives a message for a recipient who is not currently online (not in the presence table), the **sender's server queues** the encrypted blob.
503+When a server accepts a message via `/messages`, it always writes the blob to its **local mailbox** under a fresh monotonic `receivedAt`. In parallel, if the recipient is known (via gossip) to be online at one or more remote servers, a federation `forward` is queued and attempted asynchronously per server.
605504
606−The sender's server continues to listen to gossip. When a presence announcement indicates the recipient is online at some server, the sender's server forwards the queued message to that server.
505+If the recipient is not online anywhere, the message simply stays in the local mailbox until either:
607506
608−### TTL
507+- The recipient connects to this server and polls (and acks via cursor advance), or
508+- An `online` gossip event arrives naming a server where the recipient is now reachable, at which point the queued message is forwarded there.
609509
610−All messages have a **30-day TTL**, enforced in two places:
510+### TTL
611511
612−- **Server-side:** Queued messages are deleted 30 days after their timestamp. Servers are temporary relays, not long-term storage.
613−- **Client-side:** Clients MUST delete messages from local storage 30 days after their timestamp. There is no permanent message history.
512+All messages have a **30-day TTL**, enforced server-side: queued messages are deleted 30 days after their `receivedAt`. Servers are temporary relays, not long-term storage. Clients SHOULD also age out local message history.
614513
615514### Delivery Flow
616515
617516```
6185171. Alice sends a message to Bob (offline).
619−2. Alice's server queues the encrypted blob.
620− Returns: { "status": "queued" }
518+2. Alice's server appends the encrypted blob to its local mailbox.
519+ Returns: 200 {}
621520
6225213. ...time passes...
623522
624−4. Bob connects to Server C.
625−5. Server C gossips: ONLINE { bob_pubkey, server_c }
626−6. Alice's server sees the gossip, forwards the queued message to Server C.
627−7. Server C delivers to Bob on his next poll.
628−8. Bob's client sends ACK to Server C (queue cleanup).
629−9. Server C forwards ACK to Alice's server.
630−10. Alice's server deletes the blob.
631−11. Bob's client sends an encrypted `delivery.ack` message to Alice.
632−12. When Alice receives it, she marks the message as delivered.
523+4. Bob connects to Server C; Server C marks Bob locally online and gossips
524+ { eventType: "online", pubkey: bob } to its peers.
525+5. Alice's server receives the gossip and queues a /federation/forward to Server C.
526+6. Server C accepts the forward (Bob is locally online) and appends to Bob's mailbox.
527+7. Bob's client polls Server C, receives the event, advances its cursor.
528+8. Cursor advance acts as an implicit ack: Server C deletes the stored blob and
529+ the corresponding pending forward (so the chain does not double-deliver).
530+9. Bob's client sends an encrypted `delivery.ack` message back to Alice.
531+10. When Alice next polls and decrypts that, her UI marks her message delivered.
633532```
634533
635−Alice does not need to stay online. Her server handles delivery independently.
534+Note: Alice does not need to stay online for steps 4–9. Her server handles federation independently.
636535
637536### Failure Modes
638537
639538- If the sender's server goes down before delivering, queued messages are **lost**. This is an accepted tradeoff — servers are disposable.
640−- If the recipient never comes back online within 30 days, the message is deleted.
641−- The sender sees `queued` status until ACK is received, at which point it becomes `delivered`.
539+- If forwarding to a peer fails repeatedly, the forward is dropped after `maxForwardRetries`; the message remains in the origin's mailbox and will only be delivered if the recipient ever polls the origin directly.
540+- If the recipient never comes back online within 30 days, the message is deleted by the server-side TTL.
541+- The sender's UI sees no delivery status until a `delivery.ack` is received from the recipient.
642542
643543### Message Ordering
644544
645−Message ordering is **best-effort** and guaranteed only within a single server. Each server maintains a monotonic sequence counter (`receivedAt`) that provides strict ordering for locally stored messages. The `cursor` returned by `/poll` reflects this sequence.
545+Message ordering is **best-effort** and guaranteed only within a single server. Each server's `receivedAt` provides strict ordering for that server's mailbox, and the `cursor` returned by `/poll` reflects this sequence.
646546
647−Across federated servers, messages may arrive out of order due to network latency, retry delays, or differing reception times. Clients SHOULD use the sender-provided `timestamp` field for display ordering and treat `cursor` as a polling checkpoint only, not a global ordering guarantee.
547+Across federated servers, messages may arrive out of order due to network latency, retry delays, or differing reception times. Clients SHOULD use the sender-provided `ts` field (from the decrypted plaintext payload, §4) for display ordering and treat `cursor` as a polling checkpoint only, not a global ordering guarantee.
648548
649549---
650550
@@ -652,32 +552,33 @@ Across federated servers, messages may arrive out of order due to network latenc
652552
653553### Threat Model
654554
655−Skrepka protects message **content** from servers and network observers. It does not attempt to hide **metadata**.
555+Skrepka protects message **content and sender identity** from servers and network observers. It does not attempt to hide the recipient.
656556
657557### What Is Protected
658558
659−| Property | Mechanism |
660−|-----------------------|----------------------------------------------|
661−| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
662−| Sender authenticity | Ed25519 signature on every message |
663−| Replay protection | Unique nonce per message |
664−| Key compromise (partial) | Ephemeral keys per message — past messages whose blobs were already discarded cannot be decrypted even if the recipient's long-term key is compromised |
559+| Property | Mechanism |
560+|---------------------------|------------------------------------------------------------|
561+| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
562+| Sender authenticity | Ed25519 signature on every message |
563+| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
564+| Replay protection | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a different recipient |
565+| Partial post-compromise | Ephemeral X25519 keys per message — once the blob is no longer stored anywhere, even compromising the recipient's long-term key does not recover the message |
665566
666567### What Is NOT Protected
667568
668−| Risk | Details |
669−|------------------------------|-----------------------------------------------------|
670−| **No forward secrecy** | If a message blob is captured *and* the recipient's long-term X25519 key is later compromised, that specific message can be decrypted. No ratchet means no post-compromise recovery. |
671−| **Metadata exposure** | Servers see `{to, from, timestamp}` in the envelope. They know who is talking to whom and when. Message size is also visible. |
672−| **Presence gossip leaks location** | All servers in the mesh learn which server a user is connected to. |
673−| **No durable delivery** | If the sender's server goes down, queued messages are lost. |
674−| **No server authentication** | Servers are not authenticated to each other by default. A malicious server could join the mesh and observe gossip. |
675−| **Group membership visible to members** | Group member lists are shared in plaintext among members. A compromised member reveals the full group. |
569+| Risk | Details |
570+|-----------------------------------|-----------------------------------------------------|
571+| **No forward secrecy** | If a message blob is captured *and* the recipient's long-term X25519 key is later compromised, that specific message can be decrypted. No ratchet means no post-compromise recovery. |
572+| **Recipient metadata exposure** | Servers see the recipient pubkey and arrival time of every message, plus the rough size of each blob. They do **not** see the sender. |
573+| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. |
574+| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
575+| **Open federation** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers; a hostile server can join the mesh and observe gossip. |
576+| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
676577
677578### Recommendations for Implementers
678579
679580- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
680581- Warn users when a contact's public key changes (TOFU violation).
681582- Consider pinning server TLS certificates for additional transport security.
682−- Implement rate limiting on server endpoints to mitigate spam.
683−- Servers SHOULD delete message blobs immediately after successful delivery and ACK.
583+- Federate only with peers operated by trusted operators if presence metadata is sensitive.
584+- Servers SHOULD delete message blobs as soon as the implicit ack (cursor advance) lands.
README.md+12−2
@@ -4,7 +4,17 @@
44 <img src="client/Assets/Images/logo.png" width="300px" align="center">
55</p>
66
7−Decentralized, end-to-end encrypted messaging with no accounts and no home servers. See [PROTOCOL.md](PROTOCOL.md) for the full specification.
7+Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is a keypair; messages route over plain HTTP through any relay in the mesh. See [PROTOCOL.md](PROTOCOL.md) for the full specification.
8+
9+## Run the client
10+
11+The client is an F# / .NET 10 / Fabulous + Avalonia app targeting **macOS desktop** and **iOS**.
12+
13+```sh
14+cd client && ./run-mac.sh # macOS desktop (.app bundle)
15+cd client && ./run-ios.sh # iOS simulator (default: iPhone 17 Pro)
16+cd client && dotnet build # Plain build
17+```
818
919## Install the relay server
1020
@@ -12,4 +22,4 @@ Decentralized, end-to-end encrypted messaging with no accounts and no home serve
1222curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
1323```
1424
15−This installs the server binary, sets up a systemd service, and configures [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS. Re-running the same command will update the binary and restart the service.
25+This installs the server binary, sets up a systemd service, and configures [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS. Re-running the same command updates the binary and restarts the service.