on master help sign in
Fix all 16 audit findings: outbox stall, deploy auth, panics, security, docs

CRITICAL: - Outbox stalls after first send: clearing flushing flag in success path (app.rs) - install.sh missing --serverHost: auth fails on every deployed relay HIGH: - Schemeless server URL panics core: validate in SetServerUrl (app.rs) - Auth rate-limited per pubkey only: add per-IP bucket (server.knot) - Blocking unreachable from UI: block/unblock actions in chat (Views.swift) MEDIUM: - Poll loops multiply: polling guard in Model (app.rs, model.rs) - Dead poll/auth loops on build failure: route to reconnect/backoff (app.rs) - install.sh overwrites running binary: download to temp + mv (install.sh) - NSAllowsArbitraryLoads: true: replace with localhost exception (project.yml, plist) - Keychain write silent failure: check OSStatus + fatalError (Effects.swift) - skrepka.service missing flags: match install.sh output (skrepka.service) - CI never tests client: add cargo test + clippy job, fix tag collision (build.yml) LOW: - PROTOCOL.md stale-profile claim outdated: update to match code (PROTOCOL.md) - Server poll spin on expired-uncursored messages: add age guard (server.knot) - README describes deleted F# client: rewrite for Rust/Swift/Knot (README.md) - Remove stale artifacts: dotnet-tools.json, .gitignore entries, PROTOCOL-REVIEW.md refs - Unused base64 dep, wrong repo URL, dead Page::Setup, case-sensitive @p parser - QRView stopRunning blocks main thread, PhotoPicker UIKit off main, non-atomic kv writes - SSRF deny-list lexical-only limitation documented (server.knot) 34 tests pass (7 new), clippy clean.

