1# Skrepka Protocol Specification
3**Version:** 0.1 (Draft)
9Skrepka is a decentralized, end-to-end encrypted messaging protocol. It has no user accounts, no home servers, and no central authority.
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.
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)
33On 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.
35The user's **public key is their identity**. There are no usernames, no global namespace, and no registration step.
37Each device generates its own independent keypair and is treated as a separate identity.
41Public keys are encoded as **base64url** (RFC 4648 §5, no padding).
46skrepka://<base64url(ed25519_pubkey)>@<server_host>
52skrepka://dGhpcyBpcyBhIHRlc3Q@relay.example.com
55The URI contains everything needed to reach someone:
57- **Public key** — who they are
58- **Server host** — a server where they were last seen or any known relay
60Since 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.
62Identity URIs are designed to be shared out-of-band: copied as text, embedded in links, or scanned as QR codes.
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.
70There is no global directory. Users assign local nicknames to public keys on their own device.
78| Purpose | Algorithm |
79|------------------|-----------------------|
81| Key agreement | X25519 |
82| Key derivation | HKDF-SHA256 |
83| Symmetric cipher | XChaCha20-Poly1305 |
85### Per-Message Encryption
87Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
911. Generate an ephemeral X25519 keypair:
93 ephemeral_private, ephemeral_public = x25519_generate()
952. Compute the raw shared secret using the ephemeral private key and the recipient's long-term X25519 public key:
97 raw_secret = x25519(ephemeral_private, recipient_x25519_public)
993. Derive the encryption key using HKDF-SHA256:
103 salt = ephemeral_public || recipient_x25519_public,
1084. Generate a random 24-byte nonce:
110 nonce = random_bytes(24)
1125. Encrypt the plaintext message:
114 ciphertext = xchacha20_poly1305_encrypt(key, nonce, plaintext)
1166. Sign the ciphertext with the sender's Ed25519 private key:
118 signature = ed25519_sign(sender_ed25519_private, ciphertext)
1231. Verify the signature against the sender's Ed25519 public key.
1242. Compute the raw shared secret using the recipient's long-term X25519 private key and the ephemeral public key from the message.
1253. Derive the encryption key using HKDF-SHA256 with the same parameters.
1264. Decrypt the ciphertext using the derived key and nonce.
128### Encrypted Blob Format
131ephemeral_x25519_pubkey (32 bytes)
134sender_ed25519_pubkey (32 bytes)
138Total overhead: 152 bytes per message.
146The envelope is what servers see. It is transmitted in cleartext (not encrypted) so servers can route messages.
150 "to": "<base64url(recipient_ed25519_pubkey)>",
151 "from": "<base64url(sender_ed25519_pubkey)>",
152 "encrypted_blob": "<base64url(encrypted_blob)>",
153 "timestamp": 1679000000
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 |
164Servers route based on `to` and `from`. They never inspect `encrypted_blob`.
168After decryption, the plaintext is a JSON object with a `type` field:
172 "type": "<message_type>",
173 "id": "<unique_message_id>",
174 "timestamp": 1679000000,
179The `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).
181Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
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) |
196Media files are embedded inline in the encrypted payload as base64-encoded `data`. There is no separate media hosting or upload endpoint.
200Users 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.
206 "timestamp": 1679000000,
207 "display_name": "Alice",
208 "photo": "<base64(jpeg_data)>"
212Clients 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.
216Blocking 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.
222All communication uses **plain HTTP**.
224| Direction | Mechanism |
225|--------------------|----------------------|
226| Client → Server | HTTP POST |
227| Server → Client | Long polling |
228| Server → Server | HTTP POST |
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.
237New clients discover servers via a well-known URL:
240https://skrepka.org/servers.json
243The response is a JSON array of server hostnames:
248 { "host": "relay1.example.com", "region": "eu" },
249 { "host": "relay2.example.com", "region": "us" },
250 { "host": "relay3.example.com", "region": "ap" }
255The 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.
259All request and response bodies use `application/json`.
261### Message Size Limit
263The 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.
267## 6. Client ↔ Server Protocol
269### Authentication (Challenge-Response)
271When a client connects, it proves ownership of its keypair:
2731. Client sends its public key:
276 { "pubkey": "<base64url(ed25519_pubkey)>" }
2792. Server responds with a random challenge (valid for 60 seconds):
281 { "challenge": "<base64url(random_32_bytes)>" }
2843. Client signs the challenge and sends it back:
288 "pubkey": "<base64url(ed25519_pubkey)>",
289 "challenge": "<base64url(challenge)>",
290 "signature": "<base64url(ed25519_sign(private_key, challenge))>"
2944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
296 { "token": "<session_token>", "expires_at": 1679003600 }
299The session token is included in all subsequent requests as a bearer token:
301Authorization: Bearer <session_token>
304When 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).
308#### `POST /auth/challenge` — Request auth challenge
312{ "pubkey": "<base64url(ed25519_pubkey)>" }
317{ "challenge": "<base64url(random_32_bytes)>" }
320#### `POST /auth/verify` — Complete auth
325 "pubkey": "<base64url(ed25519_pubkey)>",
326 "challenge": "<base64url(challenge)>",
327 "signature": "<base64url(signature)>"
333{ "token": "<session_token>", "expires_at": 1679003600 }
338All endpoints return errors in a consistent format:
342 "error": "<error_code>",
343 "message": "<human-readable description>"
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 |
357#### `GET /poll` — Long poll for new events
359The client makes a blocking GET request. The server holds the connection open until new events are available or a timeout is reached.
361**Headers:** `Authorization: Bearer <token>`
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) |
376 "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
381 "data": { "message_ids": ["msg_abc123"] },
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.
397|------------|-------------------------------------|
398| `message` | Envelope JSON (see §4) |
399| `ack` | `{ "message_ids": ["id1", "id2"] }`|
401#### `POST /messages` — Send a message
403**Headers:** `Authorization: Bearer <token>`
408 "to": "<base64url(recipient_ed25519_pubkey)>",
409 "encrypted_blob": "<base64url(encrypted_blob)>",
410 "timestamp": 1679000000
414The server fills in `from` from the authenticated session.
419 "status": "delivered" | "queued",
420 "message_id": "<server_assigned_id>"
424- `delivered` — recipient is online, message was forwarded.
425- `queued` — recipient is offline, message is queued for later delivery.
427#### `POST /messages/ack` — Acknowledge receipt
429**Headers:** `Authorization: Bearer <token>`
433{ "message_ids": ["id1", "id2"] }
441The server deletes acknowledged messages from its queue and forwards the ACK to the sender's server.
443#### `GET /lookup/:pubkey` — Look up a user's presence
445**Response (online):**
449 "server": "other-server.example.com"
453**Response (offline):**
462## 7. Server ↔ Server Protocol
466Federation is open. A server joins the mesh by connecting to one or more known peers. No approval is required.
468Server 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.
470Operators may maintain an optional **blocklist** of server hostnames or IPs to refuse connections from.
472### Gossip (Presence Announcements)
474When a user connects to or disconnects from a server, the server broadcasts a presence update to all known peers:
480 "pubkey": "<base64url(ed25519_pubkey)>",
481 "server": "this-server.example.com",
482 "timestamp": 1679000000,
491 "pubkey": "<base64url(ed25519_pubkey)>",
492 "server": "this-server.example.com",
493 "timestamp": 1679000000
497Each 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.
499Gossip messages are forwarded to peers with a decrementing hop counter to prevent infinite propagation.
504POST /federation/gossip
511 { "type": "online", "pubkey": "...", "server": "...", "timestamp": ..., "ttl": ... },
512 { "type": "offline", "pubkey": "...", "server": "...", "timestamp": ... }
518### Message Forwarding
520When 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.
523POST /federation/forward
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",
538The receiving server delivers the message to the connected client on its next poll.
542When a recipient ACKs a message, the ACK is forwarded back to the origin server so it can delete the queued blob:
551 "message_ids": ["id1", "id2"],
552 "origin_server": "sender-server.example.com"
560Groups are a **client-side concept**. Servers are entirely unaware of groups — they see only individual messages between pairs of public keys.
564A group is defined locally on each member's device:
568 "group_id": "<random_uuid>",
569 "group_name": "Weekend Plans",
571 "<base64url(pubkey_alice)>",
572 "<base64url(pubkey_bob)>",
573 "<base64url(pubkey_carol)>"
578### Sending to a Group
580When 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:
586 "timestamp": 1679000000,
587 "group_id": "550e8400-e29b-41d4-a716-446655440000",
588 "body": "See you Saturday!"
592From the server's perspective, a group message of N members is N separate 1:1 messages.
594### Membership Management
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.
600All 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.
604## 9. Offline Delivery
608When 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.
610The 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.
614All messages have a **30-day TTL**, enforced in two places:
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.
6221. Alice sends a message to Bob (offline).
6232. Alice's server queues the encrypted blob.
624 Returns: { "status": "queued" }
6284. Bob connects to Server C.
6295. Server C gossips: ONLINE { bob_pubkey, server_c }
6306. Alice's server sees the gossip, forwards the queued message to Server C.
6317. Server C delivers to Bob on his next poll.
6328. Bob's client sends ACK to Server C.
6339. Server C forwards ACK to Alice's server.
63410. Alice's server deletes the blob.
63511. Next time Alice connects, she receives delivery confirmation.
638Alice does not need to stay online. Her server handles delivery independently.
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`.
648## 10. Security Considerations
652Skrepka protects message **content** from servers and network observers. It does not attempt to hide **metadata**.
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 |
663### What Is NOT Protected
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. |
674### Recommendations for Implementers
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.
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list