@@ -0,0 +1,680 @@
1+# Skrepka Protocol Specification
2+
3+**Version:** 0.1 (Draft)
4+
5+---
6+
7+## 1. Overview
8+
9+Skrepka is a decentralized, end-to-end encrypted messaging protocol. It has no user accounts, no home servers, and no central authority.
10+
11+### Design Goals
12+
13+- **No accounts.** A user's identity is their cryptographic keypair. No registration, no passwords, no servers storing credentials.
14+- **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.
16+- **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.
18+- **Simplicity.** Plain HTTP transport, no session negotiation, no ratchets.
19+
20+### Non-Goals
21+
22+- Forward secrecy (per-message ephemeral keys provide partial protection, but no ratchet)
23+- Metadata privacy (servers see sender, recipient, and timestamp)
24+- Durable message storage (servers queue temporarily, not permanently)
25+- Peer-to-peer delivery (at least one server must be reachable)
26+
27+---
28+
29+## 2. Identity
30+
31+### Key Generation
32+
33+On first launch, a client generates an **Ed25519 keypair** (the identity key). The corresponding **X25519 public key** for key agreement is derived from the Ed25519 key using the birational map between the two curves (RFC 7748 / RFC 8032). This means a single keypair serves both signing and encryption — only the Ed25519 key needs to be stored and shared.
34+
35+The user's **public key is their identity**. There are no usernames, no global namespace, and no registration step.
36+
37+Each device generates its own independent keypair and is treated as a separate identity.
38+
39+### Key Format
40+
41+Public keys are encoded as **base64url** (RFC 4648 §5, no padding).
42+
43+### Identity URI
44+
45+```
46+skrepka://<base64url(ed25519_pubkey)>@<server_host>
47+```
48+
49+Example:
50+
51+```
52+skrepka://dGhpcyBpcyBhIHRlc3Q@relay.example.com
53+```
54+
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.
63+
64+### Trust Model
65+
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.
67+
68+### Contacts
69+
70+There is no global directory. Users assign local nicknames to public keys on their own device.
71+
72+---
73+
74+## 3. Cryptography
75+
76+### Algorithms
77+
78+| Purpose | Algorithm |
79+|------------------|-----------------------|
80+| Signing | Ed25519 |
81+| Key agreement | X25519 |
82+| Key derivation | HKDF-SHA256 |
83+| Symmetric cipher | XChaCha20-Poly1305 |
84+
85+### Per-Message Encryption
86+
87+Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
88+
89+**Encryption flow:**
90+
91+1. Generate an ephemeral X25519 keypair:
92+ ```
93+ ephemeral_private, ephemeral_public = x25519_generate()
94+ ```
95+2. Compute the raw shared secret using the ephemeral private key and the recipient's long-term X25519 public key:
96+ ```
97+ raw_secret = x25519(ephemeral_private, recipient_x25519_public)
98+ ```
99+3. Derive the encryption key using HKDF-SHA256:
100+ ```
101+ key = hkdf_sha256(
102+ ikm = raw_secret,
103+ salt = ephemeral_public || recipient_x25519_public,
104+ info = "skrepka-v1",
105+ len = 32
106+ )
107+ ```
108+4. Generate a random 24-byte nonce:
109+ ```
110+ nonce = random_bytes(24)
111+ ```
112+5. Encrypt the plaintext message:
113+ ```
114+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, plaintext)
115+ ```
116+6. Sign the ciphertext with the sender's Ed25519 private key:
117+ ```
118+ signature = ed25519_sign(sender_ed25519_private, ciphertext)
119+ ```
120+
121+**Decryption flow:**
122+
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.
127+
128+### Encrypted Blob Format
129+
130+```
131+ephemeral_x25519_pubkey (32 bytes)
132+nonce (24 bytes)
133+ciphertext (variable)
134+sender_ed25519_pubkey (32 bytes)
135+signature (64 bytes)
136+```
137+
138+Total overhead: 152 bytes per message.
139+
140+---
141+
142+## 4. Message Format
143+
144+### Envelope
145+
146+The envelope is what servers see. It is transmitted in cleartext (not encrypted) so servers can route messages.
147+
148+```json
149+{
150+ "to": "<base64url(recipient_ed25519_pubkey)>",
151+ "from": "<base64url(sender_ed25519_pubkey)>",
152+ "encrypted_blob": "<base64url(encrypted_blob)>",
153+ "timestamp": 1679000000
154+}
155+```
156+
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 |
163+
164+Servers route based on `to` and `from`. They never inspect `encrypted_blob`.
165+
166+### Plaintext Payload
167+
168+After decryption, the plaintext is a JSON object with a `type` field:
169+
170+```json
171+{
172+ "type": "<message_type>",
173+ "id": "<unique_message_id>",
174+ "timestamp": 1679000000,
175+ ...
176+}
177+```
178+
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.
182+
183+### Message Types
184+
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) |
195+
196+Media files are embedded inline in the encrypted payload as base64-encoded `data`. There is no separate media hosting or upload endpoint.
197+
198+### Profiles
199+
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.
201+
202+```json
203+{
204+ "type": "profile",
205+ "id": "msg_456",
206+ "timestamp": 1679000000,
207+ "display_name": "Alice",
208+ "photo": "<base64(jpeg_data)>"
209+}
210+```
211+
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.
213+
214+### Blocking
215+
216+Blocking is client-side only. A blocked sender's messages are still received and decrypted but are not displayed to the user. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey.
217+
218+---
219+
220+## 5. Transport
221+
222+All communication uses **plain HTTP**.
223+
224+| Direction | Mechanism |
225+|--------------------|----------------------|
226+| Client → Server | HTTP POST |
227+| Server → Client | Long polling |
228+| Server → Server | HTTP POST |
229+
230+**Why HTTP + long polling:**
231+- No special protocols. Works through proxies, load balancers, and CDNs.
232+- Long polling provides near-real-time delivery over plain HTTP request/response cycles.
233+- No persistent connections, no WebSocket upgrade, no gRPC toolchain, no raw TCP.
234+
235+### Server Discovery
236+
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:
244+
245+```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+}
253+```
254+
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.
256+
257+### Content Type
258+
259+All request and response bodies use `application/json`.
260+
261+### Message Size Limit
262+
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.
264+
265+---
266+
267+## 6. Client ↔ Server Protocol
268+
269+### Authentication (Challenge-Response)
270+
271+When a client connects, it proves ownership of its keypair:
272+
273+1. Client sends its public key:
274+ ```
275+ POST /auth/challenge
276+ { "pubkey": "<base64url(ed25519_pubkey)>" }
277+ ```
278+
279+2. Server responds with a random challenge (valid for 60 seconds):
280+ ```json
281+ { "challenge": "<base64url(random_32_bytes)>" }
282+ ```
283+
284+3. Client signs the challenge and sends it back:
285+ ```
286+ POST /auth/verify
287+ {
288+ "pubkey": "<base64url(ed25519_pubkey)>",
289+ "challenge": "<base64url(challenge)>",
290+ "signature": "<base64url(ed25519_sign(private_key, challenge))>"
291+ }
292+ ```
293+
294+4. Server verifies the signature. On success, returns a session token valid for **1 hour**:
295+ ```json
296+ { "token": "<session_token>", "expires_at": 1679003600 }
297+ ```
298+
299+The session token is included in all subsequent requests as a bearer token:
300+```
301+Authorization: Bearer <session_token>
302+```
303+
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
321+
322+**Request:**
323+```json
324+{
325+ "pubkey": "<base64url(ed25519_pubkey)>",
326+ "challenge": "<base64url(challenge)>",
327+ "signature": "<base64url(signature)>"
328+}
329+```
330+
331+**Response:**
332+```json
333+{ "token": "<session_token>", "expires_at": 1679003600 }
334+```
335+
336+### Error Responses
337+
338+All endpoints return errors in a consistent format:
339+
340+```json
341+{
342+ "error": "<error_code>",
343+ "message": "<human-readable description>"
344+}
345+```
346+
347+| HTTP Status | Error Code | Meaning |
348+|-------------|----------------------|--------------------------------------|
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 |
356+
357+#### `GET /poll` — Long poll for new events
358+
359+The client makes a blocking GET request. The server holds the connection open until new events are available or a timeout is reached.
360+
361+**Headers:** `Authorization: Bearer <token>`
362+
363+**Query parameters:**
364+
365+| Parameter | Type | Description |
366+|--------------|--------|--------------------------------------------------|
367+| `timeout` | number | Max seconds to wait (default: 30, max: 60) |
368+| `after` | string | Cursor from the previous poll response (optional) |
369+
370+**Response:**
371+```json
372+{
373+ "events": [
374+ {
375+ "type": "message",
376+ "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
377+ "id": "evt_001"
378+ },
379+ {
380+ "type": "ack",
381+ "data": { "message_ids": ["msg_abc123"] },
382+ "id": "evt_002"
383+ }
384+ ],
385+ "cursor": "evt_002"
386+}
387+```
388+
389+- If events are available immediately, the server responds right away.
390+- If no events are available, the server holds the connection until one arrives or `timeout` expires.
391+- On timeout with no events, the server returns `{ "events": [], "cursor": "..." }`.
392+- The client should immediately issue the next poll after receiving a response.
393+
394+**Event types:**
395+
396+| Type | Data |
397+|------------|-------------------------------------|
398+| `message` | Envelope JSON (see §4) |
399+| `ack` | `{ "message_ids": ["id1", "id2"] }`|
400+
401+#### `POST /messages` — Send a message
402+
403+**Headers:** `Authorization: Bearer <token>`
404+
405+**Request:**
406+```json
407+{
408+ "to": "<base64url(recipient_ed25519_pubkey)>",
409+ "encrypted_blob": "<base64url(encrypted_blob)>",
410+ "timestamp": 1679000000
411+}
412+```
413+
414+The server fills in `from` from the authenticated session.
415+
416+**Response:**
417+```json
418+{
419+ "status": "delivered" | "queued",
420+ "message_id": "<server_assigned_id>"
421+}
422+```
423+
424+- `delivered` — recipient is online, message was forwarded.
425+- `queued` — recipient is offline, message is queued for later delivery.
426+
427+#### `POST /messages/ack` — Acknowledge receipt
428+
429+**Headers:** `Authorization: Bearer <token>`
430+
431+**Request:**
432+```json
433+{ "message_ids": ["id1", "id2"] }
434+```
435+
436+**Response:**
437+```json
438+{ "ok": true }
439+```
440+
441+The server deletes acknowledged messages from its queue and forwards the ACK to the sender's server.
442+
443+#### `GET /lookup/:pubkey` — Look up a user's presence
444+
445+**Response (online):**
446+```json
447+{
448+ "online": true,
449+ "server": "other-server.example.com"
450+}
451+```
452+
453+**Response (offline):**
454+```json
455+{
456+ "online": false
457+}
458+```
459+
460+---
461+
462+## 7. Server ↔ Server Protocol
463+
464+### Mesh Formation
465+
466+Federation is open. A server joins the mesh by connecting to one or more known peers. No approval is required.
467+
468+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.
469+
470+Operators may maintain an optional **blocklist** of server hostnames or IPs to refuse connections from.
471+
472+### Gossip (Presence Announcements)
473+
474+When a user connects to or disconnects from a server, the server broadcasts a presence update to all known peers:
475+
476+**Online:**
477+```json
478+{
479+ "type": "online",
480+ "pubkey": "<base64url(ed25519_pubkey)>",
481+ "server": "this-server.example.com",
482+ "timestamp": 1679000000,
483+ "ttl": 3600
484+}
485+```
486+
487+**Offline:**
488+```json
489+{
490+ "type": "offline",
491+ "pubkey": "<base64url(ed25519_pubkey)>",
492+ "server": "this-server.example.com",
493+ "timestamp": 1679000000
494+}
495+```
496+
497+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.
498+
499+Gossip messages are forwarded to peers with a decrementing hop counter to prevent infinite propagation.
500+
501+### Gossip Endpoint
502+
503+```
504+POST /federation/gossip
505+```
506+
507+**Request:**
508+```json
509+{
510+ "events": [
511+ { "type": "online", "pubkey": "...", "server": "...", "timestamp": ..., "ttl": ... },
512+ { "type": "offline", "pubkey": "...", "server": "...", "timestamp": ... }
513+ ],
514+ "hops_remaining": 3
515+}
516+```
517+
518+### Message Forwarding
519+
520+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.
521+
522+```
523+POST /federation/forward
524+```
525+
526+**Request:**
527+```json
528+{
529+ "to": "<base64url(recipient_ed25519_pubkey)>",
530+ "from": "<base64url(sender_ed25519_pubkey)>",
531+ "encrypted_blob": "<base64url(encrypted_blob)>",
532+ "timestamp": 1679000000,
533+ "origin_server": "sender-server.example.com",
534+ "message_id": "<id>"
535+}
536+```
537+
538+The receiving server delivers the message to the connected client on its next poll.
539+
540+### ACK Forwarding
541+
542+When a recipient ACKs a message, the ACK is forwarded back to the origin server so it can delete the queued blob:
543+
544+```
545+POST /federation/ack
546+```
547+
548+**Request:**
549+```json
550+{
551+ "message_ids": ["id1", "id2"],
552+ "origin_server": "sender-server.example.com"
553+}
554+```
555+
556+---
557+
558+## 8. Group Messaging
559+
560+Groups are a **client-side concept**. Servers are entirely unaware of groups — they see only individual messages between pairs of public keys.
561+
562+### Group Structure
563+
564+A group is defined locally on each member's device:
565+
566+```json
567+{
568+ "group_id": "<random_uuid>",
569+ "group_name": "Weekend Plans",
570+ "members": [
571+ "<base64url(pubkey_alice)>",
572+ "<base64url(pubkey_bob)>",
573+ "<base64url(pubkey_carol)>"
574+ ]
575+}
576+```
577+
578+### Sending to a Group
579+
580+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:
581+
582+```json
583+{
584+ "type": "text",
585+ "id": "msg_123",
586+ "timestamp": 1679000000,
587+ "group_id": "550e8400-e29b-41d4-a716-446655440000",
588+ "body": "See you Saturday!"
589+}
590+```
591+
592+From the server's perspective, a group message of N members is N separate 1:1 messages.
593+
594+### Membership Management
595+
596+- **Invite:** Send a `group.invite` message to the new member containing the group ID, name, and member list.
597+- **Leave:** Send a `group.leave` message to all remaining members.
598+- **Update:** Send a `group.update` message to all members when the name or membership changes.
599+
600+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.
601+
602+---
603+
604+## 9. Offline Delivery
605+
606+### Queuing
607+
608+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.
609+
610+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.
611+
612+### TTL
613+
614+All messages have a **30-day TTL**, enforced in two places:
615+
616+- **Server-side:** Queued messages are deleted 30 days after their timestamp. Servers are temporary relays, not long-term storage.
617+- **Client-side:** Clients MUST delete messages from local storage 30 days after their timestamp. There is no permanent message history.
618+
619+### Delivery Flow
620+
621+```
622+1. Alice sends a message to Bob (offline).
623+2. Alice's server queues the encrypted blob.
624+ Returns: { "status": "queued" }
625+
626+3. ...time passes...
627+
628+4. Bob connects to Server C.
629+5. Server C gossips: ONLINE { bob_pubkey, server_c }
630+6. Alice's server sees the gossip, forwards the queued message to Server C.
631+7. Server C delivers to Bob on his next poll.
632+8. Bob's client sends ACK to Server C.
633+9. Server C forwards ACK to Alice's server.
634+10. Alice's server deletes the blob.
635+11. Next time Alice connects, she receives delivery confirmation.
636+```
637+
638+Alice does not need to stay online. Her server handles delivery independently.
639+
640+### Failure Modes
641+
642+- If the sender's server goes down before delivering, queued messages are **lost**. This is an accepted tradeoff — servers are disposable.
643+- If the recipient never comes back online within 30 days, the message is deleted.
644+- The sender sees `queued` status until ACK is received, at which point it becomes `delivered`.
645+
646+---
647+
648+## 10. Security Considerations
649+
650+### Threat Model
651+
652+Skrepka protects message **content** from servers and network observers. It does not attempt to hide **metadata**.
653+
654+### What Is Protected
655+
656+| Property | Mechanism |
657+|-----------------------|----------------------------------------------|
658+| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
659+| Sender authenticity | Ed25519 signature on every message |
660+| Replay protection | Unique nonce per message |
661+| 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 |
662+
663+### What Is NOT Protected
664+
665+| Risk | Details |
666+|------------------------------|-----------------------------------------------------|
667+| **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. |
668+| **Metadata exposure** | Servers see `{to, from, timestamp}` in the envelope. They know who is talking to whom and when. Message size is also visible. |
669+| **Presence gossip leaks location** | All servers in the mesh learn which server a user is connected to. |
670+| **No durable delivery** | If the sender's server goes down, queued messages are lost. |
671+| **No server authentication** | Servers are not authenticated to each other by default. A malicious server could join the mesh and observe gossip. |
672+| **Group membership visible to members** | Group member lists are shared in plaintext among members. A compromised member reveals the full group. |
673+
674+### Recommendations for Implementers
675+
676+- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
677+- Warn users when a contact's public key changes (TOFU violation).
678+- Consider pinning server TLS certificates for additional transport security.
679+- Implement rate limiting on server endpoints to mitigate spam.
680+- Servers SHOULD delete message blobs immediately after successful delivery and ACK.