commitaffb421d80e9bcd3c24ebb12a68cde01f18cd46b authoriko <mail@iko.soy> dateSat 11 Jul 2026, 09:17 (3 months ago) parent1fc068e
21 files changed+531−155
.github/workflows/build.yml+28−2 .gitignore−3 Cargo.lock−1 Cargo.toml+1−2 PROTOCOL-REVIEW.md+72−52 PROTOCOL.md+2−2 README.md+37−11 apple/SkrepkaApp/Effects.swift+15−4 apple/SkrepkaApp/Info-iOS.plist+11−1 apple/SkrepkaApp/PhotoPicker.swift+7−3 apple/SkrepkaApp/QRView.swift+11−1 apple/SkrepkaApp/Views.swift+57−15 apple/project.yml+7−1 core/Cargo.toml−1 core/src/app.rs+183−15 core/src/model.rs+1−1 core/src/phonemic.rs+26−2 dotnet-tools.json−13 install.sh+17−5 server.knot+47−19 skrepka.service+9−1
.github/workflows/build.yml+28−2
@@ -6,6 +6,24 @@ on:
66 pull_request:
77
88jobs:
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+
927 build:
1028 strategy:
1129 fail-fast: false
@@ -59,7 +77,7 @@ jobs:
5977 path: server
6078
6179 release:
62− needs: build
80+ needs: [core, build]
6381 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
6482 runs-on: ubuntu-latest
6583 permissions:
@@ -71,9 +89,17 @@ jobs:
7189 with:
7290 path: repo
7391
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.
7497 - name: Generate version
7598 id: version
76− run: echo "tag=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M')" >> "$GITHUB_OUTPUT"
99+ run: |
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"
77103
78104 - name: Prepare release assets
79105 run: |
.gitignore−3
@@ -4,9 +4,6 @@ server
44*.db-journal
55*.db-wal
66*.db-shm
7−.fake
8−client/bin
9−client/obj
107.DS_Store
118.claude/
129
Cargo.lock−1
@@ -1797,7 +1797,6 @@ name = "skrepka_core"
17971797version = "0.1.0"
17981798dependencies = [
17991799 "anyhow",
1800− "base64",
18011800 "boltffi",
18021801 "chacha20poly1305",
18031802 "clap",
Cargo.toml+1−2
@@ -6,7 +6,7 @@ resolver = "2"
66edition = "2021"
77rust-version = "1.90"
88license = "MIT"
9−repository = "https://github.com/iko/skrepka"
9+repository = "https://github.com/ilyakooo0/skrepka"
1010
1111[workspace.dependencies]
1212crux_core = "0.19"
@@ -30,7 +30,6 @@ rand_core = { version = "0.6", features = ["getrandom"] }
3030rand_chacha = "0.3"
3131uuid = { version = "1", features = ["v4"] }
3232hex = "0.4"
33−base64 = "0.22"
3433
3534# codegen bin
3635anyhow = "1.0"
PROTOCOL-REVIEW.md+72−52
@@ -1,9 +1,14 @@
11# PROTOCOL.md Review — Holes vs. Implementation
22
3−**Date:** 2026-06-20
3+**Date:** 2026-06-20 (revised 2026-07-11 against the Rust core)
44**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
5−(`server.knot`, `client/`). Each item records whether the implementation already
6−handles it, partially handles it, or leaves it open.
5+(`server.knot`, `core/` — the Rust/Crux shared core). Each item records whether
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.
712
813Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
914
@@ -26,14 +31,18 @@ the relay server was the client-of-record to A throughout.
2631
2732**Fix (applied):** the client now signs
2833`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
29−bare lowercased hostname it dialed (`client/ApiClient.fs`, `Crypto.fs`
30−`signChallenge`), and the server verifies against
34+bare lowercased hostname it dialed (`Identity::sign_challenge`,
35+`core/src/crypto.rs`; the tag is `AUTH_TAG`), and the server verifies against
3136`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
3237configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
3338prefix 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:
3540client and server must be upgraded together.
3641
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+
3746---
3847
3948## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
@@ -41,9 +50,8 @@ client and server must be upgraded together.
4150**Severity: high — open (consequence of open federation).**
4251
4352`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
44−forwards the recipient's queued messages **to the announcing `fromServer`**.
45−
46−- `server.knot:1090` — `fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))`.
53+forwards the recipient's queued messages **to the announcing `fromServer`**
54+(`handleRecvGossip` → `forwardQueuedToServer pk fromServer`, `server.knot`).
4755
4856**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
4957fromServer:<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
5159metadata. The receiver-side "recipient must be locally online" gate is checked by
5260the attacker's own server, so it is not a real gate.
5361
54−**Existing partial defense:** `isBadServerName` SSRF deny-list (`server.knot:505`)
55−blocks internal targets; rate-limited 60/window/IP. Neither authenticates the peer.
62+**Existing partial defense:** the `isBadServerName` SSRF deny-list blocks
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`).
5666
5767`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
5868can 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.
6070
6171---
6272
63−## 🟡 3. Replay protection is narrower than §10 claims
73+## ✅ 3. Replay protection is narrower than §10 claims — closed in the Rust core
6474
65−**Severity: medium — partially fixed.**
75+**Severity: medium — fixed.**
6676
67−§10 claims "Replay protection"; the only mechanism is the recipient-bound
68−signature, which stops replay to a *different* recipient, not re-delivery of the
69−same blob to the same recipient. Per-type status:
77+§10 claimed "Replay protection"; the underlying mechanism is the
78+recipient-bound signature, which stops replay to a *different* recipient, not
79+re-delivery of the same blob to the same recipient. Per-type status, all in
80+`ingest_poll` (`core/src/app.rs`):
7081
71−- `text` — ✅ protected: deduped by `id` (`client/App.fs:38`).
72−- `delivery.ack` — ✅ harmless: `markDelivered` only flips `Sent → Delivered`,
73− idempotent (`client/App.fs:43`).
74−- `profile` — ❌ **open**: `withProfile` overwrites name/bio/photo
75− unconditionally (`client/App.fs:66`). The payload `ts` is parsed
76− (`client/Protocol.fs:53`) but **not** propagated to `ProfileMessage` or
77− compared, so a replayed *older* profile blob silently rolls a contact's
78− profile back.
82+- `text` — ✅ deduplicated by `id` before it is appended to a conversation.
83+- `delivery.ack` — ✅ harmless: it only flips `Sent → Delivered`, idempotent.
84+- `profile` — ✅ **fixed** (was open in the F# client): each `Contact` carries
85+ `last_profile_ts` (`core/src/model.rs`), and an incoming `profile` whose `ts`
86+ predates it is dropped instead of applied, so a replayed *older* profile can no
87+ longer roll a contact's name/bio/photo back.
7988
80−**Fix:** carry `ts` into `Envelope.ProfileMessage` and ignore a profile whose
81−`ts` is older than the last one stored for that contact. Tighten §10 wording to
82−"replay against a *different* recipient."
89+`PROTOCOL.md` §4 and §10 have been updated to say the reference client enforces
90+the staleness check. §10's replay wording should still be read as "replay against
91+a *different* recipient" — re-delivery to the same recipient is prevented by the
92+per-type guards above, not by the crypto.
8393
8494---
8595
@@ -90,11 +100,12 @@ same blob to the same recipient. Per-type status:
90100Any host can POST a blob for any locally-online `toKey`; the endpoint is
91101unauthenticated.
92102
93−- Mitigations present: per-IP rate limit 600/window (`server.knot:140/314`),
94− SSRF filter on origins, `no_presence` gate.
103+- Mitigations present: per-IP rate limit (`forwardRateLimit`, `server.knot`),
104+ the SSRF filter on origins, and the `no_presence` gate.
95105- 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
97− `Crypto.decrypt`, `client/Protocol.fs:88`). Worth documenting as an accepted,
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
108+ (`ingest_poll`, `core/src/app.rs`). Worth documenting as an accepted,
98109 rate-limited spam surface.
99110
100111---
@@ -102,7 +113,7 @@ unauthenticated.
102113## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
103114
104115**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
106117`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
107118message. The spec just describes the same value as both a "millisecond timestamp"
108119and "monotonic," which reads as ambiguous. Recommend: describe it as "a
@@ -113,14 +124,14 @@ checkpoint, not a clock."
113124
114125## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
115126
116−The per-blob cap is `maxBlobLen = 41943040` (40 MiB hex) enforced by the
117−`BlobHex` type (`server.knot:53/198`), and `maxBatchSize = 100`
118−(`server.knot:56`). The naive reading "100 × 40 MiB = 4 GiB request" is
119−**prevented** by a runtime body cap set in deployment:
127+The per-blob cap is `maxBlobLen` (40 MiB hex) enforced by the `BlobHex` type, and
128+a batch is capped at `maxBatchSize = 100` (`server.knot`). The naive reading
129+"100 × 40 MiB = 4 GiB request" is **prevented** by a runtime body cap set in
130+deployment:
120131
121132- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
122133 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).
124135
125136So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
126137`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.
132143
133144**Severity: low — open.**
134145
135−`info = "skrepka-v1"` is hardcoded (`client/Crypto.fs:178` / `Constants`), and
136−neither the envelope nor the plaintext payload carries a version field. "Ignore
137−unknown `type`" gives payload-type agility but no path to rotate the
138−AEAD/KDF/curve. Recommend a stated version field or an explicit "no crypto
139−agility in v0.1" note.
146+`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and neither the
147+envelope nor the plaintext payload carries a version field. "Ignore unknown
148+`type`" (`protocol::parse_payload`) gives payload-type agility but no path to
149+rotate the AEAD/KDF/curve. Recommend a stated version field or an explicit "no
150+crypto agility in v0.1" note.
140151
141152---
142153
143154## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
144155
145−`trustForwardedFor = True` by default (`server.knot:148`); the session/rate-limit
146−IP is the last XFF hop (`pickClientIp`, `server.knot:302`), and `matchIp` treats
147−an empty bound IP as a wildcard (`server.knot:546`). This is correct **behind a
148−trusted proxy** but spoofable when the server is directly exposed. The flag and
149−its `--trustForwardedFor=False` override exist; the spec presents IP-binding as a
156+`trustForwardedFor = True` by default (`server.knot`); the session/rate-limit IP
157+is the last XFF hop (`pickClientIp`), and `matchIp` treats an empty bound IP as a
158+wildcard. This is correct **behind a trusted proxy** (the `install.sh` topology,
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
161+`--trustForwardedFor=False` override exist; the spec presents IP-binding as a
150162protection without the proxy caveat. Recommend documenting it.
151163
152164---
153165
154166## Minor / noted
155167
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.
156172- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
157173 sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
158174- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
159175 metadata and stale forwards can persist ~90 min after disconnect.
160176- `/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.
162181
163182---
164183
165184## Suggested follow-ups
166185
167−1. **Spec edits (safe, no behavior change):** fold #2, #3, #6, #8 caveats into
168− `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security).
169−2. **Code fixes worth doing:** #1 (bind challenge to server) is **done**;
170− #3-profile (`ts` staleness guard) remains a genuine, fixable defect.
186+1. **Spec edits (safe, no behavior change):** fold #2, #4, #6, #8 caveats into
187+ `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security). #3's spec text
188+ is already updated.
189+2. **Code fixes:** #1 (bind challenge to server) and #3 (profile `ts` staleness
190+ guard) are **done**.
1711913. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
172192 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`
219219
220220Clients 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.
221221
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.
223223
224224### Delivery Acks
225225
@@ -590,7 +590,7 @@ Skrepka protects message **content and sender identity** from servers and networ
590590| **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. |
591591| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
592592| **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. |
594594| **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. |
595595| **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). |
596596| **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 @@
11# Skrepka
22
3−<p align="center">
4− <img src="client/Assets/Images/logo.png" width="300px" align="center">
5−</p>
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.
64
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.
86
9−## Run the client
7+## What's here
108
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:
1221
1322```sh
14−cd client && ./run-mac.sh # macOS desktop (.app bundle)
15−cd client && ./run-ios.sh # iOS simulator (default: iPhone 17 Pro)
16−cd client && dotnet build # Plain build
23+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
24+cargo install boltffi_cli
25+brew install xcodegen
1726```
1827
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:
2042
2143```sh
2244curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
2345```
2446
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 {
9191 return .ok(response: .get(value: read(key)))
9292 case .set(let key, let value):
9393 let previous = read(key)
94− try? Data(value).write(to: fileURL(key))
94+ // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
95+ try? Data(value).write(to: fileURL(key), options: .atomic)
9596 return .ok(response: .set(previous: previous))
9697 case .delete(let key):
9798 let previous = read(key)
@@ -139,20 +140,30 @@ enum Keychain {
139140 return item as? Data
140141 }
141142
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.
142146 private static func store(_ data: Data) {
143147 let delete: [String: Any] = [
144148 kSecClass as String: kSecClassGenericPassword,
145149 kSecAttrService as String: service,
146150 kSecAttrAccount as String: account,
147151 ]
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+ }
149156 let add: [String: Any] = [
150157 kSecClass as String: kSecClassGenericPassword,
151158 kSecAttrService as String: service,
152159 kSecAttrAccount as String: account,
153160 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,
155163 ]
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+ }
157168 }
158169}
apple/SkrepkaApp/Info-iOS.plist+11−1
@@ -22,8 +22,18 @@
2222 <string>1</string>
2323 <key>NSAppTransportSecurity</key>
2424 <dict>
25− <key>NSAllowsArbitraryLoads</key>
25+ <key>NSAllowsLocalNetworking</key>
2626 <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>
2737 </dict>
2838 <key>NSCameraUsageDescription</key>
2939 <string>Scan a contact's QR code to add them.</string>
apple/SkrepkaApp/PhotoPicker.swift+7−3
@@ -30,13 +30,17 @@ struct PhotoPicker: UIViewControllerRepresentable {
3030 return
3131 }
3232 provider.loadObject(ofClass: UIImage.self) { object, _ in
33− guard let image = object as? UIImage,
34− let base64 = Self.downscaledBase64(image) else { return }
35− DispatchQueue.main.async { self.onPick(base64) }
33+ guard let image = object as? UIImage else { return }
34+ // loadObject calls back on a background queue; the redraw below is UIKit.
35+ DispatchQueue.main.async {
36+ guard let base64 = Self.downscaledBase64(image) else { return }
37+ self.onPick(base64)
38+ }
3639 }
3740 }
3841
3942 /// 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.
4044 static func downscaledBase64(_ image: UIImage) -> String? {
4145 let maxDim: CGFloat = 256
4246 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 {
5050final class ScannerVC: UIViewController {
5151 weak var coordinator: QRScannerView.Coordinator?
5252 private let session = AVCaptureSession()
53+ private var previewLayer: AVCaptureVideoPreviewLayer?
5354
5455 override func viewDidLoad() {
5556 super.viewDidLoad()
@@ -69,6 +70,12 @@ final class ScannerVC: UIViewController {
6970 preview.frame = view.layer.bounds
7071 preview.videoGravity = .resizeAspectFill
7172 view.layer.addSublayer(preview)
73+ previewLayer = preview
74+ }
75+
76+ override func viewDidLayoutSubviews() {
77+ super.viewDidLayoutSubviews()
78+ previewLayer?.frame = view.bounds
7279 }
7380
7481 override func viewWillAppear(_ animated: Bool) {
@@ -80,6 +87,9 @@ final class ScannerVC: UIViewController {
8087
8188 override func viewWillDisappear(_ animated: Bool) {
8289 super.viewWillDisappear(animated)
83− if session.isRunning { session.stopRunning() }
90+ // Both start and stop block until the camera pipeline settles — never on main.
91+ if session.isRunning {
92+ DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
93+ }
8494 }
8595}
apple/SkrepkaApp/Views.swift+57−15
@@ -72,35 +72,44 @@ func initials(_ name: String) -> String {
7272 return chars.isEmpty ? "?" : String(chars).uppercased()
7373}
7474
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+
7592struct ConversationsView: View {
7693 @ObservedObject var core: Core
7794
7895 var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
96+ var blocked: [ContactVM] { core.view.contacts.filter { $0.blocked } }
7997
8098 var body: some View {
8199 NavigationStack {
82100 Group {
83− if visible.isEmpty {
101+ if core.view.contacts.isEmpty {
84102 ContentUnavailableView(
85103 "No conversations",
86104 systemImage: "bubble.left.and.bubble.right",
87105 description: Text("Tap + to add a contact by their key or QR code.")
88106 )
89107 } else {
90− List(visible, id: \.pubkey) { contact in
91− Button { core.update(.openChat(contact.pubkey)) } label: {
92− HStack(spacing: 12) {
93− Avatar(base64: contact.photo, name: contact.name)
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− }
108+ List {
109+ ForEach(visible, id: \.pubkey) { row($0) }
110+ if !blocked.isEmpty {
111+ Section("Blocked") {
112+ ForEach(blocked, id: \.pubkey) { row($0).opacity(0.5) }
104113 }
105114 }
106115 }
@@ -118,6 +127,27 @@ struct ConversationsView: View {
118127 }
119128 }
120129 }
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+ }
121151}
122152
123153struct ChatView: View {
@@ -141,6 +171,18 @@ struct ChatView: View {
141171 Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
142172 }
143173 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+ }
144186 }
145187 .padding(.horizontal).padding(.vertical, 8)
146188 .background(.bar)
apple/project.yml+7−1
@@ -29,8 +29,14 @@ targets:
2929 - UIInterfaceOrientationPortrait
3030 NSCameraUsageDescription: Scan a contact's QR code to add them.
3131 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.
3234 NSAppTransportSecurity:
33− NSAllowsArbitraryLoads: true
35+ NSAllowsLocalNetworking: true
36+ NSExceptionDomains:
37+ localhost:
38+ NSExceptionAllowsInsecureHTTPLoads: true
39+ NSIncludesSubdomains: true
3440 dependencies:
3541 - package: Shared
3642 product: SkrepkaShared
core/Cargo.toml−1
@@ -45,7 +45,6 @@ zstd.workspace = true
4545rand_core.workspace = true
4646uuid.workspace = true
4747hex.workspace = true
48−base64.workspace = true
4948
5049# optional, only for the codegen bin
5150anyhow = { workspace = true, optional = true }
core/src/app.rs+183−15
@@ -140,9 +140,11 @@ pub enum ConnStatus {
140140 Online,
141141}
142142
143−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
143+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
144144pub 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]
146148 Conversations,
147149 Chat,
148150 AddContact,
@@ -162,6 +164,9 @@ pub struct Model {
162164 token: Option<String>,
163165 conn: ConnStatus,
164166 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,
165170 flushing: bool,
166171 page: Page,
167172 active_peer: Option<String>,
@@ -183,8 +188,9 @@ impl Default for Model {
183188 token: None,
184189 conn: ConnStatus::Offline,
185190 poll_retries: 0,
191+ polling: false,
186192 flushing: false,
187− page: Page::Setup,
193+ page: Page::default(),
188194 active_peer: None,
189195 compose: String::new(),
190196 error: None,
@@ -220,6 +226,30 @@ fn server_host(url: &str) -> String {
220226 host.trim_end_matches('.').to_lowercase()
221227}
222228
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+
223253fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
224254 serde_json::to_vec(value).unwrap_or_default()
225255}
@@ -391,6 +421,11 @@ impl App for Skrepka {
391421 render()
392422 }
393423 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;
394429 model.settings.server_url = url;
395430 model.token = None;
396431 model.conn = ConnStatus::Offline;
@@ -484,7 +519,11 @@ impl App for Skrepka {
484519 };
485520 match Http::post(url).body_json(&body) {
486521 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+ }
488527 }
489528 }
490529 Event::ChallengeResult(Ok(mut resp)) => {
@@ -505,7 +544,11 @@ impl App for Skrepka {
505544 };
506545 match Http::post(url).body_json(&body) {
507546 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+ }
509552 }
510553 }
511554 Err(_) => {
@@ -543,6 +586,11 @@ impl App for Skrepka {
543586
544587 // ---------------- poll ----------------
545588 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+ }
546594 let Some(token) = model.token.clone() else {
547595 return Command::event(Event::Authenticate);
548596 };
@@ -554,11 +602,18 @@ impl App for Skrepka {
554602 .header("authorization", format!("Bearer {token}"))
555603 .body_json(&body)
556604 {
557− Ok(req) => req.build().then_send(Event::PollResult),
558− Err(_) => render(),
605+ Ok(req) => {
606+ model.polling = true;
607+ req.build().then_send(Event::PollResult)
608+ }
609+ Err(_) => {
610+ model.error = Some("could not build poll request".into());
611+ self.backoff_poll(model)
612+ }
559613 }
560614 }
561615 Event::PollResult(Ok(mut resp)) => {
616+ model.polling = false;
562617 let status = u16::from(resp.status());
563618 if status == 401 {
564619 model.token = None;
@@ -576,15 +631,20 @@ impl App for Skrepka {
576631 .and(Command::event(Event::Poll))
577632 .and(render())
578633 }
579− Event::PollResult(Err(_)) => self.backoff_poll(model),
634+ Event::PollResult(Err(_)) => {
635+ model.polling = false;
636+ self.backoff_poll(model)
637+ }
580638
581639 // ---------------- outbox ----------------
582640 Event::StartFlush => self.flush_next(model),
583641 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;
584645 let status = u16::from(resp.status());
585646 if status == 401 {
586647 model.token = None;
587− model.flushing = false;
588648 return Command::event(Event::Authenticate);
589649 }
590650 if (200..300).contains(&status) || status == 400 {
@@ -593,7 +653,6 @@ impl App for Skrepka {
593653 return self.persist_outbox(model).and(self.flush_next(model));
594654 }
595655 // transient error — stop; will resume on next connect/poll.
596− model.flushing = false;
597656 render()
598657 }
599658 Event::SendResult(Err(_)) => {
@@ -605,7 +664,6 @@ impl App for Skrepka {
605664
606665 fn view(&self, model: &Model) -> ViewModel {
607666 let page = match model.page {
608− Page::Setup => "setup",
609667 Page::Conversations => "conversations",
610668 Page::Chat => "chat",
611669 Page::AddContact => "add_contact",
@@ -817,7 +875,10 @@ impl Skrepka {
817875 {
818876 Ok(req) => req.build().then_send(Event::SendResult),
819877 Err(_) => {
878+ // Not a dead loop: the next StartFlush (from a poll page or a
879+ // fresh send) retries the head of the outbox.
820880 model.flushing = false;
881+ model.error = Some("could not build send request".into());
821882 render()
822883 }
823884 }
@@ -950,10 +1011,11 @@ mod tests {
9501011
9511012 fn with_identity() -> Model {
9521013 let id = Identity::from_seed(&[3u8; 32]);
953− let mut m = Model::default();
954− m.secret_key = Some(id.secret_key.to_vec());
955− m.my_pubkey = id.public_key_hex();
956− m
1014+ Model {
1015+ secret_key: Some(id.secret_key.to_vec()),
1016+ my_pubkey: id.public_key_hex(),
1017+ ..Default::default()
1018+ }
9571019 }
9581020
9591021 fn peer_hex(seed: u8) -> String {
@@ -1058,6 +1120,112 @@ mod tests {
10581120 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
10591121 }
10601122
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+
10611229 #[test]
10621230 fn view_exposes_identity_and_ob() {
10631231 let app = Skrepka;
core/src/model.rs+1−1
@@ -153,7 +153,7 @@ pub struct MessageVM {
153153
154154#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
155155pub struct ViewModel {
156− /// "setup" | "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
156+ /// "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
157157 pub page: String,
158158 pub has_identity: bool,
159159 pub my_pubkey_hex: String,
core/src/phonemic.rs+26−2
@@ -72,14 +72,18 @@ pub fn to_ob(bytes: &[u8]) -> String {
7272}
7373
7474/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
75+/// Input is case-insensitive; the syllable tables are lowercase.
7576pub fn from_ob(s: &str) -> Option<Vec<u8>> {
76− let parts: Vec<&str> = s.split('-').filter(|p| !p.is_empty()).collect();
77+ let lowered = s.to_lowercase();
78+ let parts: Vec<&str> = lowered.split('-').filter(|p| !p.is_empty()).collect();
7779 if parts.is_empty() {
7880 return None;
7981 }
8082 let mut out = Vec::with_capacity(parts.len() * 2);
8183 for part in parts {
82− if part.len() != 6 {
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.
86+ if part.len() != 6 || !part.is_ascii() {
8387 return None;
8488 }
8589 let hi = prefix_index(&part[0..3])?;
@@ -150,4 +154,24 @@ mod tests {
150154 assert_eq!(try_parse_pubkey("not-a-key"), None);
151155 assert_eq!(try_parse_pubkey(""), None);
152156 }
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+ }
153177}
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..."
4545
4646DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
4747
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+
4855if command -v curl >/dev/null 2>&1; then
49− curl -fSL "$DOWNLOAD_URL" -o "$INSTALL_BIN"
56+ curl -fSL "$DOWNLOAD_URL" -o "$TMP_BIN"
5057elif command -v wget >/dev/null 2>&1; then
51− wget -q "$DOWNLOAD_URL" -O "$INSTALL_BIN"
58+ wget -q "$DOWNLOAD_URL" -O "$TMP_BIN"
5259else
5360 echo "error: curl or wget required" >&2
5461 exit 1
5562fi
5663
57−chmod 755 "$INSTALL_BIN"
64+chmod 755 "$TMP_BIN"
65+mv -f "$TMP_BIN" "$INSTALL_BIN"
5866echo "Installed binary to ${INSTALL_BIN}"
5967
6068case "$PLATFORM" in
6169 linux-*)
62− cat > "$UNIT_DEST" <<'EOF'
70+ cat > "$UNIT_DEST" <<EOF
6371[Unit]
6472Description=Skrepka relay server
6573After=network-online.target
@@ -67,10 +75,14 @@ Wants=network-online.target
6775
6876[Service]
6977Type=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.
7082# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
7183# envelope, otherwise the runtime's default 16 MiB body cap rejects a
7284# 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
7486Restart=on-failure
7587RestartSec=5
7688
server.knot+47−19
@@ -116,10 +116,18 @@ pollTimeoutMs = 25000
116116-- share this window; only the per-window request budget varies per endpoint.
117117rateLimitWindow : Int<Ms>
118118rateLimitWindow = 60000
119−-- Per-pubkey budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
120−-- The normal handshake is one challenge + one verify, so a healthy flow uses 2.
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;
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.)
121129authRateLimit : Int
122−authRateLimit = 10
130+authRateLimit = 60
123131-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect
124132-- after `pollTimeoutMs`, so steady state is ~3/min per client; the budget
125133-- 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}
275283-- ============================================
276284-- Rate limiting
277285-- ============================================
278−-- Per-endpoint token-bucket configuration. Auth endpoints key on the submitted
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
281−-- proxy, else the connection address). The bearer token is deliberately not a
282−-- bucket key: the limiter runs before the handler, so it can't tell valid
283−-- sessions from invented ones, and an attacker varying the token would mint a
284−-- fresh bucket per value. Each route has an independent bucket store, so endpoints
285−-- using the same key strategy don't share budget across routes. Buckets persist
286−-- in the runtime's `_knot_rate_limits` SQLite table; rejection responds 429
287−-- before the handler runs.
286+-- Per-endpoint token-bucket configuration. Every endpoint keys on the client IP
287+-- (last X-Forwarded-For hop behind a trusted proxy, else the connection
288+-- address). Nothing the caller can mint for free is a bucket key: the limiter
289+-- runs before the handler, so it cannot tell a valid session from an invented
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
292+-- (any keypair) alike — see `authRateLimit`. Each route has an independent
293+-- bucket store, so endpoints using the same key strategy don't share budget
294+-- across routes. Buckets persist in the runtime's `_knot_rate_limits` SQLite
295+-- table; rejection responds 429 before the handler runs.
288296
289297-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
290298-- comma-separated chain that each proxy appends to; only the final entry —
@@ -304,10 +312,9 @@ pickClientIp = \h connIp -> case h of
304312 else connIp
305313 Nothing {} -> connIp
306314
307−byPubkey = \{pubkey} _ -> Just {value: pubkey}
308315byClientIp = \_ ctx -> Just {value: pickClientIp (ctx.header "X-Forwarded-For") ctx.clientIp}
309316
310−authLimit = {key: byPubkey, limit: {requests: authRateLimit, window: rateLimitWindow}}
317+authLimit = {key: byClientIp, limit: {requests: authRateLimit, window: rateLimitWindow}}
311318pollLimit = {key: byClientIp, limit: {requests: pollRateLimit, window: rateLimitWindow}}
312319sendLimit = {key: byClientIp, limit: {requests: sendRateLimit, window: rateLimitWindow}}
313320gossipLimit = {key: byClientIp, limit: {requests: gossipRateLimit, window: rateLimitWindow}}
@@ -483,7 +490,22 @@ anyOctetLeadingZero = \host -> do
483490 let rest = afterDot host
484491 (length octet > 1 && take 1 octet == "0") || (rest != "" && anyOctetLeadingZero rest)
485492
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
487509-- tricks but covers the high-value cases: `localhost`, RFC1918 ranges (10/8,
488510-- 172.16/12, 192.168/16) plus link-local
489511-- 169.254/16, the IPv4 zero net, CGNAT 100.64/10, benchmarking 198.18/15,
@@ -798,7 +820,7 @@ forwardQueuedToServer = \pubkey server -> atomic (do
798820-- revoke/logout.
799821
800822-- 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
802824-- by the route's `rateLimit` clause. The IP is recorded so /auth/verify can
803825-- require the same client.
804826handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
@@ -924,12 +946,18 @@ doPoll = \s t -> do
924946 -- `race` requires both arms to share a single effect row, so each arm
925947 -- carries the union {r *messages, clock} — the STM wait gets a `now`,
926948 -- 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.
927955 race
928956 (do
929− _ <- now
957+ tw <- now
930958 atomic (do
931959 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
933961 if count fresh > 0 then yield {} else retry))
934962 (do
935963 atomic (do _ <- *messages; yield {})
skrepka.service+9−1
@@ -5,7 +5,15 @@ Wants=network-online.target
55
66[Service]
77Type=simple
8−ExecStart=/usr/local/bin/skrepka-server
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.
16+ExecStart=/usr/local/bin/skrepka-server --serverHost=relay.example.com --http-max-body-bytes=42M
917Restart=on-failure
1018RestartSec=5
1119