on master help sign in
Fix all 16 audit findings: outbox stall, deploy auth, panics, security, docs
affb421iko
.github/workflows/build.yml+28−2
@@ -6,6 +6,24 @@ on:
6 pull_request:
6 pull_request:
7
7
8jobs:
8jobs:
9+ core:
10+ name: Core (test + clippy)
11+ runs-on: ubuntu-latest
12+ steps:
13+ - uses: actions/checkout@v4
14+
15+ - uses: dtolnay/rust-toolchain@stable
16+ with:
17+ components: clippy
18+
19+ - uses: Swatinem/rust-cache@v2
20+
21+ - name: Test
22+ run: cargo test -p skrepka_core
23+
24+ - name: Clippy
25+ run: cargo clippy -p skrepka_core --all-targets -- -D warnings
26+
9 build:
27 build:
10 strategy:
28 strategy:
11 fail-fast: false
29 fail-fast: false
@@ -59,7 +77,7 @@ jobs:
59 path: server
77 path: server
60
78
61 release:
79 release:
62− needs: build
80+ needs: [core, build]
63 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
81 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
64 runs-on: ubuntu-latest
82 runs-on: ubuntu-latest
65 permissions:
83 permissions:
@@ -71,9 +89,17 @@ jobs:
71 with:
89 with:
72 path: repo
90 path: repo
73
91
92+ # Commit timestamp to the second, plus the short SHA. Minute resolution
93+ # alone collides when two commits land in the same minute (a rebase-push,
94+ # or a merge right after a direct push), and a duplicate tag makes the
95+ # release step fail — or worse, silently overwrite the previous release's
96+ # assets. The SHA makes the tag unique even at identical timestamps.
74 - name: Generate version
97 - name: Generate version
75 id: version
98 id: version
99+ run: |
76− run: echo "tag=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M')" >> "$GITHUB_OUTPUT"
100+ ts=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M%S')
101+ sha=$(git -C repo rev-parse --short=7 HEAD)
102+ echo "tag=${ts}-${sha}" >> "$GITHUB_OUTPUT"
77
103
78 - name: Prepare release assets
104 - name: Prepare release assets
79 run: |
105 run: |
.gitignore−3
@@ -4,9 +4,6 @@ server
4*.db-journal
4*.db-journal
5*.db-wal
5*.db-wal
6*.db-shm
6*.db-shm
7−.fake
8−client/bin
9−client/obj
10.DS_Store
7.DS_Store
11.claude/
8.claude/
12
9
Cargo.lock−1
@@ -1797,7 +1797,6 @@ name = "skrepka_core"
1797version = "0.1.0"
1797version = "0.1.0"
1798dependencies = [
1798dependencies = [
1799 "anyhow",
1799 "anyhow",
1800− "base64",
1801 "boltffi",
1800 "boltffi",
1802 "chacha20poly1305",
1801 "chacha20poly1305",
1803 "clap",
1802 "clap",
Cargo.toml+1−2
@@ -6,7 +6,7 @@ resolver = "2"
6edition = "2021"
6edition = "2021"
7rust-version = "1.90"
7rust-version = "1.90"
8license = "MIT"
8license = "MIT"
9−repository = "https://github.com/iko/skrepka"
9+repository = "https://github.com/ilyakooo0/skrepka"
10
10
11[workspace.dependencies]
11[workspace.dependencies]
12crux_core = "0.19"
12crux_core = "0.19"
@@ -30,7 +30,6 @@ rand_core = { version = "0.6", features = ["getrandom"] }
30rand_chacha = "0.3"
30rand_chacha = "0.3"
31uuid = { version = "1", features = ["v4"] }
31uuid = { version = "1", features = ["v4"] }
32hex = "0.4"
32hex = "0.4"
33−base64 = "0.22"
34
33
35# codegen bin
34# codegen bin
36anyhow = "1.0"
35anyhow = "1.0"
PROTOCOL-REVIEW.md+72−52
@@ -1,9 +1,14 @@
1# PROTOCOL.md Review — Holes vs. Implementation
1# PROTOCOL.md Review — Holes vs. Implementation
2
2
3−**Date:** 2026-06-20
3+**Date:** 2026-06-20 (revised 2026-07-11 against the Rust core)
4**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
4**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
5−(`server.knot`, `client/`). Each item records whether the implementation already
5+(`server.knot`, `core/` — the Rust/Crux shared core). Each item records whether
6−handles it, partially handles it, or leaves it open.
6+the implementation already handles it, partially handles it, or leaves it open.
7+
8+> The original review was written against the F# client, which has since been
9+> replaced by the Rust core in `core/` (logic) plus a logic-free SwiftUI shell in
10+> `apple/`. Citations below point at the Rust code; findings that the rewrite
11+> closed are marked as such.
7
12
8Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
13Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
9
14
@@ -26,14 +31,18 @@ the relay server was the client-of-record to A throughout.
26
31
27**Fix (applied):** the client now signs
32**Fix (applied):** the client now signs
28`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
33`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
29−bare lowercased hostname it dialed (`client/ApiClient.fs`, `Crypto.fs`
34+bare lowercased hostname it dialed (`Identity::sign_challenge`,
30−`signChallenge`), and the server verifies against
35+`core/src/crypto.rs`; the tag is `AUTH_TAG`), and the server verifies against
31`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
36`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
32configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
37configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
33prefix is a domain-separation tag so an auth signature can never be confused with
38prefix is a domain-separation tag so an auth signature can never be confused with
34−a message signature (`recipientPub || compressed`, `Crypto.fs`). Breaking change:
39+a message signature (which is over `recipientPub || compressed`). Breaking change:
35client and server must be upgraded together.
40client and server must be upgraded together.
36
41
42+Operational corollary: a relay's `serverHost` **must** be the public hostname
43+clients dial, or every signature fails to verify. It defaults to `"localhost"`,
44+so `install.sh` passes `--serverHost=${DOMAIN}` in the systemd unit it writes.
45+
37---
46---
38
47
39## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
48## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
@@ -41,9 +50,8 @@ client and server must be upgraded together.
41**Severity: high — open (consequence of open federation).**
50**Severity: high — open (consequence of open federation).**
42
51
43`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
52`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
44−forwards the recipient's queued messages **to the announcing `fromServer`**.
53+forwards the recipient's queued messages **to the announcing `fromServer`**
45−
54+(`handleRecvGossip` → `forwardQueuedToServer pk fromServer`, `server.knot`).
46−- `server.knot:1090` — `fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))`.
47
55
48**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
56**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
49fromServer:<attacker>}`. The origin forwards all of the victim's queued
57fromServer:<attacker>}`. The origin forwards all of the victim's queued
@@ -51,8 +59,10 @@ ciphertext to the attacker, who harvests ciphertext copies + recipient/timing/si
51metadata. The receiver-side "recipient must be locally online" gate is checked by
59metadata. The receiver-side "recipient must be locally online" gate is checked by
52the attacker's own server, so it is not a real gate.
60the attacker's own server, so it is not a real gate.
53
61
54−**Existing partial defense:** `isBadServerName` SSRF deny-list (`server.knot:505`)
62+**Existing partial defense:** the `isBadServerName` SSRF deny-list blocks
55−blocks internal targets; rate-limited 60/window/IP. Neither authenticates the peer.
63+internal targets, and gossip is rate-limited per peer IP. Neither authenticates
64+the peer, and the deny-list is purely lexical — a public-looking hostname under
65+attacker DNS control still passes (documented at `isBadServerName`).
56
66
57`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
67`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
58can actively **redirect delivery**. At minimum the spec should say so; a real fix
68can actively **redirect delivery**. At minimum the spec should say so; a real fix
@@ -60,26 +70,26 @@ needs signed/authenticated presence or peer allow-listing.
60
70
61---
71---
62
72
63−## 🟡 3. Replay protection is narrower than §10 claims
73+## ✅ 3. Replay protection is narrower than §10 claims — closed in the Rust core
64
74
65−**Severity: medium — partially fixed.**
75+**Severity: medium — fixed.**
66
76
67−§10 claims "Replay protection"; the only mechanism is the recipient-bound
77+§10 claimed "Replay protection"; the underlying mechanism is the
68−signature, which stops replay to a *different* recipient, not re-delivery of the
78+recipient-bound signature, which stops replay to a *different* recipient, not
69−same blob to the same recipient. Per-type status:
79+re-delivery of the same blob to the same recipient. Per-type status, all in
80+`ingest_poll` (`core/src/app.rs`):
70
81
71−- `text` — ✅ protected: deduped by `id` (`client/App.fs:38`).
82+- `text` — ✅ deduplicated by `id` before it is appended to a conversation.
72−- `delivery.ack` — ✅ harmless: `markDelivered` only flips `Sent → Delivered`,
83+- `delivery.ack` — ✅ harmless: it only flips `Sent → Delivered`, idempotent.
73− idempotent (`client/App.fs:43`).
84+- `profile` — ✅ **fixed** (was open in the F# client): each `Contact` carries
74−- `profile` — ❌ **open**: `withProfile` overwrites name/bio/photo
85+ `last_profile_ts` (`core/src/model.rs`), and an incoming `profile` whose `ts`
75− unconditionally (`client/App.fs:66`). The payload `ts` is parsed
86+ predates it is dropped instead of applied, so a replayed *older* profile can no
76− (`client/Protocol.fs:53`) but **not** propagated to `ProfileMessage` or
87+ longer roll a contact's name/bio/photo back.
77− compared, so a replayed *older* profile blob silently rolls a contact's
78− profile back.
79
88
80−**Fix:** carry `ts` into `Envelope.ProfileMessage` and ignore a profile whose
89+`PROTOCOL.md` §4 and §10 have been updated to say the reference client enforces
81−`ts` is older than the last one stored for that contact. Tighten §10 wording to
90+the staleness check. §10's replay wording should still be read as "replay against
82−"replay against a *different* recipient."
91+a *different* recipient" — re-delivery to the same recipient is prevented by the
92+per-type guards above, not by the crypto.
83
93
84---
94---
85
95
@@ -90,11 +100,12 @@ same blob to the same recipient. Per-type status:
90Any host can POST a blob for any locally-online `toKey`; the endpoint is
100Any host can POST a blob for any locally-online `toKey`; the endpoint is
91unauthenticated.
101unauthenticated.
92
102
93−- Mitigations present: per-IP rate limit 600/window (`server.knot:140/314`),
103+- Mitigations present: per-IP rate limit (`forwardRateLimit`, `server.knot`),
94− SSRF filter on origins, `no_presence` gate.
104+ the SSRF filter on origins, and the `no_presence` gate.
95- Residual: junk blobs still reach an online recipient's poll stream; the spec
105- Residual: junk blobs still reach an online recipient's poll stream; the spec
96− never says what a client does with an undecryptable blob (it is dropped at
106+ never says what a client does with an undecryptable blob. The core drops it
107+ silently — `crypto::decrypt` returning `Err` just `continue`s the ingest loop
97− `Crypto.decrypt`, `client/Protocol.fs:88`). Worth documenting as an accepted,
108+ (`ingest_poll`, `core/src/app.rs`). Worth documenting as an accepted,
98 rate-limited spam surface.
109 rate-limited spam surface.
99
110
100---
111---
@@ -102,7 +113,7 @@ unauthenticated.
102## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
113## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
103
114
104**Not a bug.** `appendMessage` assigns `seqTs = max t (currentSeq seqRows + 1)`
115**Not a bug.** `appendMessage` assigns `seqTs = max t (currentSeq seqRows + 1)`
105−(`server.knot:653`), which is **strictly monotonic**, so no two messages share a
116+(`server.knot`), which is **strictly monotonic**, so no two messages share a
106`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
117`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
107message. The spec just describes the same value as both a "millisecond timestamp"
118message. The spec just describes the same value as both a "millisecond timestamp"
108and "monotonic," which reads as ambiguous. Recommend: describe it as "a
119and "monotonic," which reads as ambiguous. Recommend: describe it as "a
@@ -113,14 +124,14 @@ checkpoint, not a clock."
113
124
114## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
125## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
115
126
116−The per-blob cap is `maxBlobLen = 41943040` (40 MiB hex) enforced by the
127+The per-blob cap is `maxBlobLen` (40 MiB hex) enforced by the `BlobHex` type, and
117−`BlobHex` type (`server.knot:53/198`), and `maxBatchSize = 100`
118−(`server.knot:56`). The naive reading "100 × 40 MiB = 4 GiB request" is
128+a batch is capped at `maxBatchSize = 100` (`server.knot`). The naive reading
119−**prevented** by a runtime body cap set in deployment:
129+"100 × 40 MiB = 4 GiB request" is **prevented** by a runtime body cap set in
130+deployment:
120
131
121- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
132- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
122 request body (chosen just above `maxBlobLen` + envelope; the runtime default is
133 request body (chosen just above `maxBlobLen` + envelope; the runtime default is
123− 16 MiB).
134+ 16 MiB, which would reject a legitimate max-size blob with a bare 413).
124
135
125So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
136So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
126`PROTOCOL.md` §5 should state this total-body cap (and that operators must keep
137`PROTOCOL.md` §5 should state this total-body cap (and that operators must keep
@@ -132,41 +143,50 @@ So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
132
143
133**Severity: low — open.**
144**Severity: low — open.**
134
145
135−`info = "skrepka-v1"` is hardcoded (`client/Crypto.fs:178` / `Constants`), and
146+`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and neither the
136−neither the envelope nor the plaintext payload carries a version field. "Ignore
147+envelope nor the plaintext payload carries a version field. "Ignore unknown
137−unknown `type`" gives payload-type agility but no path to rotate the
148+`type`" (`protocol::parse_payload`) gives payload-type agility but no path to
138−AEAD/KDF/curve. Recommend a stated version field or an explicit "no crypto
149+rotate the AEAD/KDF/curve. Recommend a stated version field or an explicit "no
139−agility in v0.1" note.
150+crypto agility in v0.1" note.
140
151
141---
152---
142
153
143## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
154## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
144
155
145−`trustForwardedFor = True` by default (`server.knot:148`); the session/rate-limit
156+`trustForwardedFor = True` by default (`server.knot`); the session/rate-limit IP
146−IP is the last XFF hop (`pickClientIp`, `server.knot:302`), and `matchIp` treats
157+is the last XFF hop (`pickClientIp`), and `matchIp` treats an empty bound IP as a
147−an empty bound IP as a wildcard (`server.knot:546`). This is correct **behind a
158+wildcard. This is correct **behind a trusted proxy** (the `install.sh` topology,
148−trusted proxy** but spoofable when the server is directly exposed. The flag and
159+Caddy in front) but spoofable when the server is directly exposed — a client could
160+forge the header to mint fresh rate-limit buckets. The flag and its
149−its `--trustForwardedFor=False` override exist; the spec presents IP-binding as a
161+`--trustForwardedFor=False` override exist; the spec presents IP-binding as a
150protection without the proxy caveat. Recommend documenting it.
162protection without the proxy caveat. Recommend documenting it.
151
163
152---
164---
153
165
154## Minor / noted
166## Minor / noted
155
167
168+- The SSRF deny-list (`isBadServerName`) is **lexical only**: it filters the
169+ hostname string, never the address it resolves to, so an attacker-controlled
170+ name that resolves to a private IP passes. Closing it requires resolve-then-pin,
171+ which the runtime's `fetch` does not expose. Documented in place.
156- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
172- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
157 sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
173 sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
158- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
174- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
159 metadata and stale forwards can persist ~90 min after disconnect.
175 metadata and stale forwards can persist ~90 min after disconnect.
160- `/auth/challenge` is unauthenticated; fillable by anyone but capped
176- `/auth/challenge` is unauthenticated; fillable by anyone but capped
161− (`maxChallenges = 10000`, `maxChallengesPerKey = 5`).
177+ (`maxChallenges = 10000`, `maxChallengesPerKey = 5`) and rate-limited per
178+ client IP. It is deliberately **not** limited per submitted pubkey: keypairs are
179+ free, so a pubkey-keyed bucket refills on every rotation (Sybil), and it would
180+ also let an attacker exhaust a victim's login budget.
162
181
163---
182---
164
183
165## Suggested follow-ups
184## Suggested follow-ups
166
185
167−1. **Spec edits (safe, no behavior change):** fold #2, #3, #6, #8 caveats into
186+1. **Spec edits (safe, no behavior change):** fold #2, #4, #6, #8 caveats into
168− `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security).
187+ `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security). #3's spec text
188+ is already updated.
169−2. **Code fixes worth doing:** #1 (bind challenge to server) is **done**;
189+2. **Code fixes:** #1 (bind challenge to server) and #3 (profile `ts` staleness
170− #3-profile (`ts` staleness guard) remains a genuine, fixable defect.
190+ guard) are **done**.
1713. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
1913. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
172 document-as-accepted or add peer authentication.
192 document-as-accepted or add peer authentication.
PROTOCOL.md+2−2
@@ -219,7 +219,7 @@ Users share a display name, bio, and photo with contacts by sending a `profile`
219
219
220Clients 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.
220Clients 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.
221
221
222−Because `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 does not yet enforce this** — it applies every `profile` it decrypts unconditionally — so a replayed stale profile currently rolls a contact's cached name, bio, or photo back.
222+Because `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.
223
223
224### Delivery Acks
224### Delivery Acks
225
225
@@ -590,7 +590,7 @@ Skrepka protects message **content and sender identity** from servers and networ
590| **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. |
590| **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. |
591| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
591| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
592| **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 — the signature commits to the relay's own hostname. 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); it does not defend against an attacker who fully controls name resolution and the server's certificate. |
592| **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 — the signature commits to the relay's own hostname. 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); it does not defend against an attacker who fully controls name resolution and the server's certificate. |
593−| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. `text` is deduplicated by `id` and `delivery.ack` is idempotent, but `profile` carries no `id` and (in the reference client) is applied unconditionally, so a replayed stale `profile` rolls a contact's cached profile back (§4). |
593+| **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`, `delivery.ack` is idempotent, and `profile` — which carries no `id` — is guarded by the per-contact `ts` staleness check of §4, which the reference client enforces, so a replayed stale `profile` is dropped rather than rolling a contact's cached profile back. A client that omits that check is open to profile rollback. |
594| **Open federation — gossip redirect** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers. Beyond passively observing gossip, a hostile server can *actively redirect delivery*: by announcing `{eventType:"online", pubkey:<victim>, fromServer:<attacker>}` it causes origin servers to forward the victim's queued ciphertext to the attacker, which harvests ciphertext copies plus recipient/timing/size metadata. |
594| **Open federation — gossip redirect** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers. Beyond passively observing gossip, a hostile server can *actively redirect delivery*: by announcing `{eventType:"online", pubkey:<victim>, fromServer:<attacker>}` it causes origin servers to forward the victim's queued ciphertext to the attacker, which harvests ciphertext copies plus recipient/timing/size metadata. |
595| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox (rate-limited per source IP, and undecryptable blobs are dropped client-side, but they still consume the recipient's poll stream). |
595| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox (rate-limited per source IP, and undecryptable blobs are dropped client-side, but they still consume the recipient's poll stream). |
596| **No cryptographic agility** | The wire format has no version field and a fixed HKDF `info` (§3); the AEAD/KDF/curve cannot be migrated in-band. |
596| **No cryptographic agility** | The wire format has no version field and a fixed HKDF `info` (§3); the AEAD/KDF/curve cannot be migrated in-band. |
README.md+37−11
@@ -1,25 +1,51 @@
1# Skrepka
1# Skrepka
2
2
3−<p align="center">
3+Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is an Ed25519 keypair; messages route over plain HTTP through any relay in an open federated mesh. A relay only ever sees the recipient's public key and an opaque blob — the sender's identity and signature live *inside* the AEAD ciphertext, so a server never learns who sent a message.
4− <img src="client/Assets/Images/logo.png" width="300px" align="center">
5−</p>
6
4
7−Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is a keypair; messages route over plain HTTP through any relay in the mesh. See [PROTOCOL.md](PROTOCOL.md) for the full specification.
5+See [PROTOCOL.md](PROTOCOL.md) for the full specification.
8
6
9−## Run the client
7+## What's here
10
8
11−The client is an F# / .NET 10 / Fabulous + Avalonia app targeting **macOS desktop** and **iOS**.
9+| Path | What it is |
10+| --- | --- |
11+| `core/` | The shared core (Rust, [Crux](https://redbadger.github.io/crux/)). All business logic: identity, crypto, auth, the long-poll loop, the outbox, contacts, profiles, message ingest. |
12+| `apple/` | The iOS client (Swift + SwiftUI, iOS 17+). A thin shell: it executes HTTP/key-value/timer effects and renders the view model. No business logic. |
13+| `server.knot` | The relay server, written in [Knot](https://github.com/ilyakooo0/knot) and compiled to a native binary. |
14+| `servers.json` | A public relay list. A static suggestion, not a directory. |
15+
16+Crypto is X25519 + XChaCha20-Poly1305 with HKDF-SHA256, signed with Ed25519 — see `core/src/crypto.rs`, which is written to be auditable against the spec.
17+
18+## Build the client
19+
20+One-time setup:
12
21
13```sh
22```sh
14−cd client && ./run-mac.sh # macOS desktop (.app bundle)
23+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
15−cd client && ./run-ios.sh # iOS simulator (default: iPhone 17 Pro)
24+cargo install boltffi_cli
16−cd client && dotnet build # Plain build
25+brew install xcodegen
17```
26```
18
27
19−## Install the relay server
28+Then (`just` alone runs `test`):
29+
30+```sh
31+just test # run the core test suite
32+just generate # regenerate Swift types, xcframework, and the Xcode project
33+just build SIM_ID=<udid> # build for a booted simulator
34+just run SIM_ID=<udid> # install + launch on that simulator
35+```
36+
37+Run `just generate` after any change to the core's `Event`, `ViewModel`, or `Effect` types — otherwise the Swift side compiles against stale generated types.
38+
39+## Run a relay
40+
41+To install one on a Linux host, with a systemd service and [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS:
20
42
21```sh
43```sh
22curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
44curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
23```
45```
24
46
25−This installs the server binary, sets up a systemd service, and configures [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS. Re-running the same command updates the binary and restarts the service.
47+Re-running the same command updates the binary and restarts the service.
48+
49+Every top-level constant in `server.knot` is overridable at startup as `--name=value` (`--serverHost=`, `--federationEnabled=False`, `--trustForwardedFor=False`, …); run the binary with `--help` for the full list. `--serverHost` must be the public hostname clients use to reach the relay: it is the federation identity, and clients bind their auth signature to it.
50+
51+To run a relay locally you need a prebuilt `./server` binary (`knot build server.knot`, or grab a release asset); then `just server` resets the disposable database and starts it.
apple/SkrepkaApp/Effects.swift+15−4
@@ -91,7 +91,8 @@ final class KvStore {
91 return .ok(response: .get(value: read(key)))
91 return .ok(response: .get(value: read(key)))
92 case .set(let key, let value):
92 case .set(let key, let value):
93 let previous = read(key)
93 let previous = read(key)
94+ // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
94− try? Data(value).write(to: fileURL(key))
95+ try? Data(value).write(to: fileURL(key), options: .atomic)
95 return .ok(response: .set(previous: previous))
96 return .ok(response: .set(previous: previous))
96 case .delete(let key):
97 case .delete(let key):
97 let previous = read(key)
98 let previous = read(key)
@@ -139,20 +140,30 @@ enum Keychain {
139 return item as? Data
140 return item as? Data
140 }
141 }
141
142
143+ /// A failed write is fatal: the in-memory key would work for this session, but the
144+ /// next launch would find nothing and silently mint a *new* identity — losing the
145+ /// old one, and with it every contact's idea of who we are.
142 private static func store(_ data: Data) {
146 private static func store(_ data: Data) {
143 let delete: [String: Any] = [
147 let delete: [String: Any] = [
144 kSecClass as String: kSecClassGenericPassword,
148 kSecClass as String: kSecClassGenericPassword,
145 kSecAttrService as String: service,
149 kSecAttrService as String: service,
146 kSecAttrAccount as String: account,
150 kSecAttrAccount as String: account,
147 ]
151 ]
148− SecItemDelete(delete as CFDictionary)
152+ let deleted = SecItemDelete(delete as CFDictionary)
153+ guard deleted == errSecSuccess || deleted == errSecItemNotFound else {
154+ fatalError("keychain: could not clear the old identity (OSStatus \(deleted))")
155+ }
149 let add: [String: Any] = [
156 let add: [String: Any] = [
150 kSecClass as String: kSecClassGenericPassword,
157 kSecClass as String: kSecClassGenericPassword,
151 kSecAttrService as String: service,
158 kSecAttrService as String: service,
152 kSecAttrAccount as String: account,
159 kSecAttrAccount as String: account,
153 kSecValueData as String: data,
160 kSecValueData as String: data,
154− kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
161+ // ThisDeviceOnly: the identity key must never ride along in a device backup.
162+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
155 ]
163 ]
156− SecItemAdd(add as CFDictionary, nil)
164+ let added = SecItemAdd(add as CFDictionary, nil)
165+ guard added == errSecSuccess else {
166+ fatalError("keychain: could not store the identity key (OSStatus \(added))")
167+ }
157 }
168 }
158}
169}
apple/SkrepkaApp/Info-iOS.plist+11−1
@@ -22,8 +22,18 @@
22 <string>1</string>
22 <string>1</string>
23 <key>NSAppTransportSecurity</key>
23 <key>NSAppTransportSecurity</key>
24 <dict>
24 <dict>
25− <key>NSAllowsArbitraryLoads</key>
25+ <key>NSAllowsLocalNetworking</key>
26 <true/>
26 <true/>
27+ <key>NSExceptionDomains</key>
28+ <dict>
29+ <key>localhost</key>
30+ <dict>
31+ <key>NSExceptionAllowsInsecureHTTPLoads</key>
32+ <true/>
33+ <key>NSIncludesSubdomains</key>
34+ <true/>
35+ </dict>
36+ </dict>
27 </dict>
37 </dict>
28 <key>NSCameraUsageDescription</key>
38 <key>NSCameraUsageDescription</key>
29 <string>Scan a contact's QR code to add them.</string>
39 <string>Scan a contact's QR code to add them.</string>
apple/SkrepkaApp/PhotoPicker.swift+7−3
@@ -30,13 +30,17 @@ struct PhotoPicker: UIViewControllerRepresentable {
30 return
30 return
31 }
31 }
32 provider.loadObject(ofClass: UIImage.self) { object, _ in
32 provider.loadObject(ofClass: UIImage.self) { object, _ in
33− guard let image = object as? UIImage,
33+ guard let image = object as? UIImage else { return }
34− let base64 = Self.downscaledBase64(image) else { return }
34+ // loadObject calls back on a background queue; the redraw below is UIKit.
35− DispatchQueue.main.async { self.onPick(base64) }
35+ DispatchQueue.main.async {
36+ guard let base64 = Self.downscaledBase64(image) else { return }
37+ self.onPick(base64)
38+ }
36 }
39 }
37 }
40 }
38
41
39 /// Resize to fit 256px and JPEG-encode, to keep the profile payload small.
42 /// Resize to fit 256px and JPEG-encode, to keep the profile payload small.
43+ /// Call on the main thread — UIGraphicsImageRenderer and UIImage.draw are UIKit.
40 static func downscaledBase64(_ image: UIImage) -> String? {
44 static func downscaledBase64(_ image: UIImage) -> String? {
41 let maxDim: CGFloat = 256
45 let maxDim: CGFloat = 256
42 let scale = min(1, maxDim / max(image.size.width, image.size.height))
46 let scale = min(1, maxDim / max(image.size.width, image.size.height))
apple/SkrepkaApp/QRView.swift+11−1
@@ -50,6 +50,7 @@ struct QRScannerView: UIViewControllerRepresentable {
50final class ScannerVC: UIViewController {
50final class ScannerVC: UIViewController {
51 weak var coordinator: QRScannerView.Coordinator?
51 weak var coordinator: QRScannerView.Coordinator?
52 private let session = AVCaptureSession()
52 private let session = AVCaptureSession()
53+ private var previewLayer: AVCaptureVideoPreviewLayer?
53
54
54 override func viewDidLoad() {
55 override func viewDidLoad() {
55 super.viewDidLoad()
56 super.viewDidLoad()
@@ -69,6 +70,12 @@ final class ScannerVC: UIViewController {
69 preview.frame = view.layer.bounds
70 preview.frame = view.layer.bounds
70 preview.videoGravity = .resizeAspectFill
71 preview.videoGravity = .resizeAspectFill
71 view.layer.addSublayer(preview)
72 view.layer.addSublayer(preview)
73+ previewLayer = preview
74+ }
75+
76+ override func viewDidLayoutSubviews() {
77+ super.viewDidLayoutSubviews()
78+ previewLayer?.frame = view.bounds
72 }
79 }
73
80
74 override func viewWillAppear(_ animated: Bool) {
81 override func viewWillAppear(_ animated: Bool) {
@@ -80,6 +87,9 @@ final class ScannerVC: UIViewController {
80
87
81 override func viewWillDisappear(_ animated: Bool) {
88 override func viewWillDisappear(_ animated: Bool) {
82 super.viewWillDisappear(animated)
89 super.viewWillDisappear(animated)
90+ // Both start and stop block until the camera pipeline settles — never on main.
83− if session.isRunning { session.stopRunning() }
91+ if session.isRunning {
92+ DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
93+ }
84 }
94 }
85}
95}
apple/SkrepkaApp/Views.swift+57−15
@@ -72,35 +72,44 @@ func initials(_ name: String) -> String {
72 return chars.isEmpty ? "?" : String(chars).uppercased()
72 return chars.isEmpty ? "?" : String(chars).uppercased()
73}
73}
74
74
75+/// Toggles `Contact::blocked` in the core. Blocked peers stay in the contact list —
76+/// listed apart — so the block is always reversible from the UI.
77+struct BlockToggle: View {
78+ @ObservedObject var core: Core
79+ let peer: String
80+ let blocked: Bool
81+
82+ var body: some View {
83+ Button(role: blocked ? nil : .destructive) {
84+ core.update(.setBlocked(peer: peer, blocked: !blocked))
85+ } label: {
86+ Label(blocked ? "Unblock" : "Block",
87+ systemImage: blocked ? "hand.raised.slash" : "hand.raised")
88+ }
89+ }
90+}
91+
75struct ConversationsView: View {
92struct ConversationsView: View {
76 @ObservedObject var core: Core
93 @ObservedObject var core: Core
77
94
78 var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
95 var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
96+ var blocked: [ContactVM] { core.view.contacts.filter { $0.blocked } }
79
97
80 var body: some View {
98 var body: some View {
81 NavigationStack {
99 NavigationStack {
82 Group {
100 Group {
83− if visible.isEmpty {
101+ if core.view.contacts.isEmpty {
84 ContentUnavailableView(
102 ContentUnavailableView(
85 "No conversations",
103 "No conversations",
86 systemImage: "bubble.left.and.bubble.right",
104 systemImage: "bubble.left.and.bubble.right",
87 description: Text("Tap + to add a contact by their key or QR code.")
105 description: Text("Tap + to add a contact by their key or QR code.")
88 )
106 )
89 } else {
107 } else {
108+ List {
90− List(visible, id: \.pubkey) { contact in
109+ ForEach(visible, id: \.pubkey) { row($0) }
91− Button { core.update(.openChat(contact.pubkey)) } label: {
110+ if !blocked.isEmpty {
92− HStack(spacing: 12) {
111+ Section("Blocked") {
93− Avatar(base64: contact.photo, name: contact.name)
112+ ForEach(blocked, id: \.pubkey) { row($0).opacity(0.5) }
94− VStack(alignment: .leading, spacing: 2) {
95− Text(contact.name).font(.headline).foregroundColor(.primary)
96− Text(contact.lastMessage.isEmpty ? contact.ob : contact.lastMessage)
97− .font(.subheadline).foregroundColor(.secondary).lineLimit(1)
98− }
99− Spacer()
100− if contact.lastTs > 0 {
101− Text(formatTime(contact.lastTs))
102− .font(.caption).foregroundColor(.secondary)
103− }
104 }
113 }
105 }
114 }
106 }
115 }
@@ -118,6 +127,27 @@ struct ConversationsView: View {
118 }
127 }
119 }
128 }
120 }
129 }
130+
131+ private func row(_ contact: ContactVM) -> some View {
132+ Button { core.update(.openChat(contact.pubkey)) } label: {
133+ HStack(spacing: 12) {
134+ Avatar(base64: contact.photo, name: contact.name)
135+ VStack(alignment: .leading, spacing: 2) {
136+ Text(contact.name).font(.headline).foregroundColor(.primary)
137+ Text(contact.lastMessage.isEmpty ? contact.ob : contact.lastMessage)
138+ .font(.subheadline).foregroundColor(.secondary).lineLimit(1)
139+ }
140+ Spacer()
141+ if contact.lastTs > 0 {
142+ Text(formatTime(contact.lastTs))
143+ .font(.caption).foregroundColor(.secondary)
144+ }
145+ }
146+ }
147+ .contextMenu {
148+ BlockToggle(core: core, peer: contact.pubkey, blocked: contact.blocked)
149+ }
150+ }
121}
151}
122
152
123struct ChatView: View {
153struct ChatView: View {
@@ -141,6 +171,18 @@ struct ChatView: View {
141 Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
171 Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
142 }
172 }
143 Spacer()
173 Spacer()
174+ if core.view.activePeerBlocked {
175+ Text("Blocked").font(.caption).foregroundColor(.secondary)
176+ }
177+ Menu {
178+ BlockToggle(
179+ core: core,
180+ peer: core.view.activePeer,
181+ blocked: core.view.activePeerBlocked
182+ )
183+ } label: {
184+ Image(systemName: "ellipsis.circle")
185+ }
144 }
186 }
145 .padding(.horizontal).padding(.vertical, 8)
187 .padding(.horizontal).padding(.vertical, 8)
146 .background(.bar)
188 .background(.bar)
apple/project.yml+7−1
@@ -29,8 +29,14 @@ targets:
29 - UIInterfaceOrientationPortrait
29 - UIInterfaceOrientationPortrait
30 NSCameraUsageDescription: Scan a contact's QR code to add them.
30 NSCameraUsageDescription: Scan a contact's QR code to add them.
31 NSPhotoLibraryUsageDescription: Choose a photo for your profile.
31 NSPhotoLibraryUsageDescription: Choose a photo for your profile.
32+ # Relays must be reachable over TLS. The only cleartext exception is a
33+ # locally-run `just server`, for development.
32 NSAppTransportSecurity:
34 NSAppTransportSecurity:
33− NSAllowsArbitraryLoads: true
35+ NSAllowsLocalNetworking: true
36+ NSExceptionDomains:
37+ localhost:
38+ NSExceptionAllowsInsecureHTTPLoads: true
39+ NSIncludesSubdomains: true
34 dependencies:
40 dependencies:
35 - package: Shared
41 - package: Shared
36 product: SkrepkaShared
42 product: SkrepkaShared
core/Cargo.toml−1
@@ -45,7 +45,6 @@ zstd.workspace = true
45rand_core.workspace = true
45rand_core.workspace = true
46uuid.workspace = true
46uuid.workspace = true
47hex.workspace = true
47hex.workspace = true
48−base64.workspace = true
49
48
50# optional, only for the codegen bin
49# optional, only for the codegen bin
51anyhow = { workspace = true, optional = true }
50anyhow = { workspace = true, optional = true }
core/src/app.rs+183−15
@@ -140,9 +140,11 @@ pub enum ConnStatus {
140 Online,
140 Online,
141}
141}
142
142
143−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
143+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
144pub enum Page {
144pub enum Page {
145− Setup,
145+ /// The shell renders a spinner until `IdentityLoaded` arrives, so there is
146+ /// no distinct pre-identity page — conversations is the landing page.
147+ #[default]
146 Conversations,
148 Conversations,
147 Chat,
149 Chat,
148 AddContact,
150 AddContact,
@@ -162,6 +164,9 @@ pub struct Model {
162 token: Option<String>,
164 token: Option<String>,
163 conn: ConnStatus,
165 conn: ConnStatus,
164 poll_retries: u32,
166 poll_retries: u32,
167+ /// A poll request is in flight. Guards against stacking concurrent long-poll
168+ /// loops (every extra loop re-polls itself forever and never terminates).
169+ polling: bool,
165 flushing: bool,
170 flushing: bool,
166 page: Page,
171 page: Page,
167 active_peer: Option<String>,
172 active_peer: Option<String>,
@@ -183,8 +188,9 @@ impl Default for Model {
183 token: None,
188 token: None,
184 conn: ConnStatus::Offline,
189 conn: ConnStatus::Offline,
185 poll_retries: 0,
190 poll_retries: 0,
191+ polling: false,
186 flushing: false,
192 flushing: false,
187− page: Page::Setup,
193+ page: Page::default(),
188 active_peer: None,
194 active_peer: None,
189 compose: String::new(),
195 compose: String::new(),
190 error: None,
196 error: None,
@@ -220,6 +226,30 @@ fn server_host(url: &str) -> String {
220 host.trim_end_matches('.').to_lowercase()
226 host.trim_end_matches('.').to_lowercase()
221}
227}
222
228
229+/// Validate and canonicalize a user-entered relay URL.
230+///
231+/// The shell hands us raw text-field contents, and `Http::post` panics on a URL
232+/// with no scheme — so a schemeless entry must be rejected here, in the core,
233+/// before it can ever reach an effect. Returns the normalized URL (lowercased
234+/// scheme, no trailing slash) or `None` if it isn't an absolute http(s) URL.
235+fn normalize_server_url(input: &str) -> Option<String> {
236+ let (scheme, rest) = input.trim().split_once("://")?;
237+ if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
238+ return None;
239+ }
240+ let rest = rest.trim_end_matches('/');
241+ if rest.chars().any(char::is_whitespace) {
242+ return None;
243+ }
244+ // Require a non-empty authority: reject "http://", "http:///x", "http://:80".
245+ let authority = rest.split('/').next().unwrap_or("");
246+ let host = authority.split('@').next_back().unwrap_or(authority);
247+ if host.split(':').next().unwrap_or("").is_empty() {
248+ return None;
249+ }
250+ Some(format!("{}://{}", scheme.to_lowercase(), rest))
251+}
252+
223fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
253fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
224 serde_json::to_vec(value).unwrap_or_default()
254 serde_json::to_vec(value).unwrap_or_default()
225}
255}
@@ -391,6 +421,11 @@ impl App for Skrepka {
391 render()
421 render()
392 }
422 }
393 Event::SetServerUrl(url) => {
423 Event::SetServerUrl(url) => {
424+ let Some(url) = normalize_server_url(&url) else {
425+ model.error = Some("server URL must start with http:// or https://".into());
426+ return render();
427+ };
428+ model.error = None;
394 model.settings.server_url = url;
429 model.settings.server_url = url;
395 model.token = None;
430 model.token = None;
396 model.conn = ConnStatus::Offline;
431 model.conn = ConnStatus::Offline;
@@ -484,7 +519,11 @@ impl App for Skrepka {
484 };
519 };
485 match Http::post(url).body_json(&body) {
520 match Http::post(url).body_json(&body) {
486 Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
521 Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
487− Err(_) => render(),
522+ Err(_) => {
523+ model.conn = ConnStatus::Offline;
524+ model.error = Some("could not build auth request".into());
525+ self.schedule_reconnect(model)
526+ }
488 }
527 }
489 }
528 }
490 Event::ChallengeResult(Ok(mut resp)) => {
529 Event::ChallengeResult(Ok(mut resp)) => {
@@ -505,7 +544,11 @@ impl App for Skrepka {
505 };
544 };
506 match Http::post(url).body_json(&body) {
545 match Http::post(url).body_json(&body) {
507 Ok(req) => req.build().then_send(Event::VerifyResult),
546 Ok(req) => req.build().then_send(Event::VerifyResult),
508− Err(_) => render(),
547+ Err(_) => {
548+ model.conn = ConnStatus::Offline;
549+ model.error = Some("could not build auth request".into());
550+ self.schedule_reconnect(model)
551+ }
509 }
552 }
510 }
553 }
511 Err(_) => {
554 Err(_) => {
@@ -543,6 +586,11 @@ impl App for Skrepka {
543
586
544 // ---------------- poll ----------------
587 // ---------------- poll ----------------
545 Event::Poll => {
588 Event::Poll => {
589+ // Exactly one poll loop at a time: each PollResult re-issues Poll,
590+ // so a second concurrent loop would double forever.
591+ if model.polling {
592+ return Command::done();
593+ }
546 let Some(token) = model.token.clone() else {
594 let Some(token) = model.token.clone() else {
547 return Command::event(Event::Authenticate);
595 return Command::event(Event::Authenticate);
548 };
596 };
@@ -554,11 +602,18 @@ impl App for Skrepka {
554 .header("authorization", format!("Bearer {token}"))
602 .header("authorization", format!("Bearer {token}"))
555 .body_json(&body)
603 .body_json(&body)
556 {
604 {
605+ Ok(req) => {
606+ model.polling = true;
557− Ok(req) => req.build().then_send(Event::PollResult),
607+ req.build().then_send(Event::PollResult)
608+ }
558− Err(_) => render(),
609+ Err(_) => {
610+ model.error = Some("could not build poll request".into());
611+ self.backoff_poll(model)
612+ }
559 }
613 }
560 }
614 }
561 Event::PollResult(Ok(mut resp)) => {
615 Event::PollResult(Ok(mut resp)) => {
616+ model.polling = false;
562 let status = u16::from(resp.status());
617 let status = u16::from(resp.status());
563 if status == 401 {
618 if status == 401 {
564 model.token = None;
619 model.token = None;
@@ -576,15 +631,20 @@ impl App for Skrepka {
576 .and(Command::event(Event::Poll))
631 .and(Command::event(Event::Poll))
577 .and(render())
632 .and(render())
578 }
633 }
634+ Event::PollResult(Err(_)) => {
635+ model.polling = false;
579− Event::PollResult(Err(_)) => self.backoff_poll(model),
636+ self.backoff_poll(model)
637+ }
580
638
581 // ---------------- outbox ----------------
639 // ---------------- outbox ----------------
582 Event::StartFlush => self.flush_next(model),
640 Event::StartFlush => self.flush_next(model),
583 Event::SendResult(Ok(resp)) => {
641 Event::SendResult(Ok(resp)) => {
642+ // The in-flight send is over; `flush_next` refuses to run while
643+ // this is set, so it must be cleared before *any* branch below.
644+ model.flushing = false;
584 let status = u16::from(resp.status());
645 let status = u16::from(resp.status());
585 if status == 401 {
646 if status == 401 {
586 model.token = None;
647 model.token = None;
587− model.flushing = false;
588 return Command::event(Event::Authenticate);
648 return Command::event(Event::Authenticate);
589 }
649 }
590 if (200..300).contains(&status) || status == 400 {
650 if (200..300).contains(&status) || status == 400 {
@@ -593,7 +653,6 @@ impl App for Skrepka {
593 return self.persist_outbox(model).and(self.flush_next(model));
653 return self.persist_outbox(model).and(self.flush_next(model));
594 }
654 }
595 // transient error — stop; will resume on next connect/poll.
655 // transient error — stop; will resume on next connect/poll.
596− model.flushing = false;
597 render()
656 render()
598 }
657 }
599 Event::SendResult(Err(_)) => {
658 Event::SendResult(Err(_)) => {
@@ -605,7 +664,6 @@ impl App for Skrepka {
605
664
606 fn view(&self, model: &Model) -> ViewModel {
665 fn view(&self, model: &Model) -> ViewModel {
607 let page = match model.page {
666 let page = match model.page {
608− Page::Setup => "setup",
609 Page::Conversations => "conversations",
667 Page::Conversations => "conversations",
610 Page::Chat => "chat",
668 Page::Chat => "chat",
611 Page::AddContact => "add_contact",
669 Page::AddContact => "add_contact",
@@ -817,7 +875,10 @@ impl Skrepka {
817 {
875 {
818 Ok(req) => req.build().then_send(Event::SendResult),
876 Ok(req) => req.build().then_send(Event::SendResult),
819 Err(_) => {
877 Err(_) => {
878+ // Not a dead loop: the next StartFlush (from a poll page or a
879+ // fresh send) retries the head of the outbox.
820 model.flushing = false;
880 model.flushing = false;
881+ model.error = Some("could not build send request".into());
821 render()
882 render()
822 }
883 }
823 }
884 }
@@ -950,10 +1011,11 @@ mod tests {
950
1011
951 fn with_identity() -> Model {
1012 fn with_identity() -> Model {
952 let id = Identity::from_seed(&[3u8; 32]);
1013 let id = Identity::from_seed(&[3u8; 32]);
953− let mut m = Model::default();
1014+ Model {
954− m.secret_key = Some(id.secret_key.to_vec());
1015+ secret_key: Some(id.secret_key.to_vec()),
955− m.my_pubkey = id.public_key_hex();
1016+ my_pubkey: id.public_key_hex(),
956− m
1017+ ..Default::default()
1018+ }
957 }
1019 }
958
1020
959 fn peer_hex(seed: u8) -> String {
1021 fn peer_hex(seed: u8) -> String {
@@ -1058,6 +1120,112 @@ mod tests {
1058 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
1120 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
1059 }
1121 }
1060
1122
1123+ #[test]
1124+ fn normalize_server_url_requires_an_http_scheme() {
1125+ assert_eq!(
1126+ normalize_server_url(" https://Relay.Example.com/ "),
1127+ Some("https://Relay.Example.com".to_string())
1128+ );
1129+ assert_eq!(
1130+ normalize_server_url("HTTP://localhost:8080///"),
1131+ Some("http://localhost:8080".to_string())
1132+ );
1133+ // The cases that used to panic Http::post or produce a dead URL.
1134+ assert_eq!(normalize_server_url("relay.example.com"), None);
1135+ assert_eq!(normalize_server_url("localhost:8080"), None);
1136+ assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
1137+ assert_eq!(normalize_server_url("http://"), None);
1138+ assert_eq!(normalize_server_url("http://:8080"), None);
1139+ assert_eq!(normalize_server_url("https://relay example.com"), None);
1140+ assert_eq!(normalize_server_url(""), None);
1141+ }
1142+
1143+ #[test]
1144+ fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
1145+ let app = Skrepka;
1146+ let mut m = with_identity();
1147+ let original = m.settings.server_url.clone();
1148+
1149+ let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
1150+ assert_eq!(m.settings.server_url, original, "bad URL must not persist");
1151+ assert!(m.error.is_some());
1152+
1153+ let _ = app.update(
1154+ Event::SetServerUrl(" https://relay.example.com/ ".into()),
1155+ &mut m,
1156+ );
1157+ assert_eq!(m.settings.server_url, "https://relay.example.com");
1158+ assert!(m.error.is_none());
1159+ assert!(m.token.is_none());
1160+ assert_eq!(m.conn, ConnStatus::Offline);
1161+ }
1162+
1163+ /// The regression that stalled the outbox: `flushing` stayed `true` forever
1164+ /// after the first successful send, so nothing was ever sent again.
1165+ #[test]
1166+ fn send_result_ok_clears_flushing() {
1167+ let app = Skrepka;
1168+ let mut m = with_identity();
1169+ m.token = Some("t".into());
1170+ m.flushing = true;
1171+ m.outbox.push_back(OutboxItem {
1172+ recipient: peer_hex(9),
1173+ envelope_json: "{}".into(),
1174+ });
1175+
1176+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1177+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
1178+
1179+ assert!(!m.flushing, "flushing must be cleared on the success path");
1180+ assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
1181+ }
1182+
1183+ #[test]
1184+ fn flush_next_resumes_after_a_successful_send() {
1185+ let app = Skrepka;
1186+ let mut m = with_identity();
1187+ m.token = Some("t".into());
1188+ for _ in 0..2 {
1189+ m.outbox.push_back(OutboxItem {
1190+ recipient: peer_hex(9),
1191+ envelope_json: "{}".into(),
1192+ });
1193+ }
1194+ // First send goes out and marks the model as flushing.
1195+ let _ = app.update(Event::StartFlush, &mut m);
1196+ assert!(m.flushing);
1197+
1198+ // Its success must both drop the item and let the *next* one go out.
1199+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1200+ let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
1201+ assert_eq!(m.outbox.len(), 1);
1202+ assert!(m.flushing, "the second item is now in flight");
1203+ }
1204+
1205+ #[test]
1206+ fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
1207+ let app = Skrepka;
1208+ let mut m = with_identity();
1209+ m.token = Some("t".into());
1210+
1211+ let _ = app.update(Event::Poll, &mut m);
1212+ assert!(m.polling, "the first Poll issues a request");
1213+
1214+ // A second Poll (e.g. from a re-auth) must not stack another loop.
1215+ let _ = app.update(Event::Poll, &mut m);
1216+ assert!(m.polling);
1217+
1218+ // Both success and failure release the guard so the loop can continue.
1219+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1220+ let _ = app.update(Event::PollResult(Ok(resp)), &mut m);
1221+ assert!(!m.polling);
1222+
1223+ let _ = app.update(Event::Poll, &mut m);
1224+ assert!(m.polling);
1225+ let _ = app.update(Event::PollResult(Err(crux_http::HttpError::Timeout)), &mut m);
1226+ assert!(!m.polling);
1227+ }
1228+
1061 #[test]
1229 #[test]
1062 fn view_exposes_identity_and_ob() {
1230 fn view_exposes_identity_and_ob() {
1063 let app = Skrepka;
1231 let app = Skrepka;
core/src/model.rs+1−1
@@ -153,7 +153,7 @@ pub struct MessageVM {
153
153
154#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
154#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
155pub struct ViewModel {
155pub struct ViewModel {
156− /// "setup" | "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
156+ /// "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
157 pub page: String,
157 pub page: String,
158 pub has_identity: bool,
158 pub has_identity: bool,
159 pub my_pubkey_hex: String,
159 pub my_pubkey_hex: String,
core/src/phonemic.rs+26−2
@@ -72,14 +72,18 @@ pub fn to_ob(bytes: &[u8]) -> String {
72}
72}
73
73
74/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
74/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
75+/// Input is case-insensitive; the syllable tables are lowercase.
75pub fn from_ob(s: &str) -> Option<Vec<u8>> {
76pub fn from_ob(s: &str) -> Option<Vec<u8>> {
77+ let lowered = s.to_lowercase();
76− let parts: Vec<&str> = s.split('-').filter(|p| !p.is_empty()).collect();
78+ let parts: Vec<&str> = lowered.split('-').filter(|p| !p.is_empty()).collect();
77 if parts.is_empty() {
79 if parts.is_empty() {
78 return None;
80 return None;
79 }
81 }
80 let mut out = Vec::with_capacity(parts.len() * 2);
82 let mut out = Vec::with_capacity(parts.len() * 2);
81 for part in parts {
83 for part in parts {
84+ // `len` is in bytes, so the ASCII check is what makes the 3-byte slices
85+ // below char-boundary-safe on arbitrary user input.
82− if part.len() != 6 {
86+ if part.len() != 6 || !part.is_ascii() {
83 return None;
87 return None;
84 }
88 }
85 let hi = prefix_index(&part[0..3])?;
89 let hi = prefix_index(&part[0..3])?;
@@ -150,4 +154,24 @@ mod tests {
150 assert_eq!(try_parse_pubkey("not-a-key"), None);
154 assert_eq!(try_parse_pubkey("not-a-key"), None);
151 assert_eq!(try_parse_pubkey(""), None);
155 assert_eq!(try_parse_pubkey(""), None);
152 }
156 }
157+
158+ #[test]
159+ fn ob_parsing_is_case_insensitive() {
160+ let key = [0xabu8; 32];
161+ let hex_str = hex::encode(key);
162+ let ob = to_ob(&key);
163+ assert_eq!(from_ob(&ob.to_uppercase()), Some(key.to_vec()));
164+ assert_eq!(try_parse_pubkey(&ob.to_uppercase()), Some(hex_str.clone()));
165+ // Mixed case, as a QR scan or a paste from a title-cased field might give.
166+ assert_eq!(try_parse_pubkey(&ob.replace("ler", "Ler")), Some(hex_str));
167+ }
168+
169+ /// `part.len()` is a *byte* count, so a 6-byte syllable holding a multi-byte
170+ /// char would slice mid-character and panic. Reachable from the AddContact field.
171+ #[test]
172+ fn multibyte_input_is_rejected_not_panicked_on() {
173+ assert_eq!(from_ob("abéxx"), None);
174+ assert_eq!(try_parse_pubkey("abéxx"), None);
175+ assert_eq!(try_parse_pubkey("ünïcødé-abéxx"), None);
176+ }
153}
177}
dotnet-tools.jsondeleted−13
@@ -1,13 +0,0 @@
1−{
2− "version": 1,
3− "isRoot": true,
4− "tools": {
5− "fantomas": {
6− "version": "7.0.5",
7− "commands": [
8− "fantomas"
9− ],
10− "rollForward": false
11− }
12− }
13−}
install.sh+17−5
@@ -45,21 +45,29 @@ echo "Downloading latest release..."
45
45
46DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
46DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
47
47
48+# Download to a temp file next to the destination, then rename. Writing straight
49+# to $INSTALL_BIN fails with ETXTBSY while the service is running; rename(2) is
50+# atomic and happily replaces a busy binary (the running process keeps the old
51+# inode until it is restarted below).
52+TMP_BIN="${INSTALL_BIN}.new.$$"
53+trap 'rm -f "$TMP_BIN"' EXIT INT TERM
54+
48if command -v curl >/dev/null 2>&1; then
55if command -v curl >/dev/null 2>&1; then
49− curl -fSL "$DOWNLOAD_URL" -o "$INSTALL_BIN"
56+ curl -fSL "$DOWNLOAD_URL" -o "$TMP_BIN"
50elif command -v wget >/dev/null 2>&1; then
57elif command -v wget >/dev/null 2>&1; then
51− wget -q "$DOWNLOAD_URL" -O "$INSTALL_BIN"
58+ wget -q "$DOWNLOAD_URL" -O "$TMP_BIN"
52else
59else
53 echo "error: curl or wget required" >&2
60 echo "error: curl or wget required" >&2
54 exit 1
61 exit 1
55fi
62fi
56
63
64+chmod 755 "$TMP_BIN"
57−chmod 755 "$INSTALL_BIN"
65+mv -f "$TMP_BIN" "$INSTALL_BIN"
58echo "Installed binary to ${INSTALL_BIN}"
66echo "Installed binary to ${INSTALL_BIN}"
59
67
60case "$PLATFORM" in
68case "$PLATFORM" in
61 linux-*)
69 linux-*)
62− cat > "$UNIT_DEST" <<'EOF'
70+ cat > "$UNIT_DEST" <<EOF
63[Unit]
71[Unit]
64Description=Skrepka relay server
72Description=Skrepka relay server
65After=network-online.target
73After=network-online.target
@@ -67,10 +75,14 @@ Wants=network-online.target
67
75
68[Service]
76[Service]
69Type=simple
77Type=simple
78+# --serverHost is the federation identity AND the hostname clients bind their
79+# auth signature to ("skrepka-auth-v1:<host>:<challenge>"). It defaults to
80+# "localhost", which no client ever dials, so /auth/verify would reject every
81+# signature. It must be the public name clients use to reach this relay.
70# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
82# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
71# envelope, otherwise the runtime's default 16 MiB body cap rejects a
83# envelope, otherwise the runtime's default 16 MiB body cap rejects a
72# max-size blob with 413 before the handler's BlobHex check is ever reached.
84# max-size blob with 413 before the handler's BlobHex check is ever reached.
73−ExecStart=/usr/local/bin/skrepka-server --http-max-body-bytes=42M
85+ExecStart=/usr/local/bin/skrepka-server --serverHost=${DOMAIN} --http-max-body-bytes=42M
74Restart=on-failure
86Restart=on-failure
75RestartSec=5
87RestartSec=5
76
88
server.knot+47−19
@@ -116,10 +116,18 @@ pollTimeoutMs = 25000
116-- share this window; only the per-window request budget varies per endpoint.
116-- share this window; only the per-window request budget varies per endpoint.
117rateLimitWindow : Int<Ms>
117rateLimitWindow : Int<Ms>
118rateLimitWindow = 60000
118rateLimitWindow = 60000
119−-- Per-pubkey budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
119+-- Per-client-IP budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
120−-- The normal handshake is one challenge + one verify, so a healthy flow uses 2.
120+-- The normal handshake is one challenge + one verify, so a healthy flow uses 2;
121+-- the budget leaves room for many clients behind one NAT. (Keyed on IP, not on
122+-- the submitted pubkey: keypairs are free to generate, so a pubkey-keyed bucket
123+-- hands an attacker a fresh budget with every rotation — a Sybil flood could
124+-- then mint sessions until `maxSessions` is hit and evict live ones. A pubkey
125+-- key is also abusable in the other direction: flooding a *victim's* pubkey
126+-- would exhaust that victim's login budget. Per-pubkey exhaustion of the
127+-- challenge/session pools is still bounded, by `maxChallengesPerKey` and
128+-- `maxSessionsPerKey` in the handlers.)
121authRateLimit : Int
129authRateLimit : Int
122−authRateLimit = 10
130+authRateLimit = 60
123-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect
131-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect
124-- after `pollTimeoutMs`, so steady state is ~3/min per client; the budget
132-- after `pollTimeoutMs`, so steady state is ~3/min per client; the budget
125-- leaves room for several clients behind one NAT. (Keyed on IP, not bearer
133-- leaves room for several clients behind one NAT. (Keyed on IP, not bearer
@@ -275,16 +283,16 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
275-- ============================================
283-- ============================================
276-- Rate limiting
284-- Rate limiting
277-- ============================================
285-- ============================================
278−-- Per-endpoint token-bucket configuration. Auth endpoints key on the submitted
286+-- Per-endpoint token-bucket configuration. Every endpoint keys on the client IP
279−-- pubkey (so one keypair can't drain global capacity); client and federation
280−-- endpoints key on the client IP (last X-Forwarded-For hop behind a trusted
287+-- (last X-Forwarded-For hop behind a trusted proxy, else the connection
281−-- proxy, else the connection address). The bearer token is deliberately not a
288+-- address). Nothing the caller can mint for free is a bucket key: the limiter
282−-- bucket key: the limiter runs before the handler, so it can't tell valid
289+-- runs before the handler, so it cannot tell a valid session from an invented
283−-- sessions from invented ones, and an attacker varying the token would mint a
290+-- one, and an attacker varying a self-chosen value would get a fresh bucket per
291+-- value. That rules out the bearer token (any string) and the submitted pubkey
284−-- fresh bucket per value. Each route has an independent bucket store, so endpoints
292+-- (any keypair) alike — see `authRateLimit`. Each route has an independent
285−-- using the same key strategy don't share budget across routes. Buckets persist
293+-- bucket store, so endpoints using the same key strategy don't share budget
286−-- in the runtime's `_knot_rate_limits` SQLite table; rejection responds 429
294+-- across routes. Buckets persist in the runtime's `_knot_rate_limits` SQLite
287−-- before the handler runs.
295+-- table; rejection responds 429 before the handler runs.
288
296
289-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
297-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
290-- comma-separated chain that each proxy appends to; only the final entry —
298-- comma-separated chain that each proxy appends to; only the final entry —
@@ -304,10 +312,9 @@ pickClientIp = \h connIp -> case h of
304 else connIp
312 else connIp
305 Nothing {} -> connIp
313 Nothing {} -> connIp
306
314
307−byPubkey = \{pubkey} _ -> Just {value: pubkey}
308byClientIp = \_ ctx -> Just {value: pickClientIp (ctx.header "X-Forwarded-For") ctx.clientIp}
315byClientIp = \_ ctx -> Just {value: pickClientIp (ctx.header "X-Forwarded-For") ctx.clientIp}
309
316
310−authLimit = {key: byPubkey, limit: {requests: authRateLimit, window: rateLimitWindow}}
317+authLimit = {key: byClientIp, limit: {requests: authRateLimit, window: rateLimitWindow}}
311pollLimit = {key: byClientIp, limit: {requests: pollRateLimit, window: rateLimitWindow}}
318pollLimit = {key: byClientIp, limit: {requests: pollRateLimit, window: rateLimitWindow}}
312sendLimit = {key: byClientIp, limit: {requests: sendRateLimit, window: rateLimitWindow}}
319sendLimit = {key: byClientIp, limit: {requests: sendRateLimit, window: rateLimitWindow}}
313gossipLimit = {key: byClientIp, limit: {requests: gossipRateLimit, window: rateLimitWindow}}
320gossipLimit = {key: byClientIp, limit: {requests: gossipRateLimit, window: rateLimitWindow}}
@@ -483,7 +490,22 @@ anyOctetLeadingZero = \host -> do
483 let rest = afterDot host
490 let rest = afterDot host
484 (length octet > 1 && take 1 octet == "0") || (rest != "" && anyOctetLeadingZero rest)
491 (length octet > 1 && take 1 octet == "0") || (rest != "" && anyOctetLeadingZero rest)
485
492
486−-- Block private/reserved/internal hostnames (SSRF defense). Skips percent-encoding
493+-- Block private/reserved/internal hostnames (SSRF defense).
494+--
495+-- LIMITATION — this check is purely lexical: it inspects the *string* a peer
496+-- gave us, never the address that string resolves to. An attacker who controls
497+-- DNS for a name that looks perfectly public (`relay.attacker.example`) can
498+-- point it at 127.0.0.1 / 169.254.169.254 / an RFC1918 host and this returns
499+-- False, so `fetch` dials the internal target anyway. Nothing here rules that
500+-- out, and re-resolving before the request wouldn't either (the resolver is
501+-- free to answer differently the second time — a DNS-rebinding TOCTOU). Closing
502+-- it needs resolution and connection to be fused: resolve the name, reject the
503+-- resulting IP against the same reserved ranges, and connect to *that* IP —
504+-- which the `fetch` in use does not expose. Treat the list below as a guard
505+-- against accidental and naive-literal SSRF, not against a motivated attacker
506+-- with a nameserver. Same class of gap as the percent-encoding one noted next.
507+--
508+-- Skips percent-encoding
487-- tricks but covers the high-value cases: `localhost`, RFC1918 ranges (10/8,
509-- tricks but covers the high-value cases: `localhost`, RFC1918 ranges (10/8,
488-- 172.16/12, 192.168/16) plus link-local
510-- 172.16/12, 192.168/16) plus link-local
489-- 169.254/16, the IPv4 zero net, CGNAT 100.64/10, benchmarking 198.18/15,
511-- 169.254/16, the IPv4 zero net, CGNAT 100.64/10, benchmarking 198.18/15,
@@ -798,7 +820,7 @@ forwardQueuedToServer = \pubkey server -> atomic (do
798-- revoke/logout.
820-- revoke/logout.
799
821
800-- POST /auth/challenge: issue a fresh signed-nonce challenge for `pubkey`.
822-- POST /auth/challenge: issue a fresh signed-nonce challenge for `pubkey`.
801−-- Capped per-key (maxChallengesPerKey); per-pubkey rate limiting is enforced
823+-- Capped per-key (maxChallengesPerKey); per-client-IP rate limiting is enforced
802-- by the route's `rateLimit` clause. The IP is recorded so /auth/verify can
824-- by the route's `rateLimit` clause. The IP is recorded so /auth/verify can
803-- require the same client.
825-- require the same client.
804handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
826handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
@@ -924,12 +946,18 @@ doPoll = \s t -> do
924 -- `race` requires both arms to share a single effect row, so each arm
946 -- `race` requires both arms to share a single effect row, so each arm
925 -- carries the union {r *messages, clock} — the STM wait gets a `now`,
947 -- carries the union {r *messages, clock} — the STM wait gets a `now`,
926 -- the timer gets a no-op `*messages` read.
948 -- the timer gets a no-op `*messages` read.
949+ -- The wait predicate must match `collectAt`'s exactly, age guard included:
950+ -- an expired row that still sits above the cursor (not yet swept by
951+ -- `pruneExpired`) would otherwise satisfy the wait, wake the poll, then be
952+ -- filtered out by `collectAt` — returning an empty page whose cursor is
953+ -- unchanged, so the client immediately re-polls into the same wakeup. That
954+ -- is a tight spin for as long as the row survives.
927 race
955 race
928 (do
956 (do
929− _ <- now
957+ tw <- now
930 atomic (do
958 atomic (do
931 msgs <- *messages
959 msgs <- *messages
932− let fresh = filter (\m -> m.toKey == s.pubkey && m.receivedAt > s.clampedCursor) msgs
960+ let fresh = filter (\m -> m.toKey == s.pubkey && tw - m.receivedAt < messageMaxAge && m.receivedAt > s.clampedCursor) msgs
933 if count fresh > 0 then yield {} else retry))
961 if count fresh > 0 then yield {} else retry))
934 (do
962 (do
935 atomic (do _ <- *messages; yield {})
963 atomic (do _ <- *messages; yield {})
skrepka.service+9−1
@@ -5,7 +5,15 @@ Wants=network-online.target
5
5
6[Service]
6[Service]
7Type=simple
7Type=simple
8+# EDIT --serverHost: replace relay.example.com with the public hostname clients
9+# use to reach this relay. It is the federation identity and the hostname a
10+# client binds its auth signature to ("skrepka-auth-v1:<host>:<challenge>"); the
11+# built-in default is "localhost", so leaving it unset makes /auth/verify reject
12+# every signature.
13+# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
14+# envelope, otherwise the runtime's default 16 MiB body cap rejects a max-size
15+# blob with 413 before the handler's BlobHex check is ever reached.
8−ExecStart=/usr/local/bin/skrepka-server
16+ExecStart=/usr/local/bin/skrepka-server --serverHost=relay.example.com --http-max-body-bytes=42M
9Restart=on-failure
17Restart=on-failure
10RestartSec=5
18RestartSec=5
11
19