1# Skrepka Protocol Specification
3**Version:** 0.2 (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 routing metadata. Message content — including the sender's identity — is opaque to the server.
16- **Open federation.** Any server can join the mesh. Operators may optionally blocklist specific peers.
17- **Ephemeral.** Server mailboxes expire after 30 days. No permanent server-side 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- Recipient metadata privacy (servers see the recipient and timestamps; the sender is not visible to servers)
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, 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).
43### Sharing an Identity
45Identities are shared out-of-band as plain text or QR codes. The client displays public keys in two forms:
47- **Hex** — 64 lowercase hex characters (the canonical wire form).
48- **@p (phonetic)** — the public key rendered as Urbit-style syllables, hyphen-joined with no leading prefix (e.g. `ridler-binzod-marbud-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
52@p is a display encoding only — it never appears on the wire and carries no information the hex form does not. It is specified here so an independent client renders the *same* syllables, since users compare them aloud to verify keys: a client with a divergent table would show a different name for the same identity and defeat out-of-band verification.
54The 32-byte key is encoded **byte-pair by byte-pair, most-significant first**. Each pair `(hi, lo)` becomes one four-letter word `PREFIX[hi] ++ SUFFIX[lo]`, and the 16 words are joined with `-`. Both tables hold 256 three-letter syllables each and are **identical to Urbit's** (`po.hoon`); the reference copies live in [`core/src/phonemic.rs`](core/src/phonemic.rs). Neither table contains duplicates, so decoding is an unambiguous reverse lookup, and a 32-byte key always yields exactly 16 words.
56Worked example — the key beginning `0x00 0x00 0x01 0x01 ...`:
58| byte pair | prefix | suffix | word |
59|-----------|--------|--------|----------|
60| `00 00` | `doz` | `zod` | `dozzod` |
61| `01 01` | `mar` | `nec` | `marnec` |
63Note the prefix at index **124** is **`nis`** (…`ran`, `nis`, `wol`, `mis`…), matching `po.hoon`; it is not `nic`, which is a common transcription error.
65Clients accept either form when adding a contact; hex remains canonical everywhere else.
67Servers 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.
71**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.
75There is no global directory. Users assign local nicknames to public keys on their own device.
83| Purpose | Algorithm |
84|------------------|-----------------------|
86| Key agreement | X25519 |
87| Key derivation | HKDF-SHA256 |
88| Symmetric cipher | XChaCha20-Poly1305 |
89| Compression | zstd (pre-encryption) |
91### Per-Message Encryption
93Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
95The 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.
991. Generate an ephemeral X25519 keypair:
101 ephemeral_private, ephemeral_public = x25519_generate()
1032. Derive the recipient's X25519 public key from their Ed25519 public key, and compute the raw shared secret:
105 recipient_x25519_public = ed25519_pk_to_curve25519(recipient_ed25519_public)
106 raw_secret = x25519(ephemeral_private, recipient_x25519_public)
1083. Derive the symmetric encryption key using HKDF-SHA256:
112 salt = ephemeral_public || recipient_x25519_public,
1174. zstd-compress the plaintext payload (see §4):
119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
1215. Sign a domain-separation tag, the recipient pubkey, and the compressed plaintext with padding. The `"skrepka-msg-v2:"` prefix is a domain-separation tag distinguishing message signatures from auth-challenge signatures (which begin with `"skrepka-auth-v1:"`, §6), so a signature produced in one context can never validate in the other. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
123 compressed_len = uint32_be(len(compressed))
124 padding = random_bytes(target_blob_size - unpadded_blob_size)
125 signature = ed25519_sign(sender_ed25519_private,
126 "skrepka-msg-v2:" || recipient_ed25519_public || compressed_len || compressed || padding)
1286. Build the inner buffer that will be encrypted:
130 inner = sender_ed25519_public || signature || compressed_len || compressed || padding
1327. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer with
133 associated data binding the envelope header and recipient identity:
135 nonce = random_bytes(24)
136 aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
137 ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
139 The AAD authenticates every cleartext field a relay can see (version,
140 ephemeral public key, nonce) and the recipient's Ed25519 public key.
141 A relay that swaps the envelope `to` field to a different recipient causes
142 AEAD decryption to fail at the wrong recipient — the blob is dropped before
143 any cursor advance, and the original recipient's message stays in the
144 mailbox. Without AAD, a relay could silently misdeliver messages by
145 swapping `to`, making the censorship undetectable to both sender and
1478. Emit the on-wire blob:
149 blob = version_byte || ephemeral_public || nonce || ciphertext
151 where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
1551. Read the leading version byte (reject the blob if it is not recognized). Split the rest: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1562. 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`.
1573. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
1584. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
1595. Verify the signature over `"skrepka-msg-v2:" || recipient_ed25519_public || inner[96..]` (i.e. the domain-separation tag followed by `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
1606. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
162### Encrypted Blob Format
164On the wire (cleartext, server-visible):
167version (1 byte, currently 0x02)
168ephemeral_x25519_pubkey (32 bytes)
170ciphertext (variable, includes 16-byte AEAD tag)
173After AEAD decryption, the inner buffer is:
176sender_ed25519_pubkey (32 bytes)
178compressed_len (4 bytes, big-endian u32)
179compressed_plaintext (variable, zstd; exactly compressed_len bytes)
180padding (variable, random)
183The 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 `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes (346 hex chars). A relay rejects any blob shorter than this with `400 invalid_message`, since it cannot possibly carry a valid envelope.
187Blob sizes are padded to fixed boundaries so the on-wire length does not reveal the exact compressed (and thus plaintext) size to relays and network observers. The padding is added *inside* the AEAD ciphertext (after the compressed data, before encryption), so the server only sees the padded size.
189The bucket boundaries are:
192256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
195A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
197> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x02 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
199> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
201> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x02) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
203### Cryptographic Versioning
205The wire format carries a leading **version byte** (currently `0x02`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x02 adds AEAD associated data (AAD): the cleartext header `version_byte || ephemeral_public || nonce` and the recipient's Ed25519 public key are authenticated alongside the ciphertext, so a relay that tampers with any envelope field — including swapping the `to` recipient — causes AEAD decryption to fail rather than silently misdelivering the blob. The HKDF `info` string is the fixed constant `"skrepka-v2"` for version 0x02. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together — the reference implementation derives the info string from the version to ensure they cannot drift (see `crypto.rs`).
213The 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.
217 "to": "<hex(recipient_ed25519_pubkey)>",
218 "encryptedBlob": "<hex(encrypted_blob)>"
222| Field | Type | Description |
223|-----------------|--------|--------------------------------------------------|
224| `to` | string | Recipient's Ed25519 public key — exactly 64 lowercase hex chars. The server validates the **format** only (length and charset); it does not verify the decoded bytes form a valid Ed25519 curve point. |
225| `encryptedBlob` | string | Hex-encoded blob from §3. Must be at least 346 hex chars (173 bytes); the server rejects shorter blobs with `400 invalid_message`. |
227Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender. The `to` field is format-validated only — the server does not check that it represents a valid Ed25519 curve point, so blobs addressed to non-existent keys are stored and forwarded normally (the intended recipient simply cannot decrypt them). A message whose `to` matches the authenticated sender's own pubkey is rejected with `400 self_send` (§6, *POST /messages*).
231After decryption (and zstd decompression), the plaintext is a UTF-8 JSON object with a `type` field:
235 "type": "<message_type>",
241- `type` and `ts` are present on every payload. `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
242- `id` is a client-generated unique ID (e.g. UUID v4) carried by `text` messages only. Clients deduplicate incoming `text` messages by `id`. `delivery.ack` and `profile` payloads do not carry an `id`.
243- Clients MUST silently drop message types they do not recognize — no error is surfaced to the sender, and the blob is treated as if it were not there. This allows newer clients to introduce new types without breaking older ones.
247The current implementation defines three plaintext message types:
249| Type | Description | Additional Fields |
250|-----------------|------------------------------------------|------------------------------------------------|
251| `text` | A text message | `body` (string) |
252| `delivery.ack` | Sender-side delivery confirmation | `ack_ids` (array of message IDs) |
253| `profile` | Share display name, bio, and/or photo | `display_name`, `bio` (strings), `photo` (base64-encoded image, optional) |
255Future message types (media attachments, read receipts, group messages) are not part of this revision.
257### Payload Size Limits
259A decrypted payload is attacker-controlled: a peer can put any value in any field. The reference client enforces the following caps and silently drops a payload that exceeds any of them (treated identically to an unknown type). An independent client SHOULD enforce the same caps, or it risks storing, rendering, and re-transmitting unbounded attacker-supplied data.
261| Field | Cap | Rationale |
262|-------------------|-------------|--------------------------------------------------------|
263| `text.body` | 64 KiB | A text body is never legitimately this long. |
264| `text.id` | 128 chars | IDs are UUIDs; 128 chars is generous. |
265| `profile.display_name` | 128 chars (Unicode) | A display name is not a bio. |
266| `profile.bio` | 1024 chars (Unicode) | Enough for a paragraph. |
267| `profile.photo` | 64 KiB (base64) | A 256px JPEG is ~10-30 KiB; 64 KiB is headroom. |
268| `delivery.ack.ack_ids` | 1024 entries | Bounds the O(n·m) scan a peer can dictate. |
270The caps on `display_name` and `bio` are measured in Unicode characters (not bytes), so a multi-byte emoji costs the same as an ASCII letter. A payload sitting exactly at the cap is accepted; the bound is inclusive.
274Users 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.
280 "display_name": "Alice",
281 "bio": "writing things",
282 "photo": "<base64(jpeg_data)>"
286Clients 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.
288Because `profile` messages carry no `id`, they are not deduplicated, and a captured `profile` blob can be replayed to the same recipient (see §10, *Same-recipient replay*). To prevent an old profile from clobbering a newer one, clients SHOULD ignore an incoming `profile` whose `ts` is older than the `ts` of the last profile already stored for that contact. **The reference client enforces this**: it records the `ts` of the most recent profile accepted per contact and drops any incoming `profile` that predates it, so a replayed stale profile cannot roll a contact's cached name, bio, or photo back. A client that skipped the check would be vulnerable to exactly that rollback.
292When a recipient successfully decrypts an incoming `text` message, the recipient's client SHOULD send a `delivery.ack` message back to the sender:
296 "type": "delivery.ack",
297 "ack_ids": ["msg_123", "msg_124"],
302This 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.
306Blocking is client-side only. A blocked sender's messages are decrypted to recover the sender identity (which lives inside the AEAD ciphertext, §3), then dropped without storage, display, or ack — the reference client does not store, display, or ack them. Decryption is necessary because the sender identity is inside the AEAD ciphertext; the decrypted content is never persisted or shown. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey. Blocking also cuts the peer off in the other direction: the reference client refuses to send new messages to a blocked contact and drops any queued outgoing messages (including delivery acks) for them when the block is applied.
312All communication uses **plain HTTP**, served over TLS in production.
314| Direction | Mechanism |
315|--------------------|----------------------|
316| Client → Server | HTTP POST |
317| Server → Client | Long polling (POST) |
318| Server → Server | HTTP POST |
320**Why HTTP + long polling:**
321- No special protocols. Works through proxies, load balancers, and CDNs.
322- Long polling provides near-real-time delivery over plain request/response cycles.
323- No persistent connections, no WebSocket upgrade, no gRPC toolchain, no raw TCP.
327Skrepka 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:
331 "https://1.skrepka.lol",
332 "https://2.skrepka.lol",
333 "https://3.skrepka.lol"
337This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list: a relay reaches the mesh by being given **seed peers** (`--seedPeers=host1,host2`), which it gossips to unconditionally until presence gossip teaches it the rest of the mesh. A relay with no seeds and no inbound gossip stays isolated — clients on it can still message each other, but not users on other relays. See §7 for the bootstrap mechanism.
341All request and response bodies use `application/json`. The following encoding rules apply to every JSON body (request and response):
343- **Encoding:** UTF-8. Implementations MUST NOT use UTF-16 or UTF-32.
344- **Key ordering:** Object keys are unordered. A sender may emit them in any order; a receiver MUST NOT depend on key ordering for correctness.
345- **Whitespace:** Inter-token whitespace (spaces, tabs, newlines) is allowed and does not affect semantics. Pretty-printed JSON is valid.
346- **Duplicate keys:** If an object contains the same key more than once, behavior is undefined. Implementations SHOULD avoid producing duplicates and MAY reject or retain only the first value for a repeated key.
348### Federation Transport
350Servers 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. Those defenses bound abuse; they do **not** authenticate the peer, and an unauthenticated caller can both redirect a victim's queued mail (via `/federation/gossip`) and inject blobs into an online recipient's mailbox (via `/federation/forward`). Both are accepted limitations of v0.1 — see §7 for the endpoints and §10 for the security analysis.
352### Message Size Limit
354The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). A blob over the cap is rejected at validation as a malformed message (`400`), not with `413`.
356Two limits apply to a `/messages` request, independently of the per-blob cap:
358- **Batch count** — at most 100 messages per request (`413 batch_too_large`).
359- **Total request body** — the runtime enforces a single HTTP body-size cap (`--http-max-body-bytes`, returning `413` with no JSON error code). Operators **must** set this above `maxBlobLen` plus the JSON envelope — the reference deployment uses `42M` — otherwise the runtime's default 16 MiB cap would reject a maximum-size blob before the per-blob `400` check is reached. Because this caps the whole body, a batch cannot smuggle 100 × 40 MiB: the total payload across all messages in one request is bounded by this cap.
363## 6. Client ↔ Server Protocol
365### Authentication (Challenge-Response)
367When a client connects, it proves ownership of its keypair:
3691. Client sends its public key:
372 { "pubkey": "<hex(ed25519_pubkey)>" }
3752. Server responds with a random challenge string (valid for 60 s):
377 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
379 The challenge is a lowercase hex string of at least 32 chars (128 bits of entropy). The reference server uses 48 hex chars (192 bits). The challenge MUST be unique per challenge request and consumed on first use (verified).
3813. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). For IPv6 literals, the host must include square brackets (e.g. `[::1]`), matching the URL host syntax — the server's `serverHost` configuration must also include brackets for IPv6. The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
385 "pubkey": "<hex(ed25519_pubkey)>",
386 "challenge": "<random_hex>",
387 "signature": "<hex(ed25519_sign(private_key, utf8(\"skrepka-auth-v1:\" + server_host + \":\" + challenge)))>",
388 "revokeOthers": false
391 The server verifies against its own configured hostname (`serverHost`, port stripped). Binding the signature to the target host prevents a malicious or relay server from forwarding a challenge it obtained from a *different* server and replaying the client's signature there to impersonate the client (§10, *Unauthenticated session relay*).
392 Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices"). Revocation is **immediate** (within the atomic block that processes the verify) and a revoked session receives `401 unauthorized` on its next request. Note that `revokeOthers` revokes **sessions only**, not outstanding challenges — a challenge issued before the `revokeOthers` call remains valid for its 60-second window. (The reference implementation has been fixed to also drop outstanding challenges for the pubkey on `revokeOthers: true`; independent implementations SHOULD do the same.)
3944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
396 { "token": "<session_token>", "expiresAt": 1679003600000 }
399The session token is included in subsequent authenticated requests as a bearer token:
401Authorization: Bearer <session_token>
404When the server trusts proxy headers (`trustForwardedFor`, on by default), sessions are bound to the client's source IP — the last `X-Forwarded-For` hop. A later request from a different IP is rejected as `unauthorized`. If proxy-header trust is disabled, or no `X-Forwarded-For` header is present, the session is not IP-bound and this check is skipped.
406> **IP binding and mobile clients.** IP binding means a session does not survive a change of source address. In the reference deployment (Caddy in front, `trustForwardedFor` on) a phone that moves between Wi-Fi and cellular — or is NAT-rebound by its carrier — comes back on a new IP, so its next `/poll` or `/messages` gets `401 unauthorized` even though the token has not expired. This is expected: a client MUST treat `401` on any authenticated request as "re-run challenge/verify", not as a fatal error, and retry the request with the fresh token. The cost is one extra round trip per network change; the benefit is that a stolen bearer token is not usable from another network. Operators who prefer sessions that roam can run with `--trustForwardedFor=False`, which leaves sessions un-bound (the check is skipped when no trusted IP is available).
408When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
410> **Channel binding.** The signed payload includes the target server's hostname under the `skrepka-auth-v1:` domain-separation tag (step 3). Because the client binds to the host it actually dialed and the server verifies against its own `serverHost`, a relay server cannot forward a challenge it obtained from a *different* server and replay the resulting signature to impersonate the client there: the client's signature commits to the relay's hostname, not the victim server's, so verification at the victim fails. This binding is a **breaking change** — client and server must agree on the signed payload. The host comparison is on the bare hostname only (port and scheme excluded), so the same identity authenticates regardless of the port a server listens on.
412A successful `/auth/verify` also installs the pubkey in the local presence table and broadcasts an `online` gossip event to known peers (§7).
416Errors return a non-2xx HTTP status with a body of:
422| HTTP Status | Error Code | Meaning |
423|-------------|-----------------------|--------------------------------------|
424| 400 | `self_send` | A message in `/messages` is addressed to the sender |
425| 400 | `invalid_message` | A message in `/messages` has a malformed `to` or oversized/invalid `encryptedBlob` |
426| 400 | `invalid_request` | Malformed federation request: `fromServer` equals this server, fails the SSRF deny-list, or the event count exceeds the cap |
427| 401 | `invalid` | `/auth/verify` failed (bad signature, or an expired/wrong-IP challenge) |
428| 401 | `unauthorized` | `/poll` or `/messages` with a missing, invalid, or expired token, or a request from a different IP |
429| 403 | `federation_disabled` | A `/federation/*` endpoint was called while federation is disabled |
430| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
431| 413 | `batch_too_large` | A `/messages` batch exceeds the 100-message cap. A bare `413` with no JSON body (the runtime's HTTP body-size cap, `--http-max-body-bytes`) is also possible when the total request body exceeds the operator-configured limit; clients MUST treat a bodyless `413` the same as `batch_too_large` for retry purposes. Operators **must** set `--http-max-body-bytes` above `maxBlobLen` plus the JSON envelope (the reference deployment uses `42M`), otherwise the runtime's default 16 MiB cap rejects a maximum-size blob before the per-blob `400` check is reached. |
432| 429 | (HTTP only) | Per-route rate limit exceeded |
433| 503 | `capacity` | Server-side resource cap reached |
435> **Timing side-channel on token comparison (known gap).** The reference server compares the presented bearer token against each stored session token with plain string equality, which short-circuits on the first differing byte and so leaks, through response timing, how long a correct prefix an attacker guessed. The 192-bit token and the per-IP rate limit on `/poll` and `/messages` bound the attack — a blind guess is infeasible, and the sample count needed to resolve a per-byte timing difference across a network is large — but they do not eliminate it. A constant-time comparison is the correct fix; the reference implementation is blocked on the absence of such a primitive in its runtime. Implementations on platforms that offer one (`crypto_verify`, `subtle`, `hmac.compare_digest`, …) SHOULD use it.
437### `POST /poll` — Long poll for new events
439The client makes a blocking POST. The server holds the connection open until new events are available or the long-poll timeout (25 s) elapses.
441**Headers:** `Authorization: Bearer <token>`
448`cursor` is the maximum `receivedAt` value the client has already seen. It MUST be a non-negative integer. `0` means "first poll" — no messages have been acked, so the server returns all pending events. Negative cursors are clamped to `0` by the server.
454 { "encryptedBlob": "<hex>" }
456 "cursor": 1679000000123
460- `cursor` is a server-side **strictly-monotonic sequence**, seeded from the wall-clock millisecond at assignment but bumped to `previous + 1` whenever messages arrive within the same millisecond. Two messages therefore never share a `receivedAt`, so advancing the cursor past one message can never silently drop another. Treat it as an opaque checkpoint, not a clock.
461- The server clamps the supplied cursor to `max(0, min(max(t, curSeq), cursor))`, where `t` is the current wall-clock millisecond and `curSeq` is the last assigned sequence — note the ceiling is `max(t, curSeq)`, not just `curSeq`, so under idle the clamp allows acking up to the current wall-clock, while under sustained bursts (where the sequence has run ahead of the clock) it still refuses to ack beyond what has actually been issued.
462- 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**.
463- 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.
464- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
465- A single response is capped at 50 events; the client should poll again immediately after receiving any response.
467### `POST /messages` — Send messages
469**Headers:** `Authorization: Bearer <token>`
471Sends are **batched** and all-or-nothing. Up to 100 messages per request:
477 { "to": "<hex(recipient_pubkey)>", "encryptedBlob": "<hex>" }
483- `200 {}` on success — every message in the batch has been enqueued for delivery (locally, via federation, or both).
484- `400 { "error": "self_send" }` if any message targets the authenticated pubkey.
485- `401 / 429 / 503` per the table above.
487The 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.
489Whether 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.
491There is **no `/messages/ack` endpoint**: poll cursor advance is the only acknowledgement signal a recipient sends to the server.
493### Endpoints Not Present
495The following endpoints exist in earlier drafts of this spec but are **not** implemented:
497- `POST /messages/ack` — replaced by implicit ack on poll cursor advance.
498- `GET /lookup/:pubkey` — presence information is internal; clients have no way to query it.
499- `POST /federation/ack` — federation cleanup is driven by the receiving peer's poll-ack, not a back-channel.
503## 7. Server ↔ Server Protocol
507Federation is open and **off by default** (`federationEnabled = False`); an operator opts in explicitly. There is no central registry.
509A server joins the mesh in two steps:
5111. **Identity.** It is configured with its own public hostname (`serverHost`). This is the name it puts in the `fromServer` field of every gossip it sends, and the name peers must be able to resolve to reach it. It must pass the deny-list below, so the default `localhost` cannot federate.
5122. **Bootstrap.** It is configured with one or more **seed peers** (`seedPeers`, a comma-separated host list). These are gossiped to unconditionally, alongside any peers already known from presence.
514Seeding is what makes the mesh formable at all. A relay otherwise learns of a peer *only* by receiving gossip from it — presence rows remember the server that announced each key — so two fresh relays that have never heard of each other both begin with an empty gossip target set and neither ever speaks first. Seeding **one** side of a pair is sufficient: the first gossip carries `fromServer`, so the seed learns of the sender and gossips back from then on. Seed peers are gossip *targets* only; they are not written into the presence table (a presence row is keyed by the pubkey it announces, and a seed is just a hostname), so a seed that never answers costs nothing but a periodic failed request under the standard per-peer backoff.
517# relay A, seeded with relay B — B needs no seed of its own to reach A
518skrepka-server --serverHost=a.example.com --federationEnabled=true --seedPeers=b.example.com
521> **Implementation note (reference server).** `serverHost`, `federationEnabled` and `seedPeers` are configuration, and the reference relay exposes them as `--name=value` settings. As of Knot `2026.6.26.1947` the runtime *accepts but silently ignores* these flags at startup — they currently take effect only when passed to `knot build` — so a relay configured purely on the command line will run with the compiled-in defaults (`localhost`, federation off, no seeds) and quietly fail to federate. Verify with the startup log, which prints the federation state and seed list it actually loaded.
523Operators may apply an inbound blocklist; outbound federation is filtered through a built-in **SSRF deny-list** that rejects:
525- `localhost`, and the `.localhost`, `.local`, `.internal`, `.arpa`, `.onion` suffixes
526- Private and special-use IPv4 ranges: `0/8`, `10/8`, `100.64/10` (CGNAT), `127.0.0.0/8`, `169.254/16` (link-local), `172.16/12`, `192.0.2/24` and `203.0.113/24` (TEST-NET), `192.168/16`, `198.18/15` (benchmarking), and everything from `224/4` upward (multicast + reserved)
527- Obfuscated IPv4 literals: bare integer/"dword" form (e.g. `2130706433`), `0x`-prefixed hex, and dotted forms with a leading-zero (octal) octet (e.g. `0177.0.0.1`)
528- IPv6 literals — any host containing more than one `:` (covering `::`, `::1`, link-local, ULA, and global addresses) and any bracketed `[…]` form
529- The empty string, and any host beginning with `.` or `:`
530- Anything containing characters outside `[a-z0-9-.:]`
532This applies both to outbound `fetch` calls and to the `fromServer` field on inbound federation requests.
534### Gossip (Presence Announcements)
536When 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.
538**Event format (one entry per pubkey):**
541{ "eventType": "online", "pubkey": "<hex>" }
542{ "eventType": "offline", "pubkey": "<hex>" }
545Events 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.
547Gossip 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.
549A 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.
551### `POST /federation/gossip` — Ingest peer presence events
557 { "eventType": "online", "pubkey": "<hex>" },
558 { "eventType": "offline", "pubkey": "<hex>" }
560 "fromServer": "peer.example.com"
564- Up to 100 events per request.
565- Rejected with `400 invalid_request` if `fromServer` equals this server's identity, fails the SSRF deny-list, or the event count exceeds the cap.
566- `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`.
568> **Unauthenticated — and this endpoint steers delivery.** `/federation/gossip` accepts any caller, and the `fromServer` field is self-asserted. Because an `online` event causes the receiving server to forward that pubkey's queued mail to `fromServer`, anyone who can reach a relay can *redirect* a victim's inbound ciphertext to a host of their choosing by announcing `{ "eventType": "online", "pubkey": <victim>, "fromServer": <attacker-host> }`. See §10, *Open federation — gossip redirect*.
570### `POST /federation/forward` — Receive a federated message
575 "toKey": "<hex(recipient_pubkey)>",
576 "encryptedBlob": "<hex>"
580There is no `from`, no origin-server hint, and no sender timestamp — the receiving server simply records the blob for the recipient.
582The 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.
584> **Unauthenticated — mailbox injection is possible.** `/federation/forward` accepts any caller: there is no bearer token, no peer signature, and nothing ties the request to a server the receiver actually federates with. Any host that can reach a relay can therefore inject arbitrary blobs into the mailbox of any recipient who is currently locally online. The only bounds are the per-source-IP rate limit on the route (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients). Injected junk is undecryptable, so a client drops it silently on ingest — but it still occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. This is an accepted limitation of v0.1; see §10, *Open federation — forward injection*. Peer authentication is the intended fix.
588Forwards that fail (network error or `no_presence`) remain queued at the origin and are retried by a background loop:
590- Linear per-peer backoff: `retryBackoffBase × failures`, capped at `retryBackoffMax` (defaults: base 30 s, cap 8 min).
591- Up to `maxForwardRetries` attempts (default 10), then the forward is dropped.
592- Per-peer failure counters age out after 24 h, after which the next attempt is treated as fresh.
593- A single successful call to a peer clears its failure counter (no half-open state).
595When 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.
597### What's NOT Federated
599- **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.
600- **Multi-hop gossip.** Each server only sees the presence events of peers it directly federates with.
601- **Cross-server presence queries.** Servers do not ask each other "is this key online?" — they wait for a gossip update.
607Group 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.
611## 9. Offline Delivery
615When 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.
617If the recipient is not online anywhere, the message simply stays in the local mailbox until either:
619- The recipient connects to this server and polls (and acks via cursor advance), or
620- An `online` gossip event arrives naming a server where the recipient is now reachable, at which point the queued message is forwarded there.
624All 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.
6291. Alice sends a message to Bob (offline).
6302. Alice's server appends the encrypted blob to its local mailbox.
6354. Bob connects to Server C; Server C marks Bob locally online and gossips
636 { eventType: "online", pubkey: bob } to its peers.
6375. Alice's server receives the gossip and queues a /federation/forward to Server C.
6386. Server C accepts the forward (Bob is locally online) and appends to Bob's mailbox.
6397. Bob's client polls Server C, receives the event, advances its cursor.
6408. Cursor advance acts as an implicit ack: Server C deletes the stored blob and
641 the corresponding pending forward (so the chain does not double-deliver).
6429. Bob's client sends an encrypted `delivery.ack` message back to Alice.
64310. When Alice next polls and decrypts that, her UI marks her message delivered.
646Note: Alice does not need to stay online for steps 4–9. Her server handles federation independently.
650- If the sender's server goes down before delivering, queued messages are **lost**. This is an accepted tradeoff — servers are disposable.
651- 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.
652- If the recipient never comes back online within 30 days, the message is deleted by the server-side TTL.
653- The sender's UI sees no delivery status until a `delivery.ack` is received from the recipient.
657Message ordering is **best-effort** and guaranteed only within a single server. Each server assigns every message a strictly-monotonic `receivedAt` sequence (unique per message, never colliding even under bursts within one millisecond), which provides total ordering for that server's mailbox; the `cursor` returned by `/poll` reflects this sequence.
659Across 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.
663## 10. Security Considerations
667Skrepka protects message **content and sender identity** from servers and network observers. It does not attempt to hide the recipient.
671| Property | Mechanism |
672|---------------------------|------------------------------------------------------------|
673| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
674| Sender authenticity | Ed25519 signature on every message |
675| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
676| Replay protection (cross-recipient) | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a *different* recipient. This does **not** stop re-delivery of the same blob to the *same* recipient — see *Same-recipient replay* below |
677| Deletion-dependent recovery (**not** post-compromise security) | Each message uses a fresh ephemeral X25519 key, so there is no shared long-term session secret to steal. But this buys **nothing cryptographic** against a later key compromise: the recipient's long-term key still opens *any* blob an attacker has kept a copy of. The only thing protecting an old message is that the ciphertext no longer exists — relays delete on ack/TTL, and clients delete on request. That is a *deletion* property, not a *forward secrecy* property, and it fails completely against anyone who recorded the traffic. See *No forward secrecy* below, which is the same fact stated as a risk. |
679### What Is NOT Protected
682|-----------------------------------|-----------------------------------------------------|
683| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. **Additionally**, because the HKDF salt (`ephemeral_public ‖ recipient_x25519_public`) omits the sender's identity, compromising the recipient's private key also enables a *key-compromise impersonation (KCI)* vector: the attacker can decrypt a captured blob, replace the sender identity and signature inside the inner buffer with their own, re-encrypt with the same derived key, and re-deliver. The recipient sees a valid message apparently from a different sender. Including the sender pubkey in the HKDF salt or info would close this by construction, but requires a wire-format change. The signature already prevents forgery *without* key compromise, so KCI is an additional consequence of an already-catastrophic key leak, not a standalone attack. |
684| **No AAD on AEAD — envelope fields unauthenticated** (v0.1, fixed in v0.2) | In v0.1, XChaCha20-Poly1305 was called with no associated data. The envelope's `to` field was not authenticated by the AEAD — a relay could swap `to` to a different recipient's pubkey, silently misdelivering and censoring messages with zero detectability. **v0.2 fixes this**: the AAD includes `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public`, binding the envelope to the ciphertext. A `to`-swap now causes AEAD failure at the wrong recipient, and the original recipient's message stays in the mailbox. |
685| **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. **The relay can also infer delivery** by correlating a message's arrival time with the recipient's subsequent poll time — the gap between the two reveals whether the recipient was online and actively reading. |
686| **Blob size reveals message type** | The length-padding buckets (§3, *Length Padding*) are coarse enough that a relay can distinguish payload types: a `delivery.ack` with a single ID (~233 bytes) falls in the 256-byte bucket, a short `text` message falls in the 512-byte bucket, and a `profile` with a photo falls in the 65536-byte bucket. A relay observing the bucket pattern learns who acks whom and who updates their profile — without decrypting anything. Finer-grained padding (e.g., a single minimum bucket for all small messages) would reduce this leak. |
687| **Poll cursor reveals reading progress** | The client sends its cursor (last-seen `receivedAt`) on every poll, telling the relay exactly how far behind the recipient is. A relay can determine whether the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same), estimate reading speed, and detect device switches (cursor jumps backward). This is inherent to the long-poll model. |
688| **CRIME/BREACH surface on profiles** | The CRIME/BREACH side channel (§3, *Length Padding*) extends beyond text messages to `profile` broadcasts: `display_name`, `bio`, and `photo` are compressed together in one zstd frame. If an attacker can influence part of the profile text (e.g., the user sets their display name based on something the attacker controls), the compressed size could leak information about the user's other profile fields. The risk is low in practice — the base64-encoded photo is high-entropy and nearly incompressible, and bucket padding coarsens the granularity — but the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong for profile broadcasts. |
689| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. A presence row is anchored for up to 90 min (`onlineGossipTtl`), which outlives the 1 h session, so location metadata — and forwards aimed at the user — can linger for up to ~90 min after the user disconnects. |
690| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
691| **Mailbox eviction under flood (DoS)** | The per-recipient mailbox cap (`maxMessagesPerRecipient`, default 10 000) evicts the oldest messages when the cap is reached. An authenticated attacker (pubkeys are free to mint) can flood a victim's mailbox with junk, pushing out genuine undelivered messages. The victim's client only sees junk; the genuine messages are permanently destroyed. At 60 requests/min × 100 messages = 6 000/min, the attacker fills the cap in ~2 minutes (per IP; multiple IPs are faster). This is an accepted design tradeoff of v0.1 — a real fix requires per-sender quotas or pricing. |
692| **Unbounded mailbox storage** | The per-recipient mailbox cap (`maxMessagesPerRecipient`) bounds each recipient but not the count of distinct recipient keys. An attacker can fill the server's disk by sending messages to many distinct invalid pubkeys, bounded only by the 30-day TTL and rate limits. The reference server now enforces a global `maxMessagesTotal` cap (default 1 000 000) that evicts the globally-oldest rows on overflow, symmetric with the other state refs. |
693| **CPU amplification via dedup scan** | `appendMessage` performs an O(n) full-scan dedup check on every message (§6). Under a burst of 100-message batches, each message triggers a scan of the entire mailbox. With large mailboxes this is quadratic and amplifies CPU cost per request. Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated, but the amplification is real. |
694| **Clock forward-jump pins the sequence counter** | The `receivedAt` sequence counter is a forward-only ratchet: `max(wall_clock, previous + 1)`. A single NTP forward-jump (server migration, misconfiguration) pins the counter at the jumped value, delaying all TTL pruning until real time catches up. Backward jumps are safe (the counter dominates). |
695| **Challenge pool floodable (soft DoS)** | The challenge pool is globally capped at `maxChallenges` (default 10 000) with per-IP rate limiting. An attacker with many IPs — or behind a proxy with XFF spoofing when `trustForwardedFor = true` — can fill the pool with challenges for random pubkeys, causing legitimate challenges to be LRU-evicted. Authentication still works but with extra latency (clients retry). This is a soft DoS. |
696| **Rate limit counts requests, not messages** | The per-IP send rate limit (60/min) counts requests, and each request can carry up to 100 messages. The effective message rate is therefore 60 × 100 = 6 000 messages/min per IP (bounded by the body-size cap), much higher than the per-request limit suggests. An attacker with multiple IPs can send millions of messages per minute. |
697| **Session pool Sybil-floodable** | The global session pool (`maxSessions`, default 10 000) uses LRU eviction. An attacker with enough IPs can generate thousands of keypairs, authenticate each one, and fill the pool — evicting legitimate users' sessions. Users re-authenticate automatically (they get `401` and retry), so this is a soft DoS adding latency and load. Per-IP session caps were considered and rejected because they collapse behind carrier-grade NAT. |
698| **Presence table fillable with fake keys** | The presence table (`maxPresence`, default 100 000) can be filled with fake keys via gossip (100 events per request, 60/min per IP). Local rows are exempt from the cap, so local users' presence is never evicted, but legitimate *remote* presence (from honest peers) can be evicted, causing forwards to those peers to fail. Requires federation to be enabled. |
699| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers up to 100 mailbox scans and forward-staging operations. The reference server caps the number of newly-online keys that trigger forward sweeps per batch (`maxNewlyOnlineForwards`, default 10), limiting the amplification to 10× per request. An attacker can still send 100 online events per request, but only 10 trigger forward sweeps; the remaining keys are written to `*presence` without a forward sweep. Bounded by the per-IP rate limit (60/min). |
700| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address — the server has **no mechanism** to verify that the connecting peer is who it claims to be. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer, which requires runtime DNS support not currently available. |
701| **No identity revocation** | There is no in-protocol mechanism to revoke or invalidate a compromised identity key. A stolen key allows full takeover: decrypt all captured messages, send messages as the user, poll their mailbox, and receive their messages via presence redirect. The only defense is out-of-band communication. See §11 (Future Extensions — Key Rotation) for the intended direction. |
702| **Unauthenticated session relay** (mitigated) | The auth signature is bound to the target server's hostname under a domain-separation tag (§6), so a malicious/relay server can no longer forward another server's challenge and replay the resulting signature to impersonate the client elsewhere. Residual caveat: the binding is to the hostname the client dialed, so it assumes the client reaches each server under its true `serverHost` (DNS/TLS integrity). |
703| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id` (the reference client maintains a persistent `seen_ids` set that survives `trim_history`), `delivery.ack` is idempotent, and `profile` is guarded by the per-contact `ts` staleness check of §4. |
704| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. **Active users are also affected**: even when the recipient is locally online and actively polling, a forged remote presence row causes each new incoming message to be duplicated to the attacker's server — the victim receives everything normally but the attacker silently collects ciphertext metadata for the live inbound stream. See *Open federation* below. |
705| **Open federation — forward injection** | `/federation/forward` is unauthenticated; any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
706| **Bearer token theft = full impersonation** | Sessions are bearer-token-based: anyone who obtains the token can use it. The token is bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is for authorization (self-send rejection), not for authenticating the request. If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can poll the victim's mailbox and send messages as the victim — modulo IP binding. Token expiry (1 hour) is the only automatic recovery. Consider shorter token lifetimes or per-request signing. |
707| **Token entropy depends on runtime CSPRNG** | Session and challenge tokens are generated by the server's runtime random source. Implementations SHOULD verify that their RNG is cryptographically secure (e.g., backed by `/dev/urandom` or an OS-provided CSPRNG). |
708| **Non-curve-point `to` keys accepted** | The server validates that `to` is 64 lowercase hex chars but does not check that the decoded bytes form a valid Ed25519 curve point. Blobs addressed to non-existent keys are stored and forwarded normally, consuming storage until TTL expiry. |
709| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v2"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. Version 0x02 added AEAD associated data (AAD) to bind envelope fields to the ciphertext. No further rotation has been performed. |
710| **TOFU only — no verification UI** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band (e.g., @p syllables in person) to detect MITM at first contact. The reference client does not yet provide a UI for this out-of-band verification. Independent clients SHOULD implement such a UI. |
712### Open Federation (accepted limitation of v0.1)
714Every `/federation/*` endpoint accepts **any caller**. There is no bearer token, no peer signature, and no allow-list: a relay cannot tell a genuine peer from an arbitrary host on the internet. Two concrete attacks follow, and it is worth stating them precisely, because "a hostile server can observe gossip" understates both.
716**1. Gossip redirect — actively rerouting a victim's mail.** The `fromServer` field on `/federation/gossip` is self-asserted, and an `online` event for a previously-unseen key makes the receiving relay sweep its mailbox and forward that key's queued messages to `fromServer` (§7). So an attacker does not have to wait and watch. It posts:
719{ "events": [ { "eventType": "online", "pubkey": "<victim>" } ],
720 "fromServer": "attacker.example.com" }
723to every relay it can reach. Each relay holding mail for the victim now forwards that ciphertext to the attacker's host. The receiver-side "recipient must be locally online" gate does not help — that check runs on the *attacker's* server, which the attacker controls. Message **content** stays protected by E2E encryption, but the attacker harvests ciphertext copies and recipient/timing/size metadata — a real leak for a metadata-privacy-focused messenger.
725**2. Forward injection — spam into an online recipient's mailbox.** `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into the mailbox of any recipient who is currently locally online. Injected junk is undecryptable, so a client drops it silently on ingest — but it occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. The only bounds are the per-source-IP rate limit (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients).
727**3. Forward cancellation — deleting queued mail-in-flight.** The offline-event forward cancellation (§7) **widens** the unauthenticated-gossip blast radius from writing presence to **deleting queued forwards** — an attacker claiming `fromServer: X` can cancel pending forwards to any real relay X by announcing an `offline` event for the relevant key, so mail queued for delivery to X is dropped from the origin's outbox (the message itself stays in `*messages` and is re-forwarded if the key reappears on another peer, so this delays rather than destroys delivery).
729**Mitigation path.** Both attacks are accepted limitations of v0.1's unauthenticated federation. The intended fix is peer authentication — signed presence gossip and authenticated forward requests — or an explicit peer allow-list gate before `forwardQueuedToServer`. Until then, operators who federate only with trusted peers (or not at all) are unaffected.
731### Recommendations for Implementers
733- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
734- Warn users when a contact's public key changes (TOFU violation).
735- Consider pinning server TLS certificates for additional transport security.
736- Federate only with peers operated by trusted operators if presence metadata is sensitive.
737- Servers SHOULD delete message blobs as soon as the implicit ack (cursor advance) lands.
741## 11. Future Extensions
743Nothing in this section is implemented or normative in v0.1. It records the shape the extension is expected to take, so that clients avoid design choices that would foreclose it.
747Identity in Skrepka *is* the Ed25519 keypair (§2), so today a user who loses or wants to retire a key has no in-protocol way to say so: they have to re-share a new key out-of-band, and every contact must re-add them by hand. Worse, because contacts are TOFU (§2, *Trust Model*), a contact who simply receives a message from an unfamiliar key cannot distinguish "my peer rotated" from "someone is impersonating my peer".
749A future revision may add a `key_rotation` message type (§4) that lets a user signal the change in-band:
753 "type": "key_rotation",
754 "new_pubkey": "<hex(new_ed25519_pubkey)>",
759The message is a normal plaintext payload: encrypted to each contact and, per §4, signed by the **old** key — which is exactly the point. Only the holder of the retiring key can announce its successor, so the announcement carries the same authority as any other message from that identity, and a recipient who already trusts the old key can extend that trust to the new one without a fresh out-of-band fingerprint check. Recipients would update the contact entry in place: replace the stored pubkey with `new_pubkey`, keep the existing display name, avatar, and message history, and retain the old key only as a *former identity* so that already-received messages still verify.
761Open questions a full specification must settle:
763- **Proof of possession of the new key.** A signature by the old key alone proves the *announcement* is authentic, not that the announcer holds the new secret. A cross-signature (the payload additionally signed by the new key, over both pubkeys) closes that gap and should be considered mandatory.
764- **Ordering and replay.** Rotations must be totally ordered per identity, or a replayed old `key_rotation` could roll a contact back to a superseded key. The per-contact `ts` staleness rule of §4 is the obvious mechanism, and it inherits that rule's weakness (§10, *Same-recipient replay*) — a monotonic rotation counter is likely the safer construction.
765- **Contacts who miss the announcement.** A contact offline past the 30-day TTL (§9) never receives the rotation and is left holding a dead key. Re-announcing on reconnect, or letting a client ask a peer to re-send its current identity, would need to be specified.
766- **Revocation vs. rotation.** These are different operations. Rotation says "use this key from now on" and assumes the old key is still under the owner's control; revocation says "the old key is compromised — distrust it", and an attacker holding the compromised key can forge exactly the rotation message that would hijack the identity. A rotation announcement therefore MUST NOT be treated as a compromise recovery mechanism. Recovering from a stolen key needs a separate design (an offline recovery key, or out-of-band re-verification) and is out of scope here.
767- **No retroactive protection.** Rotating does not protect past traffic. Messages already sent to the old key remain decryptable by whoever holds it, exactly as §10, *No forward secrecy* describes.
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