on master help sign in
Comprehensive bug fix pass: crypto, state machine, FFI, server, Apple shell

Rust core (13 fixes): - Bound zstd decompression to 1 MiB + MAX_BLOB_LEN guard (DoS prevention) - Reset poll cursor on relay switch (prevents mail destruction) - Rewrite normalize_server_url using real Url::parse (prevents crash) - Re-validate persisted server_url on load (prevents boot crash loop) - Add #[serde(default)] on all persisted types (schema evolution safety) - Hoist blocked check to top of ingest_poll (privacy: blocked peers can no longer update profiles, ack messages, or receive broadcasts) - Add size limits on incoming payload fields (body/display_name/bio/photo) - Cap contacts at 500 + don't auto-create from bare Profile (DoS prevention) - Verify seed↔pubkey consistency in Identity::from_secret_bytes - Add authenticating guard to prevent auth storms - Chain re-poll off cursor write (prevents data loss window) - Gate Connect on all 5 startup loads completing (prevents state clobber) - Optimize view() to skip photos for conversations list (per-keystroke perf) - catch_unwind in FFI (prevents panic across extern C boundary) - Add poll watchdog timer (prevents polling wedge) - zeroize all key material (Identity, Model.secret_key, ephemeral keys) - Bound auth challenge length before signing - Fix to_ob non-injectivity on odd-length input (return Option) - Fix server_host for IPv6 literals (use Url::host_str) - Add InvalidPublicKey/Encrypt/ChallengeTooLong CryptoError variants - Skip serializing null photo in Profile payload - Fix flush_next O(n²) write pattern - Mark Authenticate/Poll/StartFlush as internal (remove from FFI surface) - Clear model.error on all navigation events - x25519 contributory check (low-order point defense) Apple shell (20 fixes): - Fix Keychain read failure silently destroying identity (return Result, never generate on ambiguous read; use SecItemUpdate over delete-then-add) - Move KV I/O to background queue + drop wasteful read-back - Surface KV errors instead of swallowing (prevent silent data loss) - Fix avatar downscale producing 512-768px instead of 256px (set scale=1) - Rewrite PhotoPicker with ImageIO downsampling (prevent OOM from full decode); add onCancel to fix picker permanently breaking on cancel - Cache decoded avatars in NSCache with async off-main-thread decode - Implement timer cancellation with DispatchWorkItem (prevent latent crash) - Add data protection + backup exclusion for plaintext message history - Switch to ephemeral URLSession (prevent relay cookie tracking) - Cache DateFormatter and CIContext (prevent per-render allocation) - Add error row to SettingsView (invalid URL was silently ignored) - Show identity error instead of spinning forever on Keychain failure - Scroll chat to newest message on appear - Reconnect on scenePhase .active (fast foreground recovery) - Locale-aware formatStamp instead of hardcoded HH:mm - Fix incoming bubble alignment (trailing→leading for incoming) - Reset Copied! state + add pasteboard expiration - Strip URI prefixes from QR scan results - Percent-encode KV file URLs (injective mapping, prevent collision) - Fix swiftlint concurrency violation in Time.handle - Add ITSAppUsesNonExemptEncryption, TARGETED_DEVICE_FAMILY=1 - Make just run depend on just build + validate SIM_ID Server (15 fixes): - Bind backend to 127.0.0.1 in install.sh + firewall 8080 (prevent X-Forwarded-For spoofing bypassing all rate limits) - Add seedPeers federation bootstrap mechanism (mesh can now form) - Default federationEnabled=False (secure by default) - Add startup warning for invalid serverHost - Normalize/validate DOMAIN in install.sh (prevent auth breakage) - Add SHA-256 checksum verification of downloaded binary - Add CI smoke test job (8 endpoint assertions) - Verify static linking in CI (Linux) - Fix isBadServerName trailing-dot bypass (localhost. SSRF bypass) - Add authHost for IPv6 literals in auth signature binding - Add minBlobLen (336 chars = 168 bytes) enforcement - Fix per-peer failure counter inflation (one per sweep, not per message) - Fix equal-timestamp tiebreaker in recordFailure/clearFailure - Snapshot peers before prune (fix offline gossip to expired peers) - Fix macos-26→macos-14 CI runner label - Add set -o pipefail to install.sh PROTOCOL.md: - Document @p encoding tables and worked example - Add compression-before-encryption leak note - Describe seedPeers bootstrap mechanism - Correct forward secrecy wording (deletion property, not PCS) 75 tests pass (was 27, +48 new). Clippy clean. Server compiles and passes smoke test. FFI typegen verified.

commit5477c474a822fea8464a24ae5de15b34a52a28b7 authoriko <mail@iko.soy> dateSat 11 Jul 2026, 18:32 (3 months ago) parent9558212
23 files changed+2730−414
.github/workflows/build.yml+111−1 .gitignore+3 Cargo.lock+2 Cargo.toml+15 Justfile+3−2 PROTOCOL.md+42−5 apple/SkrepkaApp/Core.swift+23−6 apple/SkrepkaApp/Effects.swift+226−62 apple/SkrepkaApp/Forms.swift+41−8 apple/SkrepkaApp/PhotoPicker.swift+34−19 apple/SkrepkaApp/QRView.swift+21−1 apple/SkrepkaApp/SkrepkaApp.swift+8 apple/SkrepkaApp/Views.swift+101−16 apple/project.yml+16 core/Cargo.toml+2 core/src/app.rs+1199−153 core/src/crypto.rs+231−29 core/src/ffi.rs+44−15 core/src/model.rs+22−8 core/src/phonemic.rs+35−18 core/src/protocol.rs+129−10 install.sh+129−9 server.knot+293−52
.github/workflows/build.yml+111−1
@@ -76,7 +76,10 @@ jobs:
7676 - runner: ubuntu-24.04-arm
7777 name: linux-arm64
7878 knot-platform: linux-aarch64
79− - runner: macos-26
79+ # macos-14 is the oldest GA arm64 image; the iOS job above already runs on
80+ # it. (`macos-26` is not a provisioned runner label — a job requesting it
81+ # queues until it times out rather than failing fast.)
82+ - runner: macos-14
8083 name: macos-arm64
8184 knot-platform: macos-aarch64
8285 runs-on: ${{ matrix.runner }}
@@ -111,6 +114,99 @@ jobs:
111114 - name: Verify binary
112115 run: file server
113116
117+ # The Linux release binary is what install.sh drops onto a bare host, so it
118+ # has to carry its own libc. A dynamically-linked build would still pass every
119+ # other step here and only fail on the operator's machine, at startup.
120+ - name: Verify static linking (Linux)
121+ if: runner.os == 'Linux'
122+ run: |
123+ file server | grep -q "statically linked" || {
124+ echo "::error::release binary is not statically linked"
125+ file server
126+ exit 1
127+ }
128+
129+ # End-to-end smoke test: the compile steps above only prove the server
130+ # typechecks, never that it serves. This boots the real binary and asserts the
131+ # status code of every route, so a regression that breaks auth, validation, or
132+ # the federation gate fails CI instead of shipping.
133+ #
134+ # The server binds 0.0.0.0:8080 and cannot be moved: the Knot runtime accepts
135+ # but ignores --<name>=<value> constant overrides at startup (they only apply
136+ # at `knot build` time), so --listenPort/--bindHost would be silently inert.
137+ # Everything below therefore runs against the compiled-in default port, and
138+ # against the compiled-in default federationEnabled=False.
139+ - name: Smoke test
140+ run: |
141+ set -euo pipefail
142+ ./server > /tmp/server.log 2>&1 &
143+ SERVER_PID=$!
144+ trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
145+
146+ # Wait for the listener rather than sleeping a fixed amount.
147+ for i in $(seq 1 30); do
148+ if curl -sf -o /dev/null "http://127.0.0.1:8080/" 2>/dev/null \
149+ || curl -s -o /dev/null "http://127.0.0.1:8080/"; then
150+ break
151+ fi
152+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
153+ echo "::error::server exited during startup"; cat /tmp/server.log; exit 1
154+ fi
155+ sleep 1
156+ done
157+
158+ PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
159+ SIG=$(printf '0%.0s' $(seq 1 128)) # 128 hex chars: a well-formed signature
160+ BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 336-char floor
161+
162+ # $1 = expected status, $2 = label, rest = curl args
163+ expect() {
164+ want="$1"; label="$2"; shift 2
165+ got=$(curl -s -o /tmp/body -w '%{http_code}' --max-time 10 "$@")
166+ if [ "$got" != "$want" ]; then
167+ echo "::error::${label}: expected HTTP ${want}, got ${got}"
168+ cat /tmp/body; echo; cat /tmp/server.log
169+ exit 1
170+ fi
171+ echo "ok ${label} -> ${got}"
172+ }
173+ J='Content-Type: application/json'
174+
175+ expect 200 "auth/challenge (valid pubkey)" \
176+ -X POST http://127.0.0.1:8080/auth/challenge -H "$J" -d "{\"pubkey\":\"$PK\"}"
177+ grep -q '"challenge"' /tmp/body || { echo "::error::no challenge in body"; exit 1; }
178+
179+ expect 400 "auth/challenge (malformed pubkey rejected)" \
180+ -X POST http://127.0.0.1:8080/auth/challenge -H "$J" -d '{"pubkey":"zz"}'
181+
182+ expect 401 "auth/verify (bad signature)" \
183+ -X POST http://127.0.0.1:8080/auth/verify -H "$J" \
184+ -d "{\"pubkey\":\"$PK\",\"challenge\":\"x\",\"signature\":\"$SIG\",\"revokeOthers\":false}"
185+
186+ expect 401 "poll (unauthenticated)" \
187+ -X POST http://127.0.0.1:8080/poll -H "$J" -H 'Authorization: Bearer nope' -d '{"cursor":0}'
188+
189+ expect 401 "messages (unauthenticated)" \
190+ -X POST http://127.0.0.1:8080/messages -H "$J" -H 'Authorization: Bearer nope' \
191+ -d "{\"messages\":[{\"to\":\"$PK\",\"encryptedBlob\":\"$BLOB\"}]}"
192+
193+ # federationEnabled defaults to False, so both peer endpoints must refuse.
194+ expect 403 "federation/gossip (federation disabled by default)" \
195+ -X POST http://127.0.0.1:8080/federation/gossip -H "$J" \
196+ -d '{"events":[],"fromServer":"relay.example.com"}'
197+
198+ expect 403 "federation/forward (federation disabled by default)" \
199+ -X POST http://127.0.0.1:8080/federation/forward -H "$J" \
200+ -d "{\"toKey\":\"$PK\",\"encryptedBlob\":\"$BLOB\"}"
201+
202+ # Below the 336-hex-char (168-byte) minimum envelope: rejected by BlobHex
203+ # at the route boundary, before the federation gate.
204+ expect 400 "federation/forward (undersized blob rejected)" \
205+ -X POST http://127.0.0.1:8080/federation/forward -H "$J" \
206+ -d "{\"toKey\":\"$PK\",\"encryptedBlob\":\"abcd\"}"
207+
208+ echo "smoke test passed"
209+
114210 - uses: actions/upload-artifact@v4
115211 with:
116212 name: server-${{ matrix.name }}
@@ -151,6 +247,20 @@ jobs:
151247 done
152248 chmod +x server-*
153249
250+ # Publish a SHA-256 beside each binary. install.sh downloads
251+ # "<asset>.sha256" and refuses to install a binary that does not match it,
252+ # so this is the integrity link between what CI built and what lands on an
253+ # operator's host as root. Hashing the *final* asset names (rather than the
254+ # in-artifact "server") means a downloaded pair also verifies with a plain
255+ # `sha256sum -c server-linux-x86_64.sha256`.
256+ #
257+ # The glob is expanded before the loop body runs, so `bin` only ever
258+ # iterates the binaries, never the .sha256 files being created.
259+ for bin in server-*; do
260+ sha256sum "$bin" > "${bin}.sha256"
261+ done
262+ ls -l server-*
263+
154264 - uses: softprops/action-gh-release@v2
155265 with:
156266 tag_name: ${{ steps.version.outputs.tag }}
.gitignore+3
@@ -15,3 +15,6 @@ apple/SkrepkaApp.xcodeproj
1515apple/build
1616apple/generated
1717apple/DerivedData
18+# xcodegen writes both of these from project.yml — edit that, not these.
19+apple/SkrepkaApp/Info-iOS.plist
20+apple/SkrepkaApp/SkrepkaApp.entitlements
Cargo.lock+2
@@ -1816,8 +1816,10 @@ dependencies = [
18161816 "serde",
18171817 "serde_json",
18181818 "sha2",
1819+ "url",
18191820 "uuid",
18201821 "x25519-dalek",
1822+ "zeroize",
18211823 "zstd",
18221824]
18231825
Cargo.toml+15
@@ -2,6 +2,19 @@
22members = ["core"]
33resolver = "2"
44
5+# The curve arithmetic is ~100x slower unoptimized, and the tests do real
6+# signing and real X25519 — a poll page of a thousand messages is minutes of
7+# debug-mode scalar mults. Optimizing just these crates keeps `cargo test` in
8+# seconds while everything we actually debug stays at opt-level 0.
9+[profile.dev.package.curve25519-dalek]
10+opt-level = 3
11+[profile.dev.package.ed25519-dalek]
12+opt-level = 3
13+[profile.dev.package.x25519-dalek]
14+opt-level = 3
15+[profile.dev.package.zstd-sys]
16+opt-level = 3
17+
518[workspace.package]
619edition = "2021"
720rust-version = "1.90"
@@ -30,6 +43,8 @@ rand_core = { version = "0.6", features = ["getrandom"] }
3043rand_chacha = "0.3"
3144uuid = { version = "1", features = ["v4"] }
3245hex = "0.4"
46+zeroize = { version = "1", features = ["derive"] }
47+url = "2"
3348
3449# codegen bin
3550anyhow = "1.0"
Justfile+3−2
@@ -32,12 +32,13 @@ generate: typegen pack project
3232
3333# Build the iOS app for a booted simulator (set SIM_ID, or pass a destination).
3434build sim=sim_id:
35+ @test -n "{{sim}}" || ( echo "error: no simulator. Set SIM_ID=<udid> or run: just build <udid>"; echo " list booted devices with: xcrun simctl list devices booted"; exit 1 )
3536 cd apple && xcodebuild -project SkrepkaApp.xcodeproj -scheme SkrepkaApp-iOS \
3637 -destination 'platform=iOS Simulator,id={{sim}}' \
3738 -derivedDataPath build -skipPackagePluginValidation build
3839
39−# Install + launch on a booted simulator.
40−run sim=sim_id:
40+# Build, then install + launch on a booted simulator.
41+run sim=sim_id: (build sim)
4142 cd apple && xcrun simctl install {{sim}} \
4243 build/Build/Products/Debug-iphonesimulator/SkrepkaApp-iOS.app
4344 xcrun simctl launch {{sim}} {{bundle}}
PROTOCOL.md+42−5
@@ -47,6 +47,23 @@ Identities are shared out-of-band as plain text or QR codes. The client displays
4747- **Hex** — 64 lowercase hex characters (the canonical wire form).
4848- **@p (phonetic)** — the public key rendered as Urbit-style syllables, hyphen-joined with no leading prefix (e.g. `ridler-binzod-marbud-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
4949
50+#### @p Encoding
51+
52+@p is a display encoding only — it never appears on the wire and carries no information the hex form does not. It is specified here so an independent client renders the *same* syllables, since users compare them aloud to verify keys: a client with a divergent table would show a different name for the same identity and defeat out-of-band verification.
53+
54+The 32-byte key is encoded **byte-pair by byte-pair, most-significant first**. Each pair `(hi, lo)` becomes one four-letter word `PREFIX[hi] ++ SUFFIX[lo]`, and the 16 words are joined with `-`. Both tables hold 256 three-letter syllables each and are **identical to Urbit's** (`po.hoon`); the reference copies live in [`core/src/phonemic.rs`](core/src/phonemic.rs). Neither table contains duplicates, so decoding is an unambiguous reverse lookup, and a 32-byte key always yields exactly 16 words.
55+
56+Worked example — the key beginning `0x00 0x00 0x01 0x01 ...`:
57+
58+| byte pair | prefix | suffix | word |
59+|-----------|--------|--------|----------|
60+| `00 00` | `doz` | `zod` | `dozzod` |
61+| `01 01` | `mar` | `nec` | `marnec` |
62+
63+Note the prefix at index **124** is **`nis`** (…`ran`, `nis`, `wol`, `mis`…), matching `po.hoon`; it is not `nic`, which is a common transcription error.
64+
65+Clients accept either form when adding a contact; hex remains canonical everywhere else.
66+
5067Servers are addressed separately. A client stores a single server URL (e.g. `https://relay.example.com`) in local settings; this is purely a transport choice and has nothing to do with identity. Two users on different servers can still talk via federation.
5168
5269### Trust Model
@@ -147,7 +164,13 @@ signature (64 bytes)
147164compressed_plaintext (variable, zstd)
148165```
149166
150−The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes.
167+The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes. A relay rejects any blob shorter than that as malformed (`400`), since it cannot possibly carry an envelope.
168+
169+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: the blob is exactly `72 + len(compressed)` bytes, and that number is visible to every relay and network observer.
170+>
171+> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
172+>
173+> Skrepka accepts this: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). A future revision should add length padding (e.g. bucket blobs to fixed sizes), which is the only real mitigation.
151174
152175### Cryptographic Versioning
153176
@@ -268,7 +291,7 @@ Skrepka has no central directory. Clients are configured with a single server UR
268291]
269292```
270293
271−This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list.
294+This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list: a relay reaches the mesh by being given **seed peers** (`--seedPeers=host1,host2`), which it gossips to unconditionally until presence gossip teaches it the rest of the mesh. A relay with no seeds and no inbound gossip stays isolated — clients on it can still message each other, but not users on other relays. See §7 for the bootstrap mechanism.
272295
273296### Content Type
274297
@@ -429,7 +452,21 @@ The following endpoints exist in earlier drafts of this spec but are **not** imp
429452
430453### Mesh Formation
431454
432−Federation is open. A server joins the mesh by being configured (at build/start time) with its own public hostname; peers learn of each other through gossip and through messages flowing across them. There is no central registry.
455+Federation is open and **off by default** (`federationEnabled = False`); an operator opts in explicitly. There is no central registry.
456+
457+A server joins the mesh in two steps:
458+
459+1. **Identity.** It is configured with its own public hostname (`serverHost`). This is the name it puts in the `fromServer` field of every gossip it sends, and the name peers must be able to resolve to reach it. It must pass the deny-list below, so the default `localhost` cannot federate.
460+2. **Bootstrap.** It is configured with one or more **seed peers** (`seedPeers`, a comma-separated host list). These are gossiped to unconditionally, alongside any peers already known from presence.
461+
462+Seeding is what makes the mesh formable at all. A relay otherwise learns of a peer *only* by receiving gossip from it — presence rows remember the server that announced each key — so two fresh relays that have never heard of each other both begin with an empty gossip target set and neither ever speaks first. Seeding **one** side of a pair is sufficient: the first gossip carries `fromServer`, so the seed learns of the sender and gossips back from then on. Seed peers are gossip *targets* only; they are not written into the presence table (a presence row is keyed by the pubkey it announces, and a seed is just a hostname), so a seed that never answers costs nothing but a periodic failed request under the standard per-peer backoff.
463+
464+```sh
465+# relay A, seeded with relay B — B needs no seed of its own to reach A
466+skrepka-server --serverHost=a.example.com --federationEnabled=true --seedPeers=b.example.com
467+```
468+
469+> **Implementation note (reference server).** `serverHost`, `federationEnabled` and `seedPeers` are configuration, and the reference relay exposes them as `--name=value` settings. As of Knot `2026.6.26.1947` the runtime *accepts but silently ignores* these flags at startup — they currently take effect only when passed to `knot build` — so a relay configured purely on the command line will run with the compiled-in defaults (`localhost`, federation off, no seeds) and quietly fail to federate. Verify with the startup log, which prints the federation state and seed list it actually loaded.
433470
434471Operators may apply an inbound blocklist; outbound federation is filtered through a built-in **SSRF deny-list** that rejects:
435472
@@ -585,13 +622,13 @@ Skrepka protects message **content and sender identity** from servers and networ
585622| Sender authenticity | Ed25519 signature on every message |
586623| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
587624| Replay protection (cross-recipient) | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a *different* recipient. This does **not** stop re-delivery of the same blob to the *same* recipient — see *Same-recipient replay* below |
588−| Partial post-compromise | Ephemeral X25519 keys per message — once the blob is no longer stored anywhere, even compromising the recipient's long-term key does not recover the message |
625+| Deletion-dependent recovery (**not** post-compromise security) | Each message uses a fresh ephemeral X25519 key, so there is no shared long-term session secret to steal. But this buys **nothing cryptographic** against a later key compromise: the recipient's long-term key still opens *any* blob an attacker has kept a copy of. The only thing protecting an old message is that the ciphertext no longer exists — relays delete on ack/TTL, and clients delete on request. That is a *deletion* property, not a *forward secrecy* property, and it fails completely against anyone who recorded the traffic. See *No forward secrecy* below, which is the same fact stated as a risk. |
589626
590627### What Is NOT Protected
591628
592629| Risk | Details |
593630|-----------------------------------|-----------------------------------------------------|
594−| **No forward secrecy** | If a message blob is captured *and* the recipient's long-term X25519 key is later compromised, that specific message can be decrypted. No ratchet means no post-compromise recovery. |
631+| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. |
595632| **Recipient metadata exposure** | Servers see the recipient pubkey and arrival time of every message, plus the rough size of each blob. They do **not** see the sender. |
596633| **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. |
597634| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
apple/SkrepkaApp/Core.swift+23−6
@@ -8,6 +8,11 @@ import SkrepkaShared
88final class Core: ObservableObject {
99 @Published var view: ViewModel
1010
11+ /// A Keychain failure never reaches the core — without an identity there is no
12+ /// `IdentityLoaded` event to carry it — so the shell holds it. `RootView` shows this
13+ /// instead of spinning on a progress view forever.
14+ @Published private(set) var identityError: String?
15+
1116 private let core = CoreFFI()
1217 private let store = KvStore()
1318
@@ -53,9 +58,13 @@ final class Core: ObservableObject {
5358 }
5459
5560 case .keyValue(let op):
56− let result = store.handle(op)
57− // swiftlint:disable:next force_try
58− resolve(id, try! result.bincodeSerialize())
61+ // The file I/O runs on the store's own serial queue; hop the result back to
62+ // the main actor, because the core is single-threaded and `.render` publishes.
63+ store.handle(op) { [weak self] result in
64+ // swiftlint:disable:next force_try
65+ let output = try! result.bincodeSerialize()
66+ Task { @MainActor in self?.resolve(id, output) }
67+ }
5968
6069 case .time(let req):
6170 Time.handle(req) { [weak self] response in
@@ -67,8 +76,16 @@ final class Core: ObservableObject {
6776
6877 // MARK: - Identity
6978
70− private func bootIdentity() {
71− let key = Keychain.loadOrCreateIdentity()
72− update(.identityLoaded([UInt8](key)))
79+ /// Loads the identity key, or reports why it could not. Safe to call again — that is
80+ /// what the retry affordance on the error screen does, and the common failure (the
81+ /// device has not been unlocked since boot) resolves on its own.
82+ func bootIdentity() {
83+ switch Keychain.loadOrCreateIdentity() {
84+ case .success(let key):
85+ identityError = nil
86+ update(.identityLoaded([UInt8](key)))
87+ case .failure(let error):
88+ identityError = error.message
89+ }
7390 }
7491}
apple/SkrepkaApp/Effects.swift+226−62
@@ -6,6 +6,22 @@ import Skrepka
66// MARK: - HTTP
77
88enum Http {
9+ /// A private, ephemeral session. `URLSession.shared` would hand every relay a
10+ /// persistent cookie jar and an on-disk response cache — a relay is untrusted
11+ /// infrastructure and must not be able to pin a stable identifier on the client
12+ /// or leave polled ciphertext lying around on disk.
13+ private static let session: URLSession = {
14+ let config = URLSessionConfiguration.ephemeral
15+ config.httpCookieAcceptPolicy = .never
16+ config.httpShouldSetCookies = false
17+ config.httpCookieStorage = nil
18+ config.urlCache = nil
19+ config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
20+ config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
21+ config.tlsMinimumSupportedProtocolVersion = .TLSv12
22+ return URLSession(configuration: config)
23+ }()
24+
925 static func perform(_ req: HttpRequest) async -> HttpResult {
1026 guard let url = URL(string: req.url) else {
1127 return .err(.url(req.url))
@@ -20,11 +36,11 @@ enum Http {
2036 request.httpBody = Data(req.body)
2137 }
2238 do {
23− let (data, response) = try await URLSession.shared.data(for: request)
24− let status = (response as? HTTPURLResponse)?.statusCode ?? 0
39+ let (data, response) = try await session.data(for: request)
40+ let http = response as? HTTPURLResponse
2541 return .ok(HttpResponse(
26− status: UInt16(clamping: status),
27− headers: [],
42+ status: UInt16(clamping: http?.statusCode ?? 0),
43+ headers: headers(of: http),
2844 body: [UInt8](data)
2945 ))
3046 } catch let error as URLError where error.code == .timedOut {
@@ -33,12 +49,27 @@ enum Http {
3349 return .err(.io(error.localizedDescription))
3450 }
3551 }
52+
53+ /// HTTP header names are case-insensitive; the core matches them lowercased.
54+ private static func headers(of response: HTTPURLResponse?) -> [HttpHeader] {
55+ guard let response else { return [] }
56+ return response.allHeaderFields.compactMap { (name, value) -> HttpHeader? in
57+ guard let name = name as? String, let value = value as? String else { return nil }
58+ return HttpHeader(name: name.lowercased(), value: value)
59+ }
60+ }
3661}
3762
3863// MARK: - Timer
3964
65+@MainActor
4066enum Time {
41− static func handle(_ req: TimeRequest, resolve: @escaping (TimeResponse) -> Void) {
67+ /// Live timers, so `.clear` can actually cancel one. Resolving a crux effect twice
68+ /// (once from `.clear`, once from the work item that was never cancelled) traps in
69+ /// the core, so a dangling work item is a latent crash, not just wasted work.
70+ private static var timers: [TimerId: DispatchWorkItem] = [:]
71+
72+ static func handle(_ req: TimeRequest, resolve: @MainActor @escaping (TimeResponse) -> Void) {
4273 switch req {
4374 case .now:
4475 let now = Date().timeIntervalSince1970
@@ -47,64 +78,159 @@ enum Time {
4778 resolve(.now(instant: Instant(seconds: seconds, nanos: nanos)))
4879 case .notifyAfter(let id, let duration):
4980 let seconds = Double(duration.nanos) / 1_000_000_000
50− DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
51− resolve(.durationElapsed(id: id))
52− }
81+ schedule(id, after: seconds) { resolve(.durationElapsed(id: id)) }
5382 case .notifyAt(let id, let instant):
5483 let target = Date(timeIntervalSince1970: Double(instant.seconds)
5584 + Double(instant.nanos) / 1_000_000_000)
56− let delay = max(0, target.timeIntervalSinceNow)
57− DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
85+ schedule(id, after: max(0, target.timeIntervalSinceNow)) {
5886 resolve(.instantArrived(id: id))
5987 }
6088 case .clear(let id):
89+ timers.removeValue(forKey: id)?.cancel()
6190 resolve(.cleared(id: id))
6291 }
6392 }
93+
94+ private static func schedule(
95+ _ id: TimerId,
96+ after seconds: TimeInterval,
97+ fire: @MainActor @escaping () -> Void
98+ ) {
99+ timers.removeValue(forKey: id)?.cancel() // a reused id supersedes the old timer
100+ let work = DispatchWorkItem {
101+ MainActor.assumeIsolated {
102+ timers.removeValue(forKey: id)
103+ fire()
104+ }
105+ }
106+ timers[id] = work
107+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
108+ }
64109}
65110
66111// MARK: - Key-value store (one file per key under Application Support)
67112
68113final class KvStore {
69114 private let dir: URL
115+ /// Serial: the core issues overlapping reads and writes to the same key, and the
116+ /// file for a key is the only copy of that state. Off the main actor, because
117+ /// `messages:<peer>` grows without bound and a write of it must not stutter the UI.
118+ private let queue = DispatchQueue(label: "lol.skrepka.kv")
70119
71120 init() {
72121 let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
73− dir = base.appendingPathComponent("skrepka/kv", isDirectory: true)
74− try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
122+ var url = base.appendingPathComponent("skrepka/kv", isDirectory: true)
123+ try? FileManager.default.createDirectory(
124+ at: url,
125+ withIntermediateDirectories: true,
126+ attributes: [.protectionKey: FileProtectionType.completeUnlessOpen]
127+ )
128+ // Plaintext message history: it must not ride along in an iCloud/iTunes backup,
129+ // which is not covered by the device passcode the way the Keychain item is.
130+ var values = URLResourceValues()
131+ values.isExcludedFromBackup = true
132+ try? url.setResourceValues(values)
133+ dir = url
134+
135+ // Ordered ahead of every operation: `handle` runs on the same serial queue, so
136+ // the rename is done before the first read can look for a file under the new name.
137+ queue.async { [self] in migrateLegacyNames() }
75138 }
76139
77− private func fileURL(_ key: String) -> URL {
78− let safe = key.replacingOccurrences(of: ":", with: "_")
79− .replacingOccurrences(of: "/", with: "_")
80− return dir.appendingPathComponent(safe)
140+ /// Rename the files an older build wrote, which substituted `_` for `:`.
141+ ///
142+ /// `messages:<peer>` lived in `messages_<peer>`; `fileURL(forKey:)` now percent-encodes,
143+ /// so it looks for `messages%3A<peer>` and finds nothing. Without this, an upgrade opens
144+ /// every conversation empty — and the first new message writes a fresh file, orphaning
145+ /// the old one (plaintext history) on disk forever. The other five keys are alphanumeric,
146+ /// so their names are unchanged and they need no migration.
147+ private func migrateLegacyNames() {
148+ let fm = FileManager.default
149+ guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
150+ for name in names where name.hasPrefix("messages_") {
151+ let peer = String(name.dropFirst("messages_".count))
152+ let destination = fileURL(forKey: "messages:\(peer)")
153+ guard !fm.fileExists(atPath: destination.path) else { continue }
154+ try? fm.moveItem(at: dir.appendingPathComponent(name), to: destination)
155+ }
81156 }
82157
83− private func read(_ key: String) -> Value {
84− guard let data = try? Data(contentsOf: fileURL(key)) else { return .none }
85− return .bytes([UInt8](data))
158+ /// Runs the operation on the serial queue and resolves from there. The core does not
159+ /// care which thread resolves an effect — `Core` hops the result back to the main
160+ /// actor before it touches the state machine.
161+ func handle(_ op: KeyValueOperation, completion: @escaping (KeyValueResult) -> Void) {
162+ queue.async { [self] in completion(perform(op)) }
86163 }
87164
88− func handle(_ op: KeyValueOperation) -> KeyValueResult {
165+ private func perform(_ op: KeyValueOperation) -> KeyValueResult {
89166 switch op {
90167 case .get(let key):
91− return .ok(response: .get(value: read(key)))
168+ switch read(key) {
169+ case .success(let value): return .ok(response: .get(value: value))
170+ case .failure(let error): return .err(error: .io(message: error.localizedDescription))
171+ }
172+
92173 case .set(let key, let value):
93− let previous = read(key)
94− // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
95− try? Data(value).write(to: fileURL(key), options: .atomic)
96− return .ok(response: .set(previous: previous))
174+ do {
175+ // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
176+ try Data(value).write(
177+ to: fileURL(forKey: key),
178+ options: [.atomic, .completeFileProtectionUnlessOpen]
179+ )
180+ // The core discards `previous`, so reading the old value back would
181+ // double the cost of every write to buy nothing.
182+ return .ok(response: .set(previous: .none))
183+ } catch {
184+ return .err(error: .io(message: error.localizedDescription))
185+ }
186+
97187 case .delete(let key):
98− let previous = read(key)
99− try? FileManager.default.removeItem(at: fileURL(key))
100− return .ok(response: .delete(previous: previous))
188+ switch read(key) {
189+ case .failure(let error):
190+ return .err(error: .io(message: error.localizedDescription))
191+ case .success(let previous):
192+ let url = fileURL(forKey: key)
193+ do {
194+ if FileManager.default.fileExists(atPath: url.path) {
195+ try FileManager.default.removeItem(at: url)
196+ }
197+ return .ok(response: .delete(previous: previous))
198+ } catch {
199+ return .err(error: .io(message: error.localizedDescription))
200+ }
201+ }
202+
101203 case .exists(let key):
102− let present = FileManager.default.fileExists(atPath: fileURL(key).path)
204+ let present = FileManager.default.fileExists(atPath: fileURL(forKey: key).path)
103205 return .ok(response: .exists(isPresent: present))
104− case .listKeys(_, _):
105− return .ok(response: .listKeys(keys: [], nextCursor: 0))
206+
207+ case .listKeys:
208+ // Never used by the core. Reporting success with no keys would read as
209+ // "the store is empty" — say so honestly instead.
210+ return .err(error: .io(message: "listKeys is not implemented"))
106211 }
107212 }
213+
214+ /// A missing file is an empty key; anything else is a real failure. Collapsing the
215+ /// two would let a transient read error look like "no contacts yet", and the next
216+ /// save would write that emptiness back over the real store.
217+ private func read(_ key: String) -> Result<Value, Error> {
218+ let url = fileURL(forKey: key)
219+ guard FileManager.default.fileExists(atPath: url.path) else { return .success(.none) }
220+ do {
221+ return .success(.bytes([UInt8](try Data(contentsOf: url))))
222+ } catch {
223+ return .failure(error)
224+ }
225+ }
226+
227+ /// Percent-encoding, so the key → filename mapping is injective. Substituting `_`
228+ /// for both `:` and `/` would collide `messages:ab` with `messages/ab`, and two
229+ /// peers' histories would land in the same file.
230+ private func fileURL(forKey key: String) -> URL {
231+ let encoded = key.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? key
232+ return dir.appendingPathComponent(encoded)
233+ }
108234}
109235
110236// MARK: - Keychain identity (64-byte libsodium-form Ed25519 secret key)
@@ -113,21 +239,56 @@ enum Keychain {
113239 private static let service = "lol.skrepka.identity"
114240 private static let account = "identity"
115241
116− /// Load the stored 64-byte key, or generate and persist a fresh valid one.
117− static func loadOrCreateIdentity() -> Data {
118− if let existing = load(), existing.count == 64 {
119− return existing
242+ /// `Result`'s failure type has to be an `Error`, and `OSStatus` is a bare `Int32`.
243+ struct Status: Error { let code: OSStatus }
244+
245+ enum IdentityError: Error {
246+ /// The item may well exist — we just could not read it (e.g. the device has not
247+ /// been unlocked since boot). Never generate a replacement on this path.
248+ case unreadable(OSStatus)
249+ case corrupt(Int)
250+ case notStored(OSStatus)
251+
252+ var message: String {
253+ switch self {
254+ case .unreadable(let status):
255+ return "Could not read the identity key from the Keychain (\(status)). "
256+ + "Unlock the device and try again."
257+ case .corrupt(let count):
258+ return "The stored identity key is \(count) bytes, not 64. "
259+ + "Reinstalling the app will create a new identity — the old one is unrecoverable."
260+ case .notStored(let status):
261+ return "Could not save the new identity key to the Keychain (\(status))."
262+ }
120263 }
121− // Generate a valid Ed25519 keypair; sk64 = seed(32) || pub(32).
122− let priv = Curve25519.Signing.PrivateKey()
123− var key = Data()
124− key.append(priv.rawRepresentation) // 32-byte seed
125− key.append(priv.publicKey.rawRepresentation) // 32-byte public key
126− store(key)
127− return key
128264 }
129265
130− private static func load() -> Data? {
266+ /// Load the stored 64-byte key, or — only when the Keychain positively reports that
267+ /// no item exists — generate and persist a fresh one.
268+ static func loadOrCreateIdentity() -> Result<Data, IdentityError> {
269+ switch load() {
270+ case .failure(let status):
271+ return .failure(.unreadable(status.code))
272+
273+ case .success(.some(let existing)):
274+ guard existing.count == 64 else { return .failure(.corrupt(existing.count)) }
275+ return .success(existing)
276+
277+ case .success(.none):
278+ // Generate a valid Ed25519 keypair; sk64 = seed(32) || pub(32).
279+ let priv = Curve25519.Signing.PrivateKey()
280+ var key = Data()
281+ key.append(priv.rawRepresentation) // 32-byte seed
282+ key.append(priv.publicKey.rawRepresentation) // 32-byte public key
283+ if let status = store(key) { return .failure(.notStored(status)) }
284+ return .success(key)
285+ }
286+ }
287+
288+ /// `errSecItemNotFound` is the only status that means "no identity yet". Every other
289+ /// failure leaves the question open, and the caller must not answer it by minting a
290+ /// new keypair — that would overwrite an identity we merely failed to read.
291+ private static func load() -> Result<Data?, Status> {
131292 let query: [String: Any] = [
132293 kSecClass as String: kSecClassGenericPassword,
133294 kSecAttrService as String: service,
@@ -136,34 +297,37 @@ enum Keychain {
136297 kSecMatchLimit as String: kSecMatchLimitOne,
137298 ]
138299 var item: CFTypeRef?
139− guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
140− return item as? Data
300+ let status = SecItemCopyMatching(query as CFDictionary, &item)
301+ switch status {
302+ case errSecSuccess: return .success(item as? Data)
303+ case errSecItemNotFound: return .success(nil)
304+ default: return .failure(Status(code: status))
305+ }
141306 }
142307
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.
146− private static func store(_ data: Data) {
147− let delete: [String: Any] = [
308+ /// Update in place, adding only if nothing is there. Delete-then-add would open a
309+ /// window where a crash (or a kill) leaves no key at all, and the next launch would
310+ /// silently mint a *new* identity — losing the old one, and with it every contact's
311+ /// idea of who we are.
312+ ///
313+ /// Returns `nil` on success, or the failing `OSStatus`.
314+ private static func store(_ data: Data) -> OSStatus? {
315+ let query: [String: Any] = [
148316 kSecClass as String: kSecClassGenericPassword,
149317 kSecAttrService as String: service,
150318 kSecAttrAccount as String: account,
151319 ]
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− }
156− let add: [String: Any] = [
157− kSecClass as String: kSecClassGenericPassword,
158− kSecAttrService as String: service,
159− kSecAttrAccount as String: account,
320+ let attributes: [String: Any] = [
160321 kSecValueData as String: data,
161322 // ThisDeviceOnly: the identity key must never ride along in a device backup.
162323 kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
163324 ]
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− }
325+
326+ let updated = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
327+ if updated == errSecSuccess { return nil }
328+ guard updated == errSecItemNotFound else { return updated }
329+
330+ let added = SecItemAdd(query.merging(attributes) { current, _ in current } as CFDictionary, nil)
331+ return added == errSecSuccess ? nil : added
168332 }
169333}
apple/SkrepkaApp/Forms.swift+41−8
@@ -1,10 +1,29 @@
1+import ImageIO
12import Skrepka
23import SwiftUI
34import UIKit
45
5−func decodeImage(_ base64: String) -> UIImage? {
6+/// Decode an avatar, bounded to `maxPixel` on its long edge.
7+///
8+/// A contact's `photo` is whatever bytes a peer put in a `profile` payload, and nothing
9+/// upstream bounds the image's *decoded* size — only the blob's. `UIImage(data:)` would
10+/// honour whatever dimensions the header claims, so a few KB of base64 declaring
11+/// 30000x30000 costs ~3.6 GB of bitmap and the app is jetsammed. ImageIO decodes straight
12+/// to the thumbnail size, so the cost is capped no matter what the source claims to be.
13+///
14+/// 512 covers the largest avatar we draw (88pt at @3x) with headroom; our own photos come
15+/// out of `PhotoPicker` at 256, and a thumbnail is never upscaled past the source.
16+func decodeImage(_ base64: String, maxPixel: Int = 512) -> UIImage? {
617 guard !base64.isEmpty, let data = Data(base64Encoded: base64) else { return nil }
7− return UIImage(data: data)
18+ let options: [CFString: Any] = [
19+ kCGImageSourceCreateThumbnailFromImageAlways: true,
20+ kCGImageSourceCreateThumbnailWithTransform: true,
21+ kCGImageSourceThumbnailMaxPixelSize: maxPixel,
22+ ]
23+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
24+ let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
25+ else { return nil }
26+ return UIImage(cgImage: thumb)
827}
928
1029struct AddContactView: View {
@@ -44,7 +63,7 @@ struct AddContactView: View {
4463 }
4564 .sheet(isPresented: $scanning) {
4665 QRScannerView { code in
47− input = code
66+ input = scannedKeyPayload(code)
4867 scanning = false
4968 }
5069 }
@@ -77,8 +96,14 @@ struct SettingsView: View {
7796 Text(core.view.myPubkeyOb).font(.system(.footnote, design: .monospaced))
7897 .textSelection(.enabled)
7998 Button {
80− UIPasteboard.general.string = core.view.myPubkeyHex
99+ // Expire the pasteboard item: a public key is not a secret, but it
100+ // is an identifier, and every app on the device can read the board.
101+ UIPasteboard.general.setItems(
102+ [[UIPasteboard.typeAutomatic: core.view.myPubkeyHex]],
103+ options: [.expirationDate: Date().addingTimeInterval(60)]
104+ )
81105 copied = true
106+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
82107 } label: {
83108 Label(copied ? "Copied!" : "Copy key", systemImage: "doc.on.doc")
84109 }
@@ -92,6 +117,11 @@ struct SettingsView: View {
92117 Button("Save & reconnect") {
93118 core.update(.setServerUrl(serverUrl))
94119 }.disabled(serverUrl.isEmpty || serverUrl == core.view.serverUrl)
120+ // A rejected URL sets `error` in the core and leaves the page open;
121+ // without this the Save button just does nothing, forever.
122+ if !core.view.error.isEmpty {
123+ Text(core.view.error).foregroundColor(.red)
124+ }
95125 }
96126 }
97127 .navigationTitle("Settings")
@@ -141,10 +171,13 @@ struct EditProfileView: View {
141171 }
142172 }
143173 .sheet(isPresented: $picking) {
144− PhotoPicker { base64 in
145− photo = base64
146− picking = false
147− }
174+ PhotoPicker(
175+ onPick: { base64 in
176+ photo = base64
177+ picking = false
178+ },
179+ onCancel: { picking = false }
180+ )
148181 }
149182 .onAppear {
150183 name = core.view.profile.displayName
apple/SkrepkaApp/PhotoPicker.swift+34−19
@@ -1,12 +1,15 @@
1+import ImageIO
12import PhotosUI
23import SwiftUI
34import UIKit
5+import UniformTypeIdentifiers
46
5−/// Presents the system photo picker; returns a downscaled JPEG as base64.
7+/// Presents the system photo picker; returns a 256px JPEG as base64.
68struct PhotoPicker: UIViewControllerRepresentable {
79 let onPick: (String) -> Void
10+ var onCancel: () -> Void = {}
811
9− func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) }
12+ func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick, onCancel: onCancel) }
1013
1114 func makeUIViewController(context: Context) -> PHPickerViewController {
1215 var config = PHPickerConfiguration()
@@ -21,33 +24,45 @@ struct PhotoPicker: UIViewControllerRepresentable {
2124
2225 final class Coordinator: NSObject, PHPickerViewControllerDelegate {
2326 let onPick: (String) -> Void
24− init(onPick: @escaping (String) -> Void) { self.onPick = onPick }
27+ let onCancel: () -> Void
28+
29+ init(onPick: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
30+ self.onPick = onPick
31+ self.onCancel = onCancel
32+ }
2533
2634 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
35+ // Empty results means the user cancelled. Dismissal belongs to the caller's
36+ // `isPresented` binding — dismissing the controller behind SwiftUI's back
37+ // leaves the binding true, and the sheet never opens again.
2738 guard let provider = results.first?.itemProvider,
28− provider.canLoadObject(ofClass: UIImage.self) else {
29− DispatchQueue.main.async { picker.dismiss(animated: true) }
39+ provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
40+ DispatchQueue.main.async { self.onCancel() }
3041 return
3142 }
32− provider.loadObject(ofClass: UIImage.self) { object, _ in
33− guard let image = object as? UIImage else { return }
34− // loadObject calls back on a background queue; the redraw below is UIKit.
43+ _ = provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, _ in
44+ // The temp file is deleted the moment this callback returns: decode inline.
45+ let base64 = url.flatMap { Self.thumbnailBase64(at: $0) }
3546 DispatchQueue.main.async {
36− guard let base64 = Self.downscaledBase64(image) else { return }
37− self.onPick(base64)
47+ if let base64 { self.onPick(base64) } else { self.onCancel() }
3848 }
3949 }
4050 }
4151
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.
44− static func downscaledBase64(_ image: UIImage) -> String? {
45− let maxDim: CGFloat = 256
46− let scale = min(1, maxDim / max(image.size.width, image.size.height))
47− let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
48− let renderer = UIGraphicsImageRenderer(size: size)
49− let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
50− return resized.jpegData(compressionQuality: 0.7)?.base64EncodedString()
52+ /// Downsample through ImageIO, which decodes straight to the target size. Loading
53+ /// a `UIImage` first would materialize the full bitmap — ~190 MB for a 48-megapixel
54+ /// shot — to then throw all but 256px of it away.
55+ static func thumbnailBase64(at url: URL) -> String? {
56+ let options: [CFString: Any] = [
57+ kCGImageSourceCreateThumbnailFromImageAlways: true,
58+ kCGImageSourceCreateThumbnailWithTransform: true, // honour the EXIF orientation
59+ kCGImageSourceShouldCacheImmediately: true,
60+ kCGImageSourceThumbnailMaxPixelSize: 256, // pixels, not points: no @2x/@3x inflation
61+ ]
62+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
63+ let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
64+ else { return nil }
65+ return UIImage(cgImage: thumb).jpegData(compressionQuality: 0.7)?.base64EncodedString()
5166 }
5267 }
5368}
apple/SkrepkaApp/QRView.swift+21−1
@@ -3,9 +3,13 @@ import CoreImage.CIFilterBuiltins
33import SwiftUI
44import UIKit
55
6+/// Rendering context for QR generation. Building a CIContext is expensive (it spins up a
7+/// GPU/CPU render pipeline) and `qrImage` is called from inside a SwiftUI `body`.
8+private let ciContext = CIContext()
9+
610/// Generate a QR code image from a string.
711func qrImage(_ string: String) -> UIImage? {
8− let context = CIContext()
12+ let context = ciContext
913 let filter = CIFilter.qrCodeGenerator()
1014 filter.message = Data(string.utf8)
1115 guard let output = filter.outputImage else { return nil }
@@ -14,6 +18,22 @@ func qrImage(_ string: String) -> UIImage? {
1418 return UIImage(cgImage: cg)
1519}
1620
21+/// A QR code carries whatever its author put there. Strip a leading scheme so a share
22+/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
23+/// and so the pubkey field never silently fills with an unrelated URL.
24+func scannedKeyPayload(_ raw: String) -> String {
25+ var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
26+ let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
27+ if let scheme = schemes.first(where: { value.lowercased().hasPrefix($0) }) {
28+ value = String(value.dropFirst(scheme.count))
29+ // What remains of a link is host/path#key — the key is the last component.
30+ if let key = value.split(whereSeparator: { "/#?".contains($0) }).last {
31+ value = String(key)
32+ }
33+ }
34+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
35+}
36+
1737/// A camera-backed QR scanner. Calls `onScan` with the decoded payload once.
1838struct QRScannerView: UIViewControllerRepresentable {
1939 let onScan: (String) -> Void
apple/SkrepkaApp/SkrepkaApp.swift+8
@@ -3,10 +3,18 @@ import SwiftUI
33@main
44struct SkrepkaApp: App {
55 @StateObject private var core = Core()
6+ @Environment(\.scenePhase) private var scenePhase
67
78 var body: some Scene {
89 WindowGroup {
910 RootView(core: core)
11+ .onChange(of: scenePhase) { _, phase in
12+ // Backgrounding kills the long-poll socket and the app stops running,
13+ // so on return the core would otherwise sit out its whole backoff
14+ // (up to 30s) before noticing. Reconnect immediately instead.
15+ guard phase == .active, core.view.hasIdentity else { return }
16+ core.update(.connect)
17+ }
1018 }
1119 }
1220}
apple/SkrepkaApp/Views.swift+101−16
@@ -1,13 +1,33 @@
11import Skrepka
22import SwiftUI
3+import UIKit
34
45let accent = Color(red: 98 / 255, green: 54 / 255, blue: 1.0)
56
7+/// Hoisted out of `body`: a DateFormatter costs real work to build, and these are read
8+/// once per message bubble on every render.
9+private let timeFormatter: DateFormatter = {
10+ let fmt = DateFormatter()
11+ fmt.timeStyle = .short // locale-aware: 24h or AM/PM, as the user has it set
12+ return fmt
13+}()
14+
15+private let dateFormatter: DateFormatter = {
16+ let fmt = DateFormatter()
17+ fmt.dateStyle = .short
18+ return fmt
19+}()
20+
621func formatTime(_ ms: Int64) -> String {
22+ timeFormatter.string(from: Date(timeIntervalSince1970: Double(ms) / 1000))
23+}
24+
25+/// Conversation-list stamp: the time for today, the date for anything older.
26+func formatStamp(_ ms: Int64) -> String {
727 let date = Date(timeIntervalSince1970: Double(ms) / 1000)
8− let fmt = DateFormatter()
9− fmt.dateFormat = "HH:mm"
10− return fmt.string(from: date)
28+ return Calendar.current.isDateInToday(date)
29+ ? timeFormatter.string(from: date)
30+ : dateFormatter.string(from: date)
1131}
1232
1333struct RootView: View {
@@ -16,7 +36,7 @@ struct RootView: View {
1636 var body: some View {
1737 Group {
1838 if !core.view.hasIdentity {
19− ProgressView("Generating identity…")
39+ identity
2040 } else {
2141 switch core.view.page {
2242 case "chat": ChatView(core: core)
@@ -28,6 +48,27 @@ struct RootView: View {
2848 }
2949 }
3050 }
51+
52+ /// Without an identity there is no app. If loading one failed, say so — a spinner
53+ /// that never resolves is the worst possible way to report a Keychain error.
54+ @ViewBuilder private var identity: some View {
55+ let error = core.identityError ?? (core.view.error.isEmpty ? nil : core.view.error)
56+ if let error {
57+ VStack(spacing: 12) {
58+ Image(systemName: "exclamationmark.triangle")
59+ .font(.largeTitle).foregroundColor(.orange)
60+ Text("Identity unavailable").font(.headline)
61+ Text(error)
62+ .font(.footnote).foregroundColor(.secondary)
63+ .multilineTextAlignment(.center)
64+ Button("Try again") { core.bootIdentity() }
65+ .buttonStyle(.borderedProminent).tint(accent)
66+ }
67+ .padding(32)
68+ } else {
69+ ProgressView("Generating identity…")
70+ }
71+ }
3172}
3273
3374struct ConnDot: View {
@@ -48,21 +89,52 @@ struct ConnDot: View {
4889 }
4990}
5091
92+/// Decoded avatars, keyed by their base64 source. A contact's photo is attacker-supplied
93+/// bytes off the wire; decoding it synchronously inside `body` hands any peer a way to
94+/// stall the main thread on every single render.
95+private let avatarCache: NSCache<NSString, UIImage> = {
96+ let cache = NSCache<NSString, UIImage>()
97+ cache.countLimit = 128
98+ return cache
99+}()
100+
51101struct Avatar: View {
52102 let base64: String
53103 let name: String
54104 var size: CGFloat = 44
105+ @State private var image: UIImage?
106+
55107 var body: some View {
56− if let img = decodeImage(base64) {
57− Image(uiImage: img).resizable().scaledToFill()
58− .frame(width: size, height: size).clipShape(Circle())
59− } else {
60− ZStack {
61− Circle().fill(accent.opacity(0.15))
62− Text(initials(name)).font(.system(size: size * 0.4, weight: .semibold))
63− .foregroundColor(accent)
64− }.frame(width: size, height: size)
108+ Group {
109+ if let image {
110+ Image(uiImage: image).resizable().scaledToFill()
111+ .frame(width: size, height: size).clipShape(Circle())
112+ } else {
113+ ZStack {
114+ Circle().fill(accent.opacity(0.15))
115+ Text(initials(name)).font(.system(size: size * 0.4, weight: .semibold))
116+ .foregroundColor(accent)
117+ }.frame(width: size, height: size)
118+ }
65119 }
120+ .task(id: base64) { await load() }
121+ }
122+
123+ private func load() async {
124+ guard !base64.isEmpty else {
125+ image = nil
126+ return
127+ }
128+ let key = base64 as NSString
129+ if let cached = avatarCache.object(forKey: key) {
130+ image = cached
131+ return
132+ }
133+ let source = base64
134+ let decoded = await Task.detached(priority: .userInitiated) { decodeImage(source) }.value
135+ guard !Task.isCancelled else { return }
136+ if let decoded { avatarCache.setObject(decoded, forKey: key) }
137+ image = decoded
66138 }
67139}
68140
@@ -139,7 +211,7 @@ struct ConversationsView: View {
139211 }
140212 Spacer()
141213 if contact.lastTs > 0 {
142− Text(formatTime(contact.lastTs))
214+ Text(formatStamp(contact.lastTs))
143215 .font(.caption).foregroundColor(.secondary)
144216 }
145217 }
@@ -159,13 +231,20 @@ struct ChatView: View {
159231 messages
160232 composer
161233 }
162− .navigationBarBackButtonHidden(true)
163234 .safeAreaInset(edge: .top) { header }
164235 }
165236
237+ /// The open chat's contact row. The core only fills in `photo` for this one peer,
238+ /// and only while their chat is up — a base64 avatar is far too big to clone into
239+ /// every contact on every render — so the header is the one place it can be shown.
240+ private var peer: ContactVM? {
241+ core.view.contacts.first { $0.pubkey == core.view.activePeer }
242+ }
243+
166244 private var header: some View {
167245 HStack(spacing: 12) {
168246 Button { core.update(.back) } label: { Image(systemName: "chevron.left") }
247+ Avatar(base64: peer?.photo ?? "", name: core.view.activePeerName, size: 32)
169248 VStack(alignment: .leading, spacing: 1) {
170249 Text(core.view.activePeerName).font(.headline)
171250 Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
@@ -197,6 +276,12 @@ struct ChatView: View {
197276 }
198277 }.padding()
199278 }
279+ .onAppear {
280+ // A chat opens at its newest message, not its oldest.
281+ if let last = core.view.messages.last {
282+ withAnimation(nil) { proxy.scrollTo(last.id, anchor: .bottom) }
283+ }
284+ }
200285 .onChange(of: core.view.messages.count) {
201286 if let last = core.view.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
202287 }
@@ -229,7 +314,7 @@ struct MessageBubble: View {
229314 var body: some View {
230315 HStack {
231316 if msg.outgoing { Spacer(minLength: 40) }
232− VStack(alignment: .trailing, spacing: 2) {
317+ VStack(alignment: msg.outgoing ? .trailing : .leading, spacing: 2) {
233318 Text(msg.body)
234319 .foregroundColor(msg.outgoing ? .white : .primary)
235320 HStack(spacing: 4) {
apple/project.yml+16
@@ -20,6 +20,20 @@ targets:
2020 - path: SkrepkaApp
2121 excludes:
2222 - "Info-*.plist"
23+ - "*.entitlements"
24+ settings:
25+ base:
26+ # iPhone only: the app declares portrait-only, which iPadOS rejects for an iPad
27+ # build (it requires landscape too). Rather than pretend to support a form factor
28+ # the layout was never designed for, don't claim it.
29+ TARGETED_DEVICE_FAMILY: "1"
30+ entitlements:
31+ path: SkrepkaApp/SkrepkaApp.entitlements
32+ properties:
33+ # Plaintext message history sits in Application Support. Default every file the
34+ # app creates to Complete protection, so it is unreadable while the device is
35+ # locked — the KV store then opts into UnlessOpen for the files it holds open.
36+ com.apple.developer.default-data-protection: NSFileProtectionComplete
2337 info:
2438 path: SkrepkaApp/Info-iOS.plist
2539 properties:
@@ -29,6 +43,8 @@ targets:
2943 - UIInterfaceOrientationPortrait
3044 NSCameraUsageDescription: Scan a contact's QR code to add them.
3145 NSPhotoLibraryUsageDescription: Choose a photo for your profile.
46+ # Declared so App Store Connect stops asking on every upload.
47+ ITSAppUsesNonExemptEncryption: false
3248 # Relays must be reachable over TLS. The only cleartext exception is a
3349 # locally-run `just server`, for development.
3450 NSAppTransportSecurity:
core/Cargo.toml+2
@@ -45,6 +45,8 @@ zstd.workspace = true
4545rand_core.workspace = true
4646uuid.workspace = true
4747hex.workspace = true
48+zeroize.workspace = true
49+url.workspace = true
4850
4951# optional, only for the codegen bin
5052anyhow = { workspace = true, optional = true }
core/src/app.rs+1199−153
@@ -18,11 +18,13 @@ use crux_kv::KeyValueOperation;
1818use crux_time::TimeRequest;
1919use facet::Facet;
2020use serde::{Deserialize, Serialize};
21+use url::Url;
22+use zeroize::Zeroizing;
2123
22−use crate::crypto::Identity;
24+use crate::crypto::{Identity, MAX_BLOB_LEN};
2325use crate::model::{
2426 hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
25− Settings, StoredMessage, ViewModel, MAX_MESSAGES_PER_PEER,
27+ Settings, StoredMessage, ViewModel, DEFAULT_SERVER_URL, MAX_MESSAGES_PER_PEER,
2628};
2729use crate::phonemic;
2830use crate::protocol::{self, Envelope, Payload};
@@ -38,6 +40,38 @@ type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
3840/// peer park itself at the top of the conversation list.
3941const MAX_FUTURE_SKEW_MS: i64 = 60_000;
4042
43+/// Cap on stored contacts.
44+///
45+/// Any stranger can create one just by sending us a message, so without a cap a
46+/// spammer holding a list of pubkeys can grow `contacts` (and the `contacts` kv
47+/// blob, rewritten in full on every change) without bound. Past the cap we drop
48+/// mail from unknown keys; contacts the user added by hand are never at risk,
49+/// because `AddContact` doesn't go through this path.
50+const MAX_CONTACTS: usize = 500;
51+
52+/// Cap on the total blob bytes we will decrypt out of a single poll page.
53+///
54+/// A hostile relay can answer `/poll` with as many events as it likes. Each one
55+/// costs a hex decode, a scalar mult and an AEAD pass, all before we can tell it
56+/// is junk. `MAX_BLOB_LEN` bounds one blob; this bounds the page. Anything past
57+/// the budget is dropped exactly like an undecryptable blob would be.
58+const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
59+
60+/// How long a poll may stay in flight before we assume the shell lost it.
61+///
62+/// `polling` gates the whole long-poll loop, and it is only cleared by a
63+/// `PollResult`. If the shell ever drops an HTTP effect (a cancelled task, a
64+/// backgrounded app, a bug), `polling` stays `true` and the client goes quiet
65+/// forever with no visible error. The watchdog re-arms the loop instead.
66+///
67+/// This **must** stay above the shell's own HTTP timeout (70 s, see
68+/// `Effects.swift`), or the watchdog stops being a watchdog: it would fire on
69+/// every poll that is merely slow rather than lost, abandoning a request the
70+/// shell is still going to resolve. The generation check in `PollResult` makes
71+/// that survivable, but each misfire still costs a redundant request to the
72+/// relay, so leave the margin.
73+const POLL_WATCHDOG_MS: u64 = 90_000;
74+
4175// kv keys
4276const K_SETTINGS: &str = "settings";
4377const K_PROFILE: &str = "profile";
@@ -115,20 +149,44 @@ pub enum Event {
115149 #[serde(skip)]
116150 #[facet(skip)]
117151 Saved(#[facet(opaque)] KvData),
152+ /// The cursor write specifically: the next poll is chained off it, so that a
153+ /// message is never acked to the server before it is durable locally.
154+ #[serde(skip)]
155+ #[facet(skip)]
156+ SavedCursor(#[facet(opaque)] KvData),
118157
119158 // ---- internal: auth / poll / send ----
159+ // These drive the core's own loops and are never sent by the shell, so they
160+ // are kept off the FFI surface entirely.
161+ //
162+ // The auth and poll results carry the *generation* of the round-trip that
163+ // produced them. An in-flight effect is never cancelled — it can only be
164+ // superseded — so without a generation a result belonging to an attempt we
165+ // already abandoned lands in the handler for the attempt that replaced it.
166+ // See `Model::poll_gen`.
167+ #[serde(skip)]
168+ #[facet(skip)]
120169 Authenticate,
170+ #[serde(skip)]
171+ #[facet(skip)]
121172 Poll,
173+ /// Fires `POLL_WATCHDOG_MS` after generation `n`'s poll is issued; a no-op
174+ /// unless that exact poll is somehow still in flight.
175+ #[serde(skip)]
176+ #[facet(skip)]
177+ PollWatchdog(u64),
178+ #[serde(skip)]
179+ #[facet(skip)]
122180 StartFlush,
123181 #[serde(skip)]
124182 #[facet(skip)]
125− ChallengeResult(#[facet(opaque)] HttpResult),
183+ ChallengeResult(u64, #[facet(opaque)] HttpResult),
126184 #[serde(skip)]
127185 #[facet(skip)]
128− VerifyResult(#[facet(opaque)] HttpResult),
186+ VerifyResult(u64, #[facet(opaque)] HttpResult),
129187 #[serde(skip)]
130188 #[facet(skip)]
131− PollResult(#[facet(opaque)] HttpResult),
189+ PollResult(u64, #[facet(opaque)] HttpResult),
132190 #[serde(skip)]
133191 #[facet(skip)]
134192 SendResult(#[facet(opaque)] HttpResult),
@@ -157,8 +215,14 @@ pub enum Page {
157215 EditProfile,
158216}
159217
218+/// The five kv keys loaded at startup (`settings`, `profile`, `contacts`,
219+/// `cursor`, `outbox`). `Connect` waits for all of them.
220+const STARTUP_LOADS: u8 = 5;
221+
160222pub struct Model {
161− secret_key: Option<Vec<u8>>,
223+ /// Zeroized on drop: this is the Ed25519 seed, the one secret whose leak
224+ /// costs the user their identity.
225+ secret_key: Option<Zeroizing<Vec<u8>>>,
162226 my_pubkey: String,
163227 settings: Settings,
164228 profile: OwnProfile,
@@ -172,7 +236,35 @@ pub struct Model {
172236 /// A poll request is in flight. Guards against stacking concurrent long-poll
173237 /// loops (every extra loop re-polls itself forever and never terminates).
174238 polling: bool,
239+ /// Which poll the in-flight `PollResult` is allowed to belong to.
240+ ///
241+ /// Bumped every time a poll is issued, and every time one is *abandoned* (the
242+ /// watchdog gives up on it; a relay switch invalidates it). A `PollResult`
243+ /// whose generation no longer matches is dropped on the floor.
244+ ///
245+ /// A wall-clock "has it been long enough?" test cannot do this job. An HTTP
246+ /// effect stays pending across an iOS suspension while `now_ms()` runs on, so
247+ /// on resume *every* in-flight poll looks stuck — the watchdog abandons a
248+ /// request the shell is still about to resolve, and that resolution then
249+ /// clears the guard belonging to its replacement. Two poll loops, each
250+ /// re-polling itself forever. The generation is what makes abandoning safe.
251+ poll_gen: u64,
252+ /// An auth round-trip is in flight. `Connect` is fired from several places
253+ /// (startup, a 401, a reconnect timer, a server change); without this guard
254+ /// they stack, and each one that lands installs a token and starts its own
255+ /// poll loop.
256+ authenticating: bool,
257+ /// The same idea for the auth round-trip, which spans two legs (challenge →
258+ /// verify) and is restarted by `Connect` — which the shell fires on *every*
259+ /// return to the foreground (`SkrepkaApp.swift`). The challenge still in
260+ /// flight from before the app was backgrounded must not install a token or
261+ /// overwrite `conn` on top of the attempt that replaced it.
262+ auth_gen: u64,
175263 flushing: bool,
264+ /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
265+ /// can't land and be persisted against a half-loaded model — which would
266+ /// then be clobbered by the loads still in flight.
267+ loads_pending: u8,
176268 page: Page,
177269 active_peer: Option<String>,
178270 compose: String,
@@ -194,7 +286,11 @@ impl Default for Model {
194286 conn: ConnStatus::Offline,
195287 poll_retries: 0,
196288 polling: false,
289+ poll_gen: 0,
290+ authenticating: false,
291+ auth_gen: 0,
197292 flushing: false,
293+ loads_pending: 0,
198294 page: Page::default(),
199295 active_peer: None,
200296 compose: String::new(),
@@ -209,12 +305,59 @@ impl Model {
209305 .as_ref()
210306 .and_then(|sk| Identity::from_secret_bytes(sk).ok())
211307 }
308+
309+ /// Tick off one startup load. Returns `true` exactly once — when the last of
310+ /// the five lands — so `Connect` is emitted a single time.
311+ fn startup_load_done(&mut self) -> bool {
312+ if self.loads_pending == 0 {
313+ return false;
314+ }
315+ self.loads_pending -= 1;
316+ self.loads_pending == 0
317+ }
318+
319+ /// Open a new poll generation and return it. Any `PollResult` still in flight
320+ /// under the old generation is now dead to us.
321+ fn next_poll_gen(&mut self) -> u64 {
322+ self.poll_gen = self.poll_gen.wrapping_add(1);
323+ self.poll_gen
324+ }
325+
326+ /// Abandon the in-flight poll, if any: release the loop guard and retire the
327+ /// generation so a late `PollResult` cannot clear the *replacement* poll's
328+ /// guard or fold a stale page (and a stale cursor) into the model.
329+ fn abandon_poll(&mut self) {
330+ self.polling = false;
331+ self.poll_gen = self.poll_gen.wrapping_add(1);
332+ }
333+
334+ /// Open a new auth generation and return it.
335+ fn next_auth_gen(&mut self) -> u64 {
336+ self.auth_gen = self.auth_gen.wrapping_add(1);
337+ self.auth_gen
338+ }
339+
340+ /// Abandon the in-flight auth round-trip (either leg).
341+ fn abandon_auth(&mut self) {
342+ self.authenticating = false;
343+ self.auth_gen = self.auth_gen.wrapping_add(1);
344+ }
212345}
213346
214347// ---------------------------------------------------------------------------
215348// Helpers (pure)
216349// ---------------------------------------------------------------------------
217350
351+/// Wall-clock milliseconds.
352+///
353+/// This reads the system clock from inside `update()`, which is supposed to be a
354+/// pure function of `(Event, Model)`. It isn't, and that has real costs: the
355+/// state machine can't be replayed deterministically, and tests can't pin "now".
356+/// The clean fix is to take the time as a `crux_time` effect and feed it back in
357+/// as an event — but every timestamped path (`SendText`, `SaveProfile`,
358+/// `ingest_poll`) would have to become a two-step round-trip through the shell.
359+/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
360+/// the impurity is documented rather than hidden.
218361fn now_ms() -> i64 {
219362 SystemTime::now()
220363 .duration_since(UNIX_EPOCH)
@@ -223,36 +366,44 @@ fn now_ms() -> i64 {
223366}
224367
225368/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
369+///
370+/// This feeds the auth signature, which is bound to the host (PROTOCOL.md §6),
371+/// so it must agree with what the relay itself computes. Splitting on `:` by hand
372+/// mangles an IPv6 literal (`http://[::1]:8080` → `[`), producing a signature the
373+/// server rejects; `Url` gets the bracket rules right.
226374fn server_host(url: &str) -> String {
227− let after_scheme = url.split("://").nth(1).unwrap_or(url);
228− let host_port = after_scheme.split('/').next().unwrap_or("");
229− let host = host_port.split('@').next_back().unwrap_or(host_port);
230− let host = host.split(':').next().unwrap_or(host);
231− host.trim_end_matches('.').to_lowercase()
375+ Url::parse(url)
376+ .ok()
377+ .and_then(|u| u.host_str().map(str::to_string))
378+ .map(|h| h.trim_end_matches('.').to_lowercase())
379+ .unwrap_or_default()
232380}
233381
234382/// Validate and canonicalize a user-entered relay URL.
235383///
236−/// The shell hands us raw text-field contents, and `Http::post` panics on a URL
237−/// with no scheme — so a schemeless entry must be rejected here, in the core,
238−/// before it can ever reach an effect. Returns the normalized URL (lowercased
239−/// scheme, no trailing slash) or `None` if it isn't an absolute http(s) URL.
384+/// The shell hands us raw text-field contents and `crux_http` unwraps the parse,
385+/// so anything the real parser rejects must be rejected *here* or it panics the
386+/// core. That means validating with the same parser rather than a lookalike: the
387+/// hand-rolled check this replaces accepted `http://[bad` and `http://ho^st`,
388+/// both of which `Url::parse` refuses.
389+///
390+/// A query or fragment is rejected too. Every request appends a path
391+/// (`{url}/poll`), so `http://x?y` would yield `http://x?y/poll` — the path
392+/// swallowed into the query string, pointing at nothing.
393+///
394+/// Returns the normalized URL (no trailing slash) or `None`.
240395fn normalize_server_url(input: &str) -> Option<String> {
241− let (scheme, rest) = input.trim().split_once("://")?;
242− if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
396+ let url = Url::parse(input.trim()).ok()?;
397+ if !matches!(url.scheme(), "http" | "https") {
243398 return None;
244399 }
245− let rest = rest.trim_end_matches('/');
246− if rest.chars().any(char::is_whitespace) {
400+ if url.host_str().is_none_or(str::is_empty) {
247401 return None;
248402 }
249− // Require a non-empty authority: reject "http://", "http:///x", "http://:80".
250− let authority = rest.split('/').next().unwrap_or("");
251− let host = authority.split('@').next_back().unwrap_or(authority);
252− if host.split(':').next().unwrap_or("").is_empty() {
403+ if url.query().is_some() || url.fragment().is_some() {
253404 return None;
254405 }
255− Some(format!("{}://{}", scheme.to_lowercase(), rest))
406+ Some(url.as_str().trim_end_matches('/').to_string())
256407}
257408
258409fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
@@ -326,10 +477,12 @@ impl App for Skrepka {
326477 model.error = Some("invalid identity key".into());
327478 return render();
328479 };
329− model.secret_key = Some(sk);
480+ model.secret_key = Some(Zeroizing::new(sk));
330481 model.my_pubkey = id.public_key_hex();
331482 model.page = Page::Conversations;
332− // Fan out the startup loads.
483+ // Fan out the startup loads. `Connect` waits until the last of
484+ // them lands (see `startup_load_done`).
485+ model.loads_pending = STARTUP_LOADS;
333486 Command::all([
334487 KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
335488 KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
@@ -342,45 +495,47 @@ impl App for Skrepka {
342495
343496 Event::LoadedSettings(res) => {
344497 if let Some(s) = parse_kv::<Settings>(res) {
345− model.settings = s;
498+ // Re-validate on the way in. The URL was normalized when the
499+ // user typed it, but the blob on disk is not trustworthy: an
500+ // older build wrote URLs this parser rejects, and a corrupted
501+ // or hand-edited file would reach `Http::post`, which unwraps
502+ // the parse. That is a panic on every launch — a boot loop
503+ // with no way out from inside the app.
504+ model.settings.server_url = normalize_server_url(&s.server_url)
505+ .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
346506 }
347− // Server URL known — connect and resume any queued sends.
348− Command::event(Event::Connect)
507+ self.startup_done(model).and(render())
349508 }
350509 Event::LoadedProfile(res) => {
351510 if let Some(p) = parse_kv::<OwnProfile>(res) {
352511 model.profile = p;
353512 }
354− render()
513+ self.startup_done(model).and(render())
355514 }
356515 Event::LoadedContacts(res) => {
516+ let mut cmds = vec![self.startup_done(model)];
357517 if let Some(list) = parse_kv::<Vec<Contact>>(res) {
358518 model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
359519 // Lazily load each conversation's messages.
360− let loads: Vec<_> = model
361− .contacts
362− .keys()
363− .map(|peer| {
364− let peer = peer.clone();
365− KeyValue::get(k_messages(&peer))
366− .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
367− })
368− .collect();
369− return Command::all(loads).and(render());
520+ cmds.extend(model.contacts.keys().map(|peer| {
521+ let peer = peer.clone();
522+ KeyValue::get(k_messages(&peer))
523+ .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
524+ }));
370525 }
371− render()
526+ Command::all(cmds).and(render())
372527 }
373528 Event::LoadedCursor(res) => {
374529 if let Some(c) = parse_kv::<i64>(res) {
375530 model.cursor = c;
376531 }
377− render()
532+ self.startup_done(model).and(render())
378533 }
379534 Event::LoadedOutbox(res) => {
380535 if let Some(list) = parse_kv::<Vec<OutboxItem>>(res) {
381536 model.outbox = list.into();
382537 }
383− render()
538+ self.startup_done(model).and(render())
384539 }
385540 Event::LoadedMessages(peer, res) => {
386541 if let Some(list) = parse_kv::<Vec<StoredMessage>>(res) {
@@ -397,15 +552,32 @@ impl App for Skrepka {
397552 }
398553 Command::done()
399554 }
555+ Event::SavedCursor(res) => {
556+ // The cursor write is what tells the relay it may delete the mail
557+ // we just took (it acks up to `cursor` on the next poll). Only
558+ // re-poll once it has actually landed: if we polled in parallel
559+ // and the write failed, the messages would be gone from the
560+ // server and absent from disk.
561+ if let Err(e) = res {
562+ model.error = Some(format!("could not save data: {e}"));
563+ return self.backoff_poll(model);
564+ }
565+ Command::event(Event::Poll)
566+ }
400567
401568 // ---------------- navigation ----------------
569+ // `error` is about the thing the user was just doing. Leaving it set
570+ // across a page change strands a stale message ("invalid public key")
571+ // on an unrelated screen, so every navigation clears it.
402572 Event::ShowConversations => {
403573 model.page = Page::Conversations;
404574 model.active_peer = None;
575+ model.error = None;
405576 render()
406577 }
407578 Event::ShowSettings => {
408579 model.page = Page::Settings;
580+ model.error = None;
409581 render()
410582 }
411583 Event::ShowAddContact => {
@@ -415,16 +587,19 @@ impl App for Skrepka {
415587 }
416588 Event::ShowEditProfile => {
417589 model.page = Page::EditProfile;
590+ model.error = None;
418591 render()
419592 }
420593 Event::OpenChat(peer) => {
421594 model.active_peer = Some(peer);
422595 model.page = Page::Chat;
596+ model.error = None;
423597 render()
424598 }
425599 Event::Back => {
426600 model.page = Page::Conversations;
427601 model.active_peer = None;
602+ model.error = None;
428603 render()
429604 }
430605
@@ -435,19 +610,42 @@ impl App for Skrepka {
435610 }
436611 Event::SetServerUrl(url) => {
437612 let Some(url) = normalize_server_url(&url) else {
438− model.error = Some("server URL must start with http:// or https://".into());
613+ model.error = Some("server URL must be an http(s) address".into());
439614 return render();
440615 };
441616 model.error = None;
442617 model.settings.server_url = url;
443618 model.token = None;
444619 model.conn = ConnStatus::Offline;
445− KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
446− .then_send(Event::Saved)
447− .and(Command::event(Event::Connect))
448− .and(render())
620+ // The cursor is *per relay*: it is that server's own sequence
621+ // number, and the next poll acks everything up to it. Carrying
622+ // relay A's cursor (roughly `now_ms()`) over to relay B tells B
623+ // to delete every message queued for us before it hands any of
624+ // them over. Start B from the beginning.
625+ model.cursor = 0;
626+ // ...and retire the poll that is still in flight *to relay A*, or
627+ // resetting the cursor achieves nothing: A's answer is already on
628+ // its way, it carries A's cursor, and `ingest_poll` would write it
629+ // straight back over the zero we just set. The next poll — to B —
630+ // would then ack a sequence number B never issued, and B would
631+ // drop every message waiting for us.
632+ model.abandon_poll();
633+ Command::all([
634+ KeyValue::set(K_SETTINGS, json_bytes(&model.settings)).then_send(Event::Saved),
635+ KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
636+ ])
637+ .and(Command::event(Event::Connect))
638+ .and(render())
449639 }
450640 Event::Connect => {
641+ // Connect restarts the auth flow, so whatever challenge/verify was
642+ // in flight is now stale and its guard must come off. Retiring the
643+ // generation as we do it is what makes that safe: the shell fires
644+ // Connect on every return to the foreground, so the round-trip we
645+ // are dropping here is usually one that is still very much alive
646+ // and about to resolve. Clearing the guard alone would let it land
647+ // and install its token on top of the attempt replacing it.
648+ model.abandon_auth();
451649 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
452650 Command::event(Event::Authenticate)
453651 } else {
@@ -484,6 +682,18 @@ impl App for Skrepka {
484682 if let Some(c) = model.contacts.get_mut(&peer) {
485683 c.blocked = blocked;
486684 }
685+ if blocked {
686+ // Gating *ingest* on the block only stops the acks we have not
687+ // queued yet. Anything already sitting in the outbox for this
688+ // peer — an ack for the very message that made the user block
689+ // them, a profile from the last broadcast — still flushes, and
690+ // an ack is precisely the "we are online and reading you"
691+ // signal the block is supposed to cut off. Drop it all: once
692+ // blocked, nothing more goes to this peer.
693+ model.outbox.retain(|item| item.recipient != peer);
694+ return Command::all([self.persist_contacts(model), self.persist_outbox(model)])
695+ .and(render());
696+ }
487697 self.persist_contacts(model).and(render())
488698 }
489699 Event::SaveProfile {
@@ -503,14 +713,23 @@ impl App for Skrepka {
503713 bio: model.profile.bio.clone(),
504714 photo: model.profile.photo.clone(),
505715 };
506− // Broadcast the new profile to every contact.
716+ // Broadcast the new profile to every contact we haven't blocked.
717+ // Blocking is meant to cut the peer off in both directions; a
718+ // profile broadcast would keep feeding them our display name,
719+ // avatar, and a liveness signal.
507720 //
508721 // Each item carries its own copy of the serialized payload, so a
509722 // profile with a photo is duplicated (base64 and all) once per
510723 // contact, in memory and in the `outbox` kv blob. Acceptable at
511724 // today's contact counts; the fix is to intern the payload and
512725 // have `OutboxItem` reference it by id.
513− for peer in model.contacts.keys().cloned().collect::<Vec<_>>() {
726+ let recipients: Vec<String> = model
727+ .contacts
728+ .values()
729+ .filter(|c| !c.blocked)
730+ .map(|c| c.pubkey.clone())
731+ .collect();
732+ for peer in recipients {
514733 model.outbox.push_back(OutboxItem {
515734 recipient: peer,
516735 envelope_json: protocol::serialize_payload(&payload, ts),
@@ -527,6 +746,15 @@ impl App for Skrepka {
527746
528747 // ---------------- auth ----------------
529748 Event::Authenticate => {
749+ // Exactly one auth round-trip at a time. `Authenticate` is reached
750+ // from startup, a 401 on poll, a 401 on send, a reconnect timer
751+ // and a server change — all of which can fire at once after a
752+ // network blip. Each one that got through would hit
753+ // `/auth/challenge`, and each token that came back would install
754+ // itself and kick off another poll loop.
755+ if model.authenticating {
756+ return Command::done();
757+ }
530758 let Some(id) = model.identity() else {
531759 return render();
532760 };
@@ -537,7 +765,13 @@ impl App for Skrepka {
537765 pubkey: &id.public_key_hex(),
538766 };
539767 match Http::post(url).body_json(&body) {
540− Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
768+ Ok(req) => {
769+ model.authenticating = true;
770+ let gen = model.next_auth_gen();
771+ req.build()
772+ .then_send(move |r| Event::ChallengeResult(gen, r))
773+ .and(render())
774+ }
541775 Err(_) => {
542776 model.conn = ConnStatus::Offline;
543777 model.error = Some("could not build auth request".into());
@@ -545,42 +779,67 @@ impl App for Skrepka {
545779 }
546780 }
547781 }
548− Event::ChallengeResult(Ok(mut resp)) => {
782+ Event::ChallengeResult(gen, res) => {
783+ // A challenge from an attempt we already gave up on (a relay
784+ // switch, a foreground `Connect`). Its `authenticating` is not
785+ // ours to clear and its challenge is not ours to sign.
786+ if gen != model.auth_gen {
787+ return Command::done();
788+ }
789+ // Cleared here and re-set only if the verify leg actually goes
790+ // out — every exit from this arm must leave the guard consistent.
791+ model.authenticating = false;
792+ let Ok(mut resp) = res else {
793+ model.conn = ConnStatus::Offline;
794+ return self.schedule_reconnect(model);
795+ };
549796 let bytes = resp.take_body().unwrap_or_default();
550797 let Some(id) = model.identity() else {
551798 return render();
552799 };
553− match serde_json::from_slice::<ChallengeResp>(&bytes) {
554− Ok(c) => {
555− let host = server_host(&model.settings.server_url);
556− let signature = id.sign_challenge(&host, &c.challenge);
557− let url = format!("{}/auth/verify", model.settings.server_url);
558− let body = VerifyReq {
559− pubkey: &id.public_key_hex(),
560− challenge: &c.challenge,
561− signature: &signature,
562− revoke_others: false,
563− };
564− match Http::post(url).body_json(&body) {
565− Ok(req) => req.build().then_send(Event::VerifyResult),
566− Err(_) => {
567− model.conn = ConnStatus::Offline;
568− model.error = Some("could not build auth request".into());
569− self.schedule_reconnect(model)
570− }
571− }
800+ let Ok(c) = serde_json::from_slice::<ChallengeResp>(&bytes) else {
801+ model.conn = ConnStatus::Offline;
802+ return self.schedule_reconnect(model);
803+ };
804+ let host = server_host(&model.settings.server_url);
805+ let Ok(signature) = id.sign_challenge(&host, &c.challenge) else {
806+ // An over-long challenge: a relay trying to get us to sign
807+ // something of its choosing. Back off rather than comply.
808+ model.conn = ConnStatus::Offline;
809+ model.error = Some("relay sent a malformed auth challenge".into());
810+ return self.schedule_reconnect(model);
811+ };
812+ let url = format!("{}/auth/verify", model.settings.server_url);
813+ let body = VerifyReq {
814+ pubkey: &id.public_key_hex(),
815+ challenge: &c.challenge,
816+ signature: &signature,
817+ revoke_others: false,
818+ };
819+ match Http::post(url).body_json(&body) {
820+ Ok(req) => {
821+ model.authenticating = true;
822+ // The verify leg belongs to the same attempt as the
823+ // challenge that produced it, so it keeps the generation
824+ // rather than opening a new one.
825+ req.build().then_send(move |r| Event::VerifyResult(gen, r))
572826 }
573827 Err(_) => {
574828 model.conn = ConnStatus::Offline;
829+ model.error = Some("could not build auth request".into());
575830 self.schedule_reconnect(model)
576831 }
577832 }
578833 }
579− Event::ChallengeResult(Err(_)) => {
580− model.conn = ConnStatus::Offline;
581− self.schedule_reconnect(model)
582− }
583− Event::VerifyResult(Ok(mut resp)) => {
834+ Event::VerifyResult(gen, res) => {
835+ if gen != model.auth_gen {
836+ return Command::done();
837+ }
838+ model.authenticating = false;
839+ let Ok(mut resp) = res else {
840+ model.conn = ConnStatus::Offline;
841+ return self.schedule_reconnect(model);
842+ };
584843 let bytes = resp.take_body().unwrap_or_default();
585844 match serde_json::from_slice::<VerifyResp>(&bytes) {
586845 Ok(v) if !v.token.is_empty() => {
@@ -598,10 +857,6 @@ impl App for Skrepka {
598857 }
599858 }
600859 }
601− Event::VerifyResult(Err(_)) => {
602− model.conn = ConnStatus::Offline;
603− self.schedule_reconnect(model)
604− }
605860
606861 // ---------------- poll ----------------
607862 Event::Poll => {
@@ -623,7 +878,16 @@ impl App for Skrepka {
623878 {
624879 Ok(req) => {
625880 model.polling = true;
626− req.build().then_send(Event::PollResult)
881+ let gen = model.next_poll_gen();
882+ // Arm the watchdog alongside the request: if this poll
883+ // never resolves, nothing else will ever clear `polling`.
884+ req.build()
885+ .then_send(move |r| Event::PollResult(gen, r))
886+ .and(
887+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
888+ .0
889+ .then_send(move |_| Event::PollWatchdog(gen)),
890+ )
627891 }
628892 Err(_) => {
629893 model.error = Some("could not build poll request".into());
@@ -631,8 +895,36 @@ impl App for Skrepka {
631895 }
632896 }
633897 }
634− Event::PollResult(Ok(mut resp)) => {
898+ Event::PollWatchdog(gen) => {
899+ // Only the poll this watchdog was armed for, and only while it is
900+ // still the current one and still in flight. A watchdog whose poll
901+ // already completed (or was superseded) is a stale timer and must
902+ // not touch a healthy successor.
903+ if gen != model.poll_gen || !model.polling {
904+ return Command::done();
905+ }
906+ // Retire it as we give up on it. The request itself is not
907+ // cancelled — nothing can cancel it — so if it *does* eventually
908+ // resolve, the generation check below is what stops it from
909+ // clearing the replacement poll's guard and forking the loop.
910+ model.abandon_poll();
911+ Command::event(Event::Poll)
912+ }
913+ Event::PollResult(gen, res) => {
914+ // A result for a poll we already abandoned. Dropping the page is
915+ // safe — and required. We never wrote a cursor for it, so the
916+ // relay still holds every message in it and the current poll will
917+ // be handed them again; whereas honouring it here would clear a
918+ // guard that now belongs to a *different*, live poll, and the
919+ // re-poll it chains would be a second loop re-polling itself
920+ // forever alongside the first.
921+ if gen != model.poll_gen {
922+ return Command::done();
923+ }
635924 model.polling = false;
925+ let Ok(mut resp) = res else {
926+ return self.backoff_poll(model);
927+ };
636928 let status = u16::from(resp.status());
637929 if status == 401 {
638930 model.token = None;
@@ -645,14 +937,15 @@ impl App for Skrepka {
645937 let bytes = resp.take_body().unwrap_or_default();
646938 let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
647939 let cmd = self.ingest_poll(model, parsed);
648− // Persist cursor and immediately re-poll (the server long-polled 25s).
649− cmd.and(KeyValue::set(K_CURSOR, json_bytes(&model.cursor)).then_send(Event::Saved))
650− .and(Command::event(Event::Poll))
651− .and(render())
652− }
653− Event::PollResult(Err(_)) => {
654− model.polling = false;
655− self.backoff_poll(model)
940+ // The re-poll hangs off the *cursor write*, not off this event.
941+ // Polling again is what acks the batch we just took — the server
942+ // deletes everything up to `cursor` — so it must not happen until
943+ // the batch is durable on our side. `SavedCursor` re-polls.
944+ cmd.and(
945+ KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
946+ .then_send(Event::SavedCursor),
947+ )
948+ .and(render())
656949 }
657950
658951 // ---------------- outbox ----------------
@@ -695,17 +988,38 @@ impl App for Skrepka {
695988 ConnStatus::Online => "online",
696989 };
697990
991+ let active_peer = model.active_peer.clone().unwrap_or_default();
992+
993+ // `view()` runs on every render, and `ComposeChanged` renders on every
994+ // keystroke. A contact's `photo` is a base64 avatar — tens of KiB each —
995+ // so cloning all of them per keypress meant megabytes of allocation and
996+ // bincode-encoding to type one message.
997+ //
998+ // Only the chat page has a composer (`ComposeChanged` is sent from
999+ // `ChatView` and nowhere else), so only the chat page is on that hot path
1000+ // — and while it is up, the only avatar on screen is the peer we are
1001+ // talking to. Every *other* page renders from a cold event, and the
1002+ // conversations list draws an avatar per row, so it gets the real photos.
1003+ // Withholding them there would not save a single keystroke's work; it
1004+ // would just replace every contact's picture with their initials.
1005+ let chat_open = model.page == Page::Chat;
1006+
6981007 let mut contacts: Vec<ContactVM> = model
6991008 .contacts
7001009 .values()
7011010 .map(|c| {
7021011 let msgs = model.messages.get(&c.pubkey);
7031012 let last = msgs.and_then(|m| m.last());
1013+ let photo = if chat_open && c.pubkey != active_peer {
1014+ String::new()
1015+ } else {
1016+ c.photo.clone().unwrap_or_default()
1017+ };
7041018 ContactVM {
7051019 pubkey: c.pubkey.clone(),
7061020 name: c.label(),
7071021 ob: hex_to_ob(&c.pubkey),
708− photo: c.photo.clone().unwrap_or_default(),
1022+ photo,
7091023 blocked: c.blocked,
7101024 last_message: last.map(|m| m.body.clone()).unwrap_or_default(),
7111025 last_ts: last.map_or(0, |m| m.ts),
@@ -714,7 +1028,6 @@ impl App for Skrepka {
7141028 .collect();
7151029 contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
7161030
717− let active_peer = model.active_peer.clone().unwrap_or_default();
7181031 let active_contact = model.contacts.get(&active_peer);
7191032 let messages: Vec<MessageVM> = model
7201033 .messages
@@ -760,6 +1073,20 @@ impl App for Skrepka {
7601073}
7611074
7621075impl Skrepka {
1076+ /// One of the five startup kv loads landed. Fires `Connect` on the last one.
1077+ ///
1078+ /// Connecting earlier would let a poll land — and be persisted — against a
1079+ /// model that is still missing its contacts, cursor, or outbox. The loads
1080+ /// still in flight would then overwrite what the poll just ingested, and the
1081+ /// stale `cursor` (0) would make us re-fetch mail we had already acked.
1082+ fn startup_done(&self, model: &mut Model) -> Command<Effect, Event> {
1083+ if model.startup_load_done() {
1084+ Command::event(Event::Connect)
1085+ } else {
1086+ Command::done()
1087+ }
1088+ }
1089+
7631090 fn persist_contacts(&self, model: &Model) -> Command<Effect, Event> {
7641091 let list: Vec<Contact> = model.contacts.values().cloned().collect();
7651092 KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
@@ -784,7 +1111,15 @@ impl Skrepka {
7841111 .and(render())
7851112 }
7861113
1114+ /// The poll loop stalled — retry after a backoff, and stop claiming to be online.
1115+ ///
1116+ /// Every path here (a transport error, a 5xx, a failed cursor write) means the loop
1117+ /// is not healthy, so leaving `conn` at `Online` both lies to the status dot and —
1118+ /// worse — makes `Connect` a no-op, because it stands down when it already believes
1119+ /// it is online. The shell fires `Connect` on every foreground precisely to recover
1120+ /// the long-poll that backgrounding killed; without this it recovers nothing.
7871121 fn backoff_poll(&self, model: &mut Model) -> Command<Effect, Event> {
1122+ model.conn = ConnStatus::Offline;
7881123 let delay = backoff_ms(model.poll_retries);
7891124 model.poll_retries = model.poll_retries.saturating_add(1);
7901125 Time::notify_after(Duration::from_millis(delay))
@@ -849,54 +1184,63 @@ impl Skrepka {
8491184 .and(render())
8501185 }
8511186
1187+ /// Encrypt and send the head of the outbox, discarding any unsendable items
1188+ /// ahead of it.
1189+ ///
1190+ /// The discards happen in one pass, followed by a single `persist_outbox`.
1191+ /// The previous shape recursed once per bad item and emitted a full outbox
1192+ /// rewrite each time, so a contact list holding `n` unusable keys cost
1193+ /// O(n) kv writes of an O(n) blob — and `n` stack frames — to reach the first
1194+ /// item that could actually go out.
8521195 fn flush_next(&self, model: &mut Model) -> Command<Effect, Event> {
8531196 if model.flushing {
8541197 return Command::done();
8551198 }
856− let Some(item) = model.outbox.front().cloned() else {
857− return Command::done();
858− };
8591199 let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
8601200 return Command::done();
8611201 };
862− // An unusable recipient key can never be encrypted to, so the item has to
863− // be dropped or it blocks the head of the outbox forever — but say so,
864− // rather than letting the message vanish.
865− let drop_item = |model: &mut Model| {
866− model.error = Some(format!(
867− "cannot send to {}: invalid key",
868− trunc_ob(&item.recipient)
869− ));
870− model.outbox.pop_front();
1202+
1203+ let mut dropped = false;
1204+ let sendable = loop {
1205+ let Some(item) = model.outbox.front() else {
1206+ break None;
1207+ };
1208+ match encrypt_for(&id, item) {
1209+ Some(blob) => break Some((item.recipient.clone(), blob)),
1210+ None => {
1211+ // An unusable recipient key can never be encrypted to, so the
1212+ // item has to go or it blocks the head of the outbox forever —
1213+ // but say so, rather than letting the message vanish.
1214+ model.error = Some(format!(
1215+ "cannot send to {}: invalid key",
1216+ trunc_ob(&item.recipient)
1217+ ));
1218+ model.outbox.pop_front();
1219+ dropped = true;
1220+ }
1221+ }
8711222 };
8721223
873− let Ok(recipient) = hex::decode(&item.recipient) else {
874− drop_item(model);
875− return self.persist_outbox(model).and(self.flush_next(model));
1224+ let persist = if dropped {
1225+ self.persist_outbox(model)
1226+ } else {
1227+ Command::done()
8761228 };
877− let mut recip = [0u8; 32];
878− if recipient.len() != 32 {
879− drop_item(model);
880− return self.persist_outbox(model).and(self.flush_next(model));
881− }
882− recip.copy_from_slice(&recipient);
8831229
884− let mut rng = rand_core::OsRng;
885− let blob = match crate::crypto::encrypt(&mut rng, &id, &recip, item.envelope_json.as_bytes())
886− {
887− // Reachable for a 32-byte key that isn't a curve point: `AddContact`
888− // rejects those now, but an older contact list may still hold one.
889− Ok(b) => b,
890− Err(_) => {
891− drop_item(model);
892− return self.persist_outbox(model).and(self.flush_next(model));
893− }
1230+ let Some((recipient, blob)) = sendable else {
1231+ // Nothing left to send. Render only if we dropped something, so the
1232+ // user sees why their message went away.
1233+ return if dropped {
1234+ persist.and(render())
1235+ } else {
1236+ Command::done()
1237+ };
8941238 };
8951239
8961240 model.flushing = true;
8971241 let batch = SendBatch {
8981242 messages: vec![Envelope {
899− to: item.recipient,
1243+ to: recipient,
9001244 encrypted_blob: hex::encode(blob),
9011245 }],
9021246 };
@@ -905,13 +1249,13 @@ impl Skrepka {
9051249 .header("authorization", format!("Bearer {token}"))
9061250 .body_json(&batch)
9071251 {
908− Ok(req) => req.build().then_send(Event::SendResult),
1252+ Ok(req) => persist.and(req.build().then_send(Event::SendResult)),
9091253 Err(_) => {
9101254 // Not a dead loop: the next StartFlush (from a poll page or a
9111255 // fresh send) retries the head of the outbox.
9121256 model.flushing = false;
9131257 model.error = Some("could not build send request".into());
914− render()
1258+ persist.and(render())
9151259 }
9161260 }
9171261 }
@@ -931,7 +1275,24 @@ impl Skrepka {
9311275 // sender hex -> message ids needing acks
9321276 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
9331277
1278+ let mut budget = MAX_POLL_TOTAL_BYTES;
9341279 for ev in page.events {
1280+ // Reject an oversized blob on its hex length, before it costs us a
1281+ // decode. No conforming sender produces one (the relay caps blobs at
1282+ // the same size), so this can only be junk.
1283+ if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
1284+ continue;
1285+ }
1286+ match budget.checked_sub(ev.encrypted_blob.len() / 2) {
1287+ Some(left) => budget = left,
1288+ None => {
1289+ // A page this large is a misbehaving relay, not real mail.
1290+ // Stop here; the rest of the page is discarded exactly as an
1291+ // undecryptable blob would be.
1292+ model.error = Some("relay sent an oversized poll page".into());
1293+ break;
1294+ }
1295+ }
9351296 let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
9361297 continue;
9371298 };
@@ -942,25 +1303,38 @@ impl Skrepka {
9421303 continue;
9431304 };
9441305 let sender = dec.sender_hex;
1306+
1307+ // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
1308+ // their texts. A blocked peer must not be able to rewrite their
1309+ // display name and avatar in our contact list, mark our messages
1310+ // delivered, or (via an ack we send back) learn that we are online
1311+ // and reading. So the gate sits above the payload match, not inside
1312+ // the `Text` arm.
1313+ if model.contacts.get(&sender).is_some_and(|c| c.blocked) {
1314+ continue;
1315+ }
1316+
9451317 // `ts` is chosen by the sender and drives conversation ordering,
9461318 // in-chat ordering, and the displayed time. Bound it to the near
9471319 // future so a peer cannot pin itself to the top of the list forever
9481320 // with `ts = i64::MAX`.
9491321 let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
950− let blocked = model.contacts.get(&sender).is_some_and(|c| c.blocked);
1322+ let known = model.contacts.contains_key(&sender);
9511323 match parsed.payload {
9521324 Payload::Text { id: msg_id, body } => {
953− // A blocked peer's messages are not displayed (PROTOCOL.md
954− // §4), so don't store them — and don't ack them either: an
955− // ack would tell them we are online and reading.
956− if blocked {
957− continue;
958− }
959− // auto-create contact
960− if !model.contacts.contains_key(&sender) {
961− model
962− .contacts
963− .insert(sender.clone(), Contact::new(sender.clone(), String::new(), ts));
1325+ // Auto-create a contact for a stranger who writes to us — but
1326+ // only up to `MAX_CONTACTS`. Past that, drop the message
1327+ // entirely: storing it would mean a `messages:<peer>` blob for
1328+ // a peer with no contact entry, i.e. a conversation nothing
1329+ // can open and nothing ever trims.
1330+ if !known {
1331+ if model.contacts.len() >= MAX_CONTACTS {
1332+ continue;
1333+ }
1334+ model.contacts.insert(
1335+ sender.clone(),
1336+ Contact::new(sender.clone(), String::new(), ts),
1337+ );
9641338 contacts_dirty = true;
9651339 }
9661340 let convo = model.messages.entry(sender.clone()).or_default();
@@ -1000,10 +1374,13 @@ impl Skrepka {
10001374 bio,
10011375 photo,
10021376 } => {
1003− let c = model
1004− .contacts
1005− .entry(sender.clone())
1006− .or_insert_with(|| Contact::new(sender.clone(), String::new(), ts));
1377+ // A bare profile from a stranger creates nothing. It is an
1378+ // unsolicited push with no message attached, so honouring it
1379+ // would let anyone holding our pubkey put an entry (with an
1380+ // avatar) in our contact list without ever saying a word.
1381+ let Some(c) = model.contacts.get_mut(&sender) else {
1382+ continue;
1383+ };
10071384 // Ignore stale profile replays (PROTOCOL.md §4). The clamped
10081385 // `ts` is what gets recorded, so a peer cannot park
10091386 // `last_profile_ts` at `i64::MAX` and freeze its own profile.
@@ -1018,14 +1395,26 @@ impl Skrepka {
10181395 }
10191396 }
10201397
1021− // Queue delivery acks (one payload per sender).
1398+ // Queue delivery acks, in batches the recipient will actually accept.
1399+ //
1400+ // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
1401+ // ids, and a peer running this same code applies that cap to *us*. One
1402+ // payload per sender therefore silently stops working the moment a peer
1403+ // has more than `MAX_ACK_IDS` messages waiting for us — which is exactly
1404+ // what a long offline stretch produces — and every one of those messages
1405+ // stays un-delivered on their side forever, because a poll page is only
1406+ // ever acked once.
10221407 let ack_ts = now_ms();
10231408 for (sender, ids) in ack_targets {
1024− let payload = Payload::DeliveryAck { ack_ids: ids };
1025− model.outbox.push_back(OutboxItem {
1026− recipient: sender,
1027− envelope_json: protocol::serialize_payload(&payload, ack_ts),
1028− });
1409+ for batch in ids.chunks(protocol::MAX_ACK_IDS) {
1410+ let payload = Payload::DeliveryAck {
1411+ ack_ids: batch.to_vec(),
1412+ };
1413+ model.outbox.push_back(OutboxItem {
1414+ recipient: sender.clone(),
1415+ envelope_json: protocol::serialize_payload(&payload, ack_ts),
1416+ });
1417+ }
10291418 }
10301419
10311420 // Persist everything touched.
@@ -1042,6 +1431,16 @@ impl Skrepka {
10421431 }
10431432}
10441433
1434+/// Encrypt one outbox item to its recipient, or `None` if the recipient's key is
1435+/// unusable — a bad hex string, the wrong length, or 32 bytes that aren't a curve
1436+/// point. `AddContact` rejects all three now, but an older contact list (or a
1437+/// profile-broadcast fan-out over one) may still hold such a key.
1438+fn encrypt_for(sender: &Identity, item: &OutboxItem) -> Option<Vec<u8>> {
1439+ let recipient: [u8; 32] = hex::decode(&item.recipient).ok()?.try_into().ok()?;
1440+ let mut rng = rand_core::OsRng;
1441+ crate::crypto::encrypt(&mut rng, sender, &recipient, item.envelope_json.as_bytes()).ok()
1442+}
1443+
10451444fn backoff_ms(retries: u32) -> u64 {
10461445 (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
10471446}
@@ -1057,6 +1456,18 @@ fn trim_history(convo: &mut Vec<StoredMessage>) {
10571456}
10581457
10591458/// Decode a kv `get` result into a typed value, ignoring errors / absence.
1459+///
1460+/// `None` conflates three different things: the key is absent (a fresh install),
1461+/// the read failed, and the bytes are present but don't deserialize. The caller
1462+/// keeps its default in all three, and the next mutation writes that default back
1463+/// — so a blob we merely *failed to read* is destroyed rather than left alone.
1464+///
1465+/// The mitigation is upstream: every persisted type is `#[serde(default)]` (see
1466+/// `model.rs`), so adding a field can't make an old blob unreadable, which is by
1467+/// far the likeliest way this fires in practice. A genuinely corrupt blob is
1468+/// still lost. Fixing that properly means distinguishing the three cases here and
1469+/// refusing to overwrite on the third — a change to every caller and to the error
1470+/// surface, not a drive-by.
10601471fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
10611472 let bytes = res.ok().flatten()?;
10621473 serde_json::from_slice(&bytes).ok()
@@ -1070,7 +1481,7 @@ mod tests {
10701481 fn with_identity() -> Model {
10711482 let id = Identity::from_seed(&[3u8; 32]);
10721483 Model {
1073− secret_key: Some(id.secret_key.to_vec()),
1484+ secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
10741485 my_pubkey: id.public_key_hex(),
10751486 ..Default::default()
10761487 }
@@ -1080,6 +1491,24 @@ mod tests {
10801491 Identity::from_seed(&[seed; 32]).public_key_hex()
10811492 }
10821493
1494+ /// `Command::event` *queues* an event; it does not run it. The runtime drains
1495+ /// that queue back into `update`, so a test that stops at the first `update`
1496+ /// only ever sees half of what an event actually does — `Connect` would look
1497+ /// like it never authenticates, and the poll watchdog like it never re-polls.
1498+ /// Drain it the way the runtime does.
1499+ fn drive(app: &Skrepka, cmd: &mut Command<Effect, Event>, model: &mut Model) {
1500+ for event in cmd.events().collect::<Vec<_>>() {
1501+ let mut next = app.update(event, model);
1502+ drive(app, &mut next, model);
1503+ }
1504+ }
1505+
1506+ /// `update` + drain, i.e. what the shell actually observes.
1507+ fn dispatch(app: &Skrepka, event: Event, model: &mut Model) {
1508+ let mut cmd = app.update(event, model);
1509+ drive(app, &mut cmd, model);
1510+ }
1511+
10831512 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
10841513 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
10851514 let sender = Identity::from_seed(&[sender_seed; 32]);
@@ -1277,7 +1706,24 @@ mod tests {
12771706 fn server_host_strips_scheme_port_and_case() {
12781707 assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
12791708 assert_eq!(server_host("http://localhost:8080"), "localhost");
1280− assert_eq!(server_host("relay.example.com."), "relay.example.com");
1709+ assert_eq!(server_host("https://relay.example.com./x"), "relay.example.com");
1710+ // Its only caller passes `settings.server_url`, which is always an
1711+ // absolute URL (`normalize_server_url` guarantees it). Anything else is a
1712+ // bug, and an empty host produces a signature the relay rejects — which is
1713+ // the right failure: loud, not a silently-wrong host binding.
1714+ assert_eq!(server_host("relay.example.com"), "");
1715+ }
1716+
1717+ /// The auth signature is bound to the host (PROTOCOL.md §6), so `server_host`
1718+ /// has to agree with what the relay computes. Splitting on `:` by hand cut an
1719+ /// IPv6 literal at its first colon and produced `[`.
1720+ #[test]
1721+ fn server_host_handles_an_ipv6_literal() {
1722+ assert_eq!(server_host("http://[::1]:8080"), "[::1]");
1723+ assert_eq!(
1724+ server_host("https://[2001:DB8::1]/poll"),
1725+ "[2001:db8::1]"
1726+ );
12811727 }
12821728
12831729 #[test]
@@ -1371,16 +1817,63 @@ mod tests {
13711817 assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
13721818 }
13731819
1820+ /// Gating ingest stops the acks we have not queued yet — but the ack for the
1821+ /// message that made the user hit "block" is already in the outbox, and an ack
1822+ /// is exactly the "we are online and reading you" signal the block exists to
1823+ /// cut off.
1824+ #[test]
1825+ fn blocking_a_peer_drops_what_is_already_queued_for_them() {
1826+ let app = Skrepka;
1827+ let mut m = with_identity();
1828+ let enemy = peer_hex(9);
1829+ let friend = peer_hex(11);
1830+ m.contacts
1831+ .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
1832+
1833+ // An ack to the peer we are about to block, plus unrelated mail.
1834+ for recipient in [&enemy, &friend] {
1835+ m.outbox.push_back(OutboxItem {
1836+ recipient: recipient.clone(),
1837+ envelope_json: protocol::serialize_payload(
1838+ &Payload::DeliveryAck {
1839+ ack_ids: vec!["m1".into()],
1840+ },
1841+ 1,
1842+ ),
1843+ });
1844+ }
1845+
1846+ let _ = app.update(
1847+ Event::SetBlocked {
1848+ peer: enemy.clone(),
1849+ blocked: true,
1850+ },
1851+ &mut m,
1852+ );
1853+
1854+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
1855+ assert_eq!(
1856+ recipients,
1857+ vec![friend.as_str()],
1858+ "nothing more goes to a blocked peer — and everyone else is untouched"
1859+ );
1860+ }
1861+
13741862 #[test]
13751863 fn normalize_server_url_requires_an_http_scheme() {
1864+ // The real parser canonicalizes: scheme and host are lowercased.
13761865 assert_eq!(
13771866 normalize_server_url(" https://Relay.Example.com/ "),
1378− Some("https://Relay.Example.com".to_string())
1867+ Some("https://relay.example.com".to_string())
13791868 );
13801869 assert_eq!(
13811870 normalize_server_url("HTTP://localhost:8080///"),
13821871 Some("http://localhost:8080".to_string())
13831872 );
1873+ assert_eq!(
1874+ normalize_server_url("http://[::1]:8080"),
1875+ Some("http://[::1]:8080".to_string())
1876+ );
13841877 // The cases that used to panic Http::post or produce a dead URL.
13851878 assert_eq!(normalize_server_url("relay.example.com"), None);
13861879 assert_eq!(normalize_server_url("localhost:8080"), None);
@@ -1391,6 +1884,24 @@ mod tests {
13911884 assert_eq!(normalize_server_url(""), None);
13921885 }
13931886
1887+ /// The hand-rolled validator this replaces was not the parser it guarded:
1888+ /// it waved these through, and `crux_http` unwraps the parse.
1889+ #[test]
1890+ fn normalize_server_url_rejects_what_the_url_parser_rejects() {
1891+ assert_eq!(normalize_server_url("http://[bad"), None);
1892+ assert_eq!(normalize_server_url("http://ho^st"), None);
1893+ assert_eq!(normalize_server_url("http://a\u{7f}b"), None);
1894+ }
1895+
1896+ /// Every request appends a path, so a query or fragment would silently
1897+ /// swallow it: `http://x?y` + "/poll" is `http://x?y/poll` — a request to `/`
1898+ /// with a junk query, not to the poll endpoint.
1899+ #[test]
1900+ fn normalize_server_url_rejects_a_query_or_fragment() {
1901+ assert_eq!(normalize_server_url("http://x?y"), None);
1902+ assert_eq!(normalize_server_url("https://relay.example.com/#frag"), None);
1903+ }
1904+
13941905 #[test]
13951906 fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
13961907 let app = Skrepka;
@@ -1461,22 +1972,557 @@ mod tests {
14611972
14621973 let _ = app.update(Event::Poll, &mut m);
14631974 assert!(m.polling, "the first Poll issues a request");
1975+ let gen = m.poll_gen;
14641976
14651977 // A second Poll (e.g. from a re-auth) must not stack another loop.
14661978 let _ = app.update(Event::Poll, &mut m);
14671979 assert!(m.polling);
1980+ assert_eq!(m.poll_gen, gen, "and does not open a new generation");
14681981
14691982 // Both success and failure release the guard so the loop can continue.
14701983 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1471− let _ = app.update(Event::PollResult(Ok(resp)), &mut m);
1984+ let _ = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
14721985 assert!(!m.polling);
14731986
14741987 let _ = app.update(Event::Poll, &mut m);
14751988 assert!(m.polling);
1476− let _ = app.update(Event::PollResult(Err(crux_http::HttpError::Timeout)), &mut m);
1989+ let _ = app.update(
1990+ Event::PollResult(m.poll_gen, Err(crux_http::HttpError::Timeout)),
1991+ &mut m,
1992+ );
14771993 assert!(!m.polling);
14781994 }
14791995
1996+ /// The cursor is the *relay's* sequence number, and the next poll acks
1997+ /// everything up to it. Carrying relay A's cursor over to relay B tells B to
1998+ /// delete every message waiting for us before handing any of them over.
1999+ #[test]
2000+ fn switching_relays_resets_the_cursor() {
2001+ let app = Skrepka;
2002+ let mut m = with_identity();
2003+ m.cursor = now_ms();
2004+ m.token = Some("t".into());
2005+ m.conn = ConnStatus::Online;
2006+
2007+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
2008+
2009+ assert_eq!(m.cursor, 0, "the new relay is polled from the beginning");
2010+ assert!(m.token.is_none(), "the old relay's token is worthless");
2011+ assert_eq!(m.conn, ConnStatus::Offline);
2012+ }
2013+
2014+ /// Resetting the cursor is not enough on its own: the poll already in flight
2015+ /// is addressed to the *old* relay and its page carries the old relay's
2016+ /// sequence number. Letting it land writes that number straight back over the
2017+ /// reset, and the next poll hands it to the new relay — which reads it as an
2018+ /// ack and drops every message queued for us before serving any of them.
2019+ #[test]
2020+ fn switching_relays_retires_the_poll_still_in_flight_at_the_old_one() {
2021+ let app = Skrepka;
2022+ let mut m = with_identity();
2023+ m.token = Some("t".into());
2024+ m.conn = ConnStatus::Online;
2025+
2026+ let _ = app.update(Event::Poll, &mut m);
2027+ let old_relay_poll = m.poll_gen;
2028+ assert!(m.polling);
2029+
2030+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
2031+ assert!(!m.polling, "the old relay's poll no longer holds the guard");
2032+
2033+ // It resolves after the switch, carrying the old relay's cursor.
2034+ let body = serde_json::to_vec(&serde_json::json!({
2035+ "events": [],
2036+ "cursor": 1_700_000_000_000i64,
2037+ }))
2038+ .unwrap();
2039+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
2040+ let _ = app.update(Event::PollResult(old_relay_poll, Ok(resp)), &mut m);
2041+
2042+ assert_eq!(
2043+ m.cursor, 0,
2044+ "the new relay must not be handed a cursor from a sequence it never issued"
2045+ );
2046+ }
2047+
2048+ /// A URL that the parser rejects reaches `Http::post`, which unwraps it. On
2049+ /// disk it is a panic on every launch — a boot loop with no way out.
2050+ #[test]
2051+ fn a_corrupt_persisted_server_url_falls_back_to_the_default() {
2052+ let app = Skrepka;
2053+ let mut m = with_identity();
2054+ m.loads_pending = STARTUP_LOADS;
2055+
2056+ let junk = serde_json::to_vec(&serde_json::json!({"server_url": "http://[bad"})).unwrap();
2057+ let _ = app.update(Event::LoadedSettings(Ok(Some(junk))), &mut m);
2058+
2059+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
2060+ assert!(
2061+ normalize_server_url(&m.settings.server_url).is_some(),
2062+ "whatever we end up with must survive the parser"
2063+ );
2064+ }
2065+
2066+ /// A settings blob written before a field existed must still load — otherwise
2067+ /// `parse_kv` returns None, the default is used, and the next write destroys
2068+ /// what was there.
2069+ #[test]
2070+ fn an_old_settings_blob_still_loads() {
2071+ let app = Skrepka;
2072+ let mut m = with_identity();
2073+ m.loads_pending = STARTUP_LOADS;
2074+
2075+ let empty = serde_json::to_vec(&serde_json::json!({})).unwrap();
2076+ let _ = app.update(Event::LoadedSettings(Ok(Some(empty))), &mut m);
2077+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
2078+
2079+ // ...and a Contact missing every optional field still becomes a contact.
2080+ let peer = peer_hex(9);
2081+ let old = serde_json::to_vec(&serde_json::json!([{"pubkey": peer}])).unwrap();
2082+ let _ = app.update(Event::LoadedContacts(Ok(Some(old))), &mut m);
2083+ assert!(m.contacts.contains_key(&peer));
2084+ }
2085+
2086+ /// Connecting before the startup loads land lets a poll be ingested against a
2087+ /// half-loaded model — and the loads still in flight then overwrite it.
2088+ #[test]
2089+ fn connect_waits_for_every_startup_load() {
2090+ let app = Skrepka;
2091+ let mut m = with_identity();
2092+ m.loads_pending = STARTUP_LOADS;
2093+
2094+ let connects = |cmd: &mut Command<Effect, Event>| {
2095+ cmd.events()
2096+ .filter(|e| matches!(e, Event::Connect))
2097+ .count()
2098+ };
2099+
2100+ for (i, ev) in [
2101+ Event::LoadedSettings(Ok(None)),
2102+ Event::LoadedProfile(Ok(None)),
2103+ Event::LoadedContacts(Ok(None)),
2104+ Event::LoadedCursor(Ok(None)),
2105+ ]
2106+ .into_iter()
2107+ .enumerate()
2108+ {
2109+ let mut cmd = app.update(ev, &mut m);
2110+ assert_eq!(
2111+ m.loads_pending,
2112+ STARTUP_LOADS - 1 - i as u8,
2113+ "each load ticks the counter down"
2114+ );
2115+ assert_eq!(connects(&mut cmd), 0, "and none of them connects");
2116+ }
2117+
2118+ // The fifth — and only the fifth — emits Connect.
2119+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
2120+ assert_eq!(m.loads_pending, 0);
2121+ assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
2122+
2123+ // A late duplicate (a re-delivered load) must not connect again.
2124+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
2125+ assert_eq!(connects(&mut cmd), 0);
2126+ }
2127+
2128+ /// `Authenticate` is reachable from startup, a 401 on poll, a 401 on send and
2129+ /// a reconnect timer. Without a guard a network blip fires all of them, and
2130+ /// every token that comes back starts its own poll loop.
2131+ #[test]
2132+ fn authentication_does_not_stack() {
2133+ let app = Skrepka;
2134+ let mut m = with_identity();
2135+
2136+ let _ = app.update(Event::Authenticate, &mut m);
2137+ assert!(m.authenticating, "the first one goes out");
2138+ assert_eq!(m.conn, ConnStatus::Connecting);
2139+
2140+ // A second Authenticate while the first is in flight is a no-op.
2141+ m.conn = ConnStatus::Offline;
2142+ let _ = app.update(Event::Authenticate, &mut m);
2143+ assert_eq!(m.conn, ConnStatus::Offline, "no second challenge was issued");
2144+
2145+ // Any terminal outcome releases the guard.
2146+ let _ = app.update(
2147+ Event::ChallengeResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
2148+ &mut m,
2149+ );
2150+ assert!(!m.authenticating);
2151+
2152+ let _ = app.update(Event::Authenticate, &mut m);
2153+ assert!(m.authenticating);
2154+ let _ = app.update(
2155+ Event::VerifyResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
2156+ &mut m,
2157+ );
2158+ assert!(!m.authenticating);
2159+ }
2160+
2161+ /// The shell fires `Connect` on *every* return to the foreground
2162+ /// (`SkrepkaApp.swift`), so a challenge issued before the app was backgrounded
2163+ /// is routinely still in flight when the next attempt starts. It must not come
2164+ /// back and install a token, or clobber `conn`, on top of the attempt that
2165+ /// replaced it.
2166+ #[test]
2167+ fn an_auth_round_trip_superseded_by_connect_cannot_land() {
2168+ let app = Skrepka;
2169+ let mut m = with_identity();
2170+
2171+ let _ = app.update(Event::Authenticate, &mut m);
2172+ let superseded = m.auth_gen;
2173+ assert!(m.authenticating);
2174+
2175+ // Foregrounding restarts the flow.
2176+ dispatch(&app, Event::Connect, &mut m);
2177+ let live = m.auth_gen;
2178+ assert_ne!(live, superseded, "the in-flight attempt was retired");
2179+ assert!(m.authenticating, "and a fresh one went out");
2180+
2181+ // The old challenge finally answers. It is not ours any more.
2182+ let body = serde_json::to_vec(&serde_json::json!({"challenge": "deadbeef"})).unwrap();
2183+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
2184+ let _ = app.update(Event::ChallengeResult(superseded, Ok(resp)), &mut m);
2185+ assert!(
2186+ m.authenticating,
2187+ "the live attempt still holds the guard — a stale leg must not release it"
2188+ );
2189+
2190+ // Nor may a stale *verify* install a token over the live attempt.
2191+ let body = serde_json::to_vec(&serde_json::json!({"token": "stale"})).unwrap();
2192+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
2193+ let _ = app.update(Event::VerifyResult(superseded, Ok(resp)), &mut m);
2194+ assert!(m.token.is_none(), "no token from a superseded attempt");
2195+ assert_ne!(m.conn, ConnStatus::Online);
2196+
2197+ // The live attempt still completes normally.
2198+ let body = serde_json::to_vec(&serde_json::json!({"token": "real"})).unwrap();
2199+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
2200+ let _ = app.update(Event::VerifyResult(live, Ok(resp)), &mut m);
2201+ assert_eq!(m.token.as_deref(), Some("real"));
2202+ assert_eq!(m.conn, ConnStatus::Online);
2203+ }
2204+
2205+ /// Blocking is meant to cut a peer off in *both* directions.
2206+ #[test]
2207+ fn a_blocked_peer_cannot_update_their_profile_or_ack_our_messages() {
2208+ let app = Skrepka;
2209+ let mut m = with_identity();
2210+ let peer = peer_hex(9);
2211+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
2212+ contact.blocked = true;
2213+ contact.display_name = "Bob".into();
2214+ m.contacts.insert(peer.clone(), contact);
2215+ m.messages.insert(
2216+ peer.clone(),
2217+ vec![StoredMessage {
2218+ id: "mine".into(),
2219+ body: "hi".into(),
2220+ ts: 1,
2221+ outgoing: true,
2222+ delivered: false,
2223+ }],
2224+ );
2225+
2226+ let profile = Payload::Profile {
2227+ display_name: "Mallory".into(),
2228+ bio: "spam".into(),
2229+ photo: Some("aGk=".into()),
2230+ };
2231+ let page = page_from(9, &m, &profile, now_ms());
2232+ let _ = app.ingest_poll(&mut m, page);
2233+ assert_eq!(
2234+ m.contacts[&peer].display_name, "Bob",
2235+ "a blocked peer cannot rewrite their entry in our contact list"
2236+ );
2237+
2238+ let ack = Payload::DeliveryAck {
2239+ ack_ids: vec!["mine".into()],
2240+ };
2241+ let page = page_from(9, &m, &ack, now_ms());
2242+ let _ = app.ingest_poll(&mut m, page);
2243+ assert!(
2244+ !m.messages[&peer][0].delivered,
2245+ "nor tell us they read what we sent before the block"
2246+ );
2247+ }
2248+
2249+ /// Blocking someone and then updating our profile must not hand them our new
2250+ /// display name, avatar, and a liveness signal.
2251+ #[test]
2252+ fn save_profile_does_not_broadcast_to_blocked_contacts() {
2253+ let app = Skrepka;
2254+ let mut m = with_identity();
2255+ let friend = peer_hex(9);
2256+ let enemy = peer_hex(11);
2257+ m.contacts
2258+ .insert(friend.clone(), Contact::new(friend.clone(), "Bob".into(), 0));
2259+ let mut blocked = Contact::new(enemy.clone(), "Mallory".into(), 0);
2260+ blocked.blocked = true;
2261+ m.contacts.insert(enemy.clone(), blocked);
2262+
2263+ let _ = app.update(
2264+ Event::SaveProfile {
2265+ display_name: "Alice".into(),
2266+ bio: String::new(),
2267+ photo: None,
2268+ },
2269+ &mut m,
2270+ );
2271+
2272+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
2273+ assert_eq!(recipients, vec![friend.as_str()]);
2274+ }
2275+
2276+ /// Any stranger can create a contact just by writing to us, so the list needs
2277+ /// a ceiling — and past it, the message goes too: a stored conversation with
2278+ /// no contact entry is one nothing can open and nothing ever trims.
2279+ #[test]
2280+ fn contacts_from_strangers_are_capped() {
2281+ let app = Skrepka;
2282+ let mut m = with_identity();
2283+ for i in 0..MAX_CONTACTS {
2284+ let k = format!("{i:064x}");
2285+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
2286+ }
2287+ assert_eq!(m.contacts.len(), MAX_CONTACTS);
2288+
2289+ let peer = peer_hex(9);
2290+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
2291+ let _ = app.ingest_poll(&mut m, page);
2292+
2293+ assert_eq!(m.contacts.len(), MAX_CONTACTS, "the cap holds");
2294+ assert!(!m.contacts.contains_key(&peer));
2295+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
2296+ assert!(m.outbox.is_empty(), "and nothing is acked back");
2297+ }
2298+
2299+ /// A bare profile is an unsolicited push with no message attached. Honouring
2300+ /// it would let anyone holding our pubkey install an entry — with an avatar —
2301+ /// in our contact list without ever saying a word.
2302+ #[test]
2303+ fn a_profile_from_a_stranger_does_not_create_a_contact() {
2304+ let app = Skrepka;
2305+ let mut m = with_identity();
2306+ let peer = peer_hex(9);
2307+
2308+ let profile = Payload::Profile {
2309+ display_name: "Mallory".into(),
2310+ bio: String::new(),
2311+ photo: Some("aGk=".into()),
2312+ };
2313+ let page = page_from(9, &m, &profile, now_ms());
2314+ let _ = app.ingest_poll(&mut m, page);
2315+ assert!(m.contacts.is_empty(), "no contact from a bare profile");
2316+
2317+ // But a profile from someone we *do* know still applies.
2318+ m.contacts
2319+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
2320+ let page = page_from(9, &m, &profile, now_ms());
2321+ let _ = app.ingest_poll(&mut m, page);
2322+ assert_eq!(m.contacts[&peer].display_name, "Mallory");
2323+ }
2324+
2325+ /// `polling` gates the whole long-poll loop and is cleared only by a
2326+ /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
2327+ /// silent forever with no visible error.
2328+ #[test]
2329+ fn the_watchdog_unwedges_a_poll_that_never_resolved() {
2330+ let app = Skrepka;
2331+ let mut m = with_identity();
2332+ m.token = Some("t".into());
2333+
2334+ let _ = app.update(Event::Poll, &mut m);
2335+ assert!(m.polling);
2336+ let wedged = m.poll_gen;
2337+
2338+ // The watchdog armed for *this* poll releases the guard and restarts the
2339+ // loop — which issues a fresh poll under a new generation.
2340+ dispatch(&app, Event::PollWatchdog(wedged), &mut m);
2341+ assert!(m.polling, "the loop restarted");
2342+ assert_ne!(m.poll_gen, wedged, "under a new generation");
2343+ }
2344+
2345+ /// A watchdog is armed per poll and nothing cancels the earlier ones, so a
2346+ /// timer for a poll that has already completed keeps firing. It must not
2347+ /// touch the healthy poll that replaced it.
2348+ #[test]
2349+ fn a_stale_watchdog_leaves_the_current_poll_alone() {
2350+ let app = Skrepka;
2351+ let mut m = with_identity();
2352+ m.token = Some("t".into());
2353+
2354+ let _ = app.update(Event::Poll, &mut m);
2355+ let first = m.poll_gen;
2356+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
2357+ let _ = app.update(Event::PollResult(first, Ok(resp)), &mut m);
2358+ let _ = app.update(Event::Poll, &mut m);
2359+ let current = m.poll_gen;
2360+ assert!(m.polling);
2361+
2362+ let _ = app.update(Event::PollWatchdog(first), &mut m);
2363+ assert!(m.polling, "the live poll is untouched");
2364+ assert_eq!(m.poll_gen, current, "and not superseded");
2365+ }
2366+
2367+ /// The regression the generation exists for.
2368+ ///
2369+ /// An HTTP effect cannot be cancelled, only abandoned — and it stays pending
2370+ /// across an iOS suspension while the wall clock runs on, so on resume the
2371+ /// watchdog gives up on a poll the shell is still about to resolve. If that
2372+ /// late `PollResult` were honoured it would clear `polling` — a guard that by
2373+ /// then belongs to the *replacement* poll — and chain a re-poll of its own.
2374+ /// Two loops, each re-polling itself forever, doubling on every recurrence.
2375+ #[test]
2376+ fn a_poll_abandoned_by_the_watchdog_cannot_fork_the_loop_when_it_lands() {
2377+ let app = Skrepka;
2378+ let mut m = with_identity();
2379+ m.token = Some("t".into());
2380+
2381+ let _ = app.update(Event::Poll, &mut m);
2382+ let abandoned = m.poll_gen;
2383+
2384+ // The watchdog gives up on it; the loop restarts under a new generation.
2385+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
2386+ let live = m.poll_gen;
2387+ assert!(m.polling && live != abandoned);
2388+
2389+ // Now the abandoned request finally resolves. It must be ignored outright.
2390+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
2391+ let mut cmd = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
2392+
2393+ assert!(m.polling, "the live poll still holds the guard");
2394+ assert_eq!(m.poll_gen, live);
2395+ assert_eq!(
2396+ cmd.events().count(),
2397+ 0,
2398+ "and the corpse chains nothing — no second loop"
2399+ );
2400+ }
2401+
2402+ /// Dropping an abandoned page loses nothing: its cursor was never written, so
2403+ /// the relay still holds every message in it and re-delivers them.
2404+ #[test]
2405+ fn an_abandoned_poll_page_is_not_ingested() {
2406+ let app = Skrepka;
2407+ let mut m = with_identity();
2408+ m.token = Some("t".into());
2409+ let peer = peer_hex(9);
2410+
2411+ let _ = app.update(Event::Poll, &mut m);
2412+ let abandoned = m.poll_gen;
2413+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
2414+ let body = serde_json::to_vec(&serde_json::json!({
2415+ "events": [{"encryptedBlob": page.events[0].encrypted_blob}],
2416+ "cursor": 12345,
2417+ }))
2418+ .unwrap();
2419+
2420+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
2421+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
2422+ let _ = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
2423+
2424+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
2425+ assert_eq!(m.cursor, 0, "and the stale cursor is not adopted");
2426+ }
2427+
2428+ /// `view()` runs per keystroke (`ComposeChanged` renders), so cloning every
2429+ /// contact's base64 avatar each time meant megabytes of allocation to type one
2430+ /// message. Only the chat page has a composer, and while it is up the only
2431+ /// avatar on screen is the peer we are talking to — so that is the only page
2432+ /// that withholds anything.
2433+ ///
2434+ /// The conversations list draws an avatar per row (`ConversationsView.row`).
2435+ /// Blanking photos there buys nothing — no keystroke renders that page — and
2436+ /// costs every contact their picture.
2437+ #[test]
2438+ fn photos_are_withheld_on_the_chat_page_and_nowhere_else() {
2439+ let app = Skrepka;
2440+ let mut m = with_identity();
2441+ let a = peer_hex(9);
2442+ let b = peer_hex(11);
2443+ for peer in [&a, &b] {
2444+ let mut c = Contact::new(peer.clone(), String::new(), 0);
2445+ c.photo = Some("aGVsbG8=".into());
2446+ m.contacts.insert(peer.clone(), c);
2447+ }
2448+ let photo_of = |vm: &ViewModel, k: &str| {
2449+ vm.contacts
2450+ .iter()
2451+ .find(|c| c.pubkey == k)
2452+ .map(|c| c.photo.clone())
2453+ .unwrap()
2454+ };
2455+
2456+ // Conversations list: every row keeps its avatar.
2457+ let vm = app.view(&m);
2458+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
2459+ assert_eq!(photo_of(&vm, &b), "aGVsbG8=");
2460+
2461+ // Open a chat: exactly that peer's photo, and no one else's — this is the
2462+ // page that re-renders on every keystroke.
2463+ m.page = Page::Chat;
2464+ m.active_peer = Some(a.clone());
2465+ let vm = app.view(&m);
2466+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
2467+ assert!(photo_of(&vm, &b).is_empty());
2468+ }
2469+
2470+ /// `parse_payload` drops a `delivery.ack` with more than `MAX_ACK_IDS` ids, and
2471+ /// a peer running this code applies that cap to us. One ack payload per sender
2472+ /// therefore stops working the moment a peer has more than the cap waiting for
2473+ /// us — a long offline stretch — and those messages stay un-delivered on their
2474+ /// side forever, because a page is only ever acked once.
2475+ #[test]
2476+ fn acks_are_batched_so_the_peer_can_actually_parse_them() {
2477+ let app = Skrepka;
2478+ let mut m = with_identity();
2479+ let peer = peer_hex(9);
2480+ m.contacts
2481+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
2482+
2483+ // One page holding more messages from one sender than fits in a single ack.
2484+ let count = protocol::MAX_ACK_IDS + 5;
2485+ let sender = Identity::from_seed(&[9u8; 32]);
2486+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
2487+ let events = (0..count)
2488+ .map(|i| {
2489+ let json = protocol::serialize_payload(&text(&format!("m{i}"), "hi"), now_ms());
2490+ let blob = crate::crypto::encrypt(
2491+ &mut rand_core::OsRng,
2492+ &sender,
2493+ &me.public_key(),
2494+ json.as_bytes(),
2495+ )
2496+ .unwrap();
2497+ PollEvent {
2498+ encrypted_blob: hex::encode(blob),
2499+ }
2500+ })
2501+ .collect();
2502+ let _ = app.ingest_poll(&mut m, PollResp { events, cursor: 1 });
2503+
2504+ // Local history is capped as always — but every message we *received* is
2505+ // acked, whether or not we chose to keep it.
2506+ assert_eq!(m.messages[&peer].len(), MAX_MESSAGES_PER_PEER);
2507+ assert_eq!(m.outbox.len(), 2, "the acks are split into parseable batches");
2508+
2509+ // And every batch really does survive the recipient's parser.
2510+ let mut acked = 0;
2511+ for item in &m.outbox {
2512+ assert_eq!(item.recipient, peer);
2513+ let parsed = protocol::parse_payload(item.envelope_json.as_bytes())
2514+ .expect("a peer must be able to parse the ack we send it");
2515+ match parsed.payload {
2516+ Payload::DeliveryAck { ack_ids } => {
2517+ assert!(ack_ids.len() <= protocol::MAX_ACK_IDS);
2518+ acked += ack_ids.len();
2519+ }
2520+ _ => panic!("expected a delivery.ack"),
2521+ }
2522+ }
2523+ assert_eq!(acked, count, "and between them they ack the whole page");
2524+ }
2525+
14802526 #[test]
14812527 fn view_exposes_identity_and_ob() {
14822528 let app = Skrepka;
core/src/crypto.rs+231−29
@@ -7,14 +7,22 @@
77//! The X25519 keys are derived from Ed25519 the same way libsodium's
88//! `crypto_sign_ed25519_{pk,sk}_to_curve25519` do, so blobs interoperate with
99//! any client following the spec.
10+//!
11+//! Everything an attacker controls is bounded here, because this is the first
12+//! code a hostile relay or peer reaches: the blob is length-capped before any
13+//! work is done on it, and the zstd frame inside is decompressed through a
14+//! limited reader so a compression bomb cannot exhaust memory.
15+
16+use std::io::Read;
1017
1118use chacha20poly1305::aead::Aead;
12−use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
19+use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
1320use curve25519_dalek::edwards::CompressedEdwardsY;
1421use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
1522use hkdf::Hkdf;
1623use rand_core::{CryptoRng, RngCore};
1724use sha2::{Digest, Sha256, Sha512};
25+use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
1826
1927/// HKDF `info` string (PROTOCOL.md §3 / Constants.hkdfInfo). Fixed.
2028const HKDF_INFO: &[u8] = b"skrepka-v1";
@@ -26,14 +34,43 @@ pub const ED25519_PUBLIC_LEN: usize = 32;
2634pub const NONCE_LEN: usize = 24;
2735/// 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 16 (AEAD tag).
2836pub const MIN_BLOB_LEN: usize = 32 + NONCE_LEN + 32 + 64 + 16;
37+/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
38+/// rejected on length before it costs us a scalar mult or an AEAD pass.
39+///
40+/// Deliberately looser than the reference relay rather than equal to it: that
41+/// relay's `maxBlobLen` (41943040) bounds the *hex* string, i.e. ~20 MiB decoded,
42+/// so no blob it serves comes close to this. Sizing the client's ceiling to one
43+/// relay's would make us reject blobs a differently-configured relay may legally
44+/// deliver, and it buys no safety — the real bounds on attacker-controlled work
45+/// are `MAX_POLL_TOTAL_BYTES` (the page) and `MAX_PLAINTEXT_LEN` (the
46+/// decompression), both of which hold whatever this is set to.
47+pub const MAX_BLOB_LEN: usize = 42 * 1024 * 1024;
48+/// Upper bound on the *decompressed* plaintext inside a blob.
49+///
50+/// `zstd` frames advertise their content size, but a hostile peer can lie or
51+/// simply craft a small frame that expands to gigabytes. Without a cap the
52+/// decoder happily allocates all of it and iOS jetsams the app. Payloads are
53+/// JSON (text bodies, a base64 avatar at worst), so 1 MiB is generous.
54+pub const MAX_PLAINTEXT_LEN: usize = 1 << 20;
55+/// Upper bound on a server-supplied auth challenge we are willing to sign.
56+/// We sign whatever the relay hands us, so it must not be able to make us sign
57+/// an arbitrarily large blob of its choosing.
58+pub const MAX_CHALLENGE_LEN: usize = 256;
2959
3060#[derive(Debug, Clone, PartialEq, Eq)]
3161pub enum CryptoError {
3262 BadKeyLength,
3363 BlobTooShort,
64+ BlobTooLong,
65+ /// A 32-byte key that is not a valid curve point.
66+ InvalidPublicKey,
67+ /// The X25519 exchange produced an all-zero shared secret (low-order point).
68+ InvalidEphemeralKey,
69+ Encrypt,
3470 Decrypt,
3571 BadSignature,
3672 Decompress,
73+ ChallengeTooLong,
3774}
3875
3976impl std::fmt::Display for CryptoError {
@@ -41,9 +78,14 @@ impl std::fmt::Display for CryptoError {
4178 let s = match self {
4279 CryptoError::BadKeyLength => "bad key length",
4380 CryptoError::BlobTooShort => "blob too short",
81+ CryptoError::BlobTooLong => "blob too long",
82+ CryptoError::InvalidPublicKey => "invalid public key",
83+ CryptoError::InvalidEphemeralKey => "invalid ephemeral key",
84+ CryptoError::Encrypt => "encryption failed",
4485 CryptoError::Decrypt => "decryption failed",
4586 CryptoError::BadSignature => "signature verification failed",
4687 CryptoError::Decompress => "decompression failed",
88+ CryptoError::ChallengeTooLong => "auth challenge too long",
4789 };
4890 f.write_str(s)
4991 }
@@ -53,26 +95,43 @@ impl std::error::Error for CryptoError {}
5395
5496/// A generated identity: the 64-byte libsodium-form secret key. The public key
5597/// is the last 32 bytes.
56−#[derive(Clone)]
98+///
99+/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
100+/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
101+#[derive(Clone, ZeroizeOnDrop)]
57102pub struct Identity {
58103 pub secret_key: [u8; ED25519_SECRET_LEN],
59104}
60105
61106impl Identity {
62107 /// Build from a 64-byte secret key (e.g. loaded from the Keychain).
108+ ///
109+ /// The trailing 32 bytes are *claimed* to be the public key; we re-derive it
110+ /// from the seed and check. A mismatched pair would otherwise sign under one
111+ /// key while advertising another — every message we send would be silently
112+ /// unverifiable at the recipient.
63113 pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
64114 if bytes.len() != ED25519_SECRET_LEN {
65115 return Err(CryptoError::BadKeyLength);
66116 }
67117 let mut secret_key = [0u8; ED25519_SECRET_LEN];
68118 secret_key.copy_from_slice(bytes);
119+
120+ let mut seed = Zeroizing::new([0u8; 32]);
121+ seed.copy_from_slice(&secret_key[..32]);
122+ let derived = SigningKey::from_bytes(&seed);
123+ if derived.verifying_key().as_bytes() != &secret_key[32..] {
124+ secret_key.zeroize();
125+ return Err(CryptoError::BadKeyLength);
126+ }
127+
69128 Ok(Identity { secret_key })
70129 }
71130
72131 /// Generate a fresh identity from a CSPRNG.
73132 pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
74− let mut seed = [0u8; 32];
75− rng.fill_bytes(&mut seed);
133+ let mut seed = Zeroizing::new([0u8; 32]);
134+ rng.fill_bytes(&mut *seed);
76135 Self::from_seed(&seed)
77136 }
78137
@@ -97,34 +156,43 @@ impl Identity {
97156 }
98157
99158 fn signing_key(&self) -> SigningKey {
100− let mut seed = [0u8; 32];
159+ let mut seed = Zeroizing::new([0u8; 32]);
101160 seed.copy_from_slice(&self.secret_key[..32]);
102161 SigningKey::from_bytes(&seed)
103162 }
104163
105164 /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
106165 /// `server_host` must already be the bare lowercased hostname.
107− pub fn sign_challenge(&self, server_host: &str, challenge: &str) -> String {
166+ ///
167+ /// The challenge comes straight off the wire from a relay we have not
168+ /// authenticated, so its length is bounded before it goes into the hash.
169+ pub fn sign_challenge(&self, server_host: &str, challenge: &str) -> Result<String, CryptoError> {
170+ if challenge.len() > MAX_CHALLENGE_LEN {
171+ return Err(CryptoError::ChallengeTooLong);
172+ }
108173 let message = format!("{AUTH_TAG}{server_host}:{challenge}");
109174 let sig = self.signing_key().sign(message.as_bytes());
110− hex::encode(sig.to_bytes())
175+ Ok(hex::encode(sig.to_bytes()))
111176 }
112177}
113178
114179/// libsodium `crypto_sign_ed25519_pk_to_curve25519`: Edwards Y -> Montgomery u.
115180fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
116181 let compressed = CompressedEdwardsY(*ed_pub);
117− let point = compressed.decompress().ok_or(CryptoError::Decrypt)?;
182+ let point = compressed
183+ .decompress()
184+ .ok_or(CryptoError::InvalidPublicKey)?;
118185 Ok(point.to_montgomery().to_bytes())
119186}
120187
121188/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
122−fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> [u8; 32] {
189+fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> Zeroizing<[u8; 32]> {
123190 let mut h = Sha512::new();
124191 h.update(&secret_key[..32]);
125− let digest = h.finalize();
126− let mut scalar = [0u8; 32];
192+ let mut digest = h.finalize();
193+ let mut scalar = Zeroizing::new([0u8; 32]);
127194 scalar.copy_from_slice(&digest[..32]);
195+ digest.as_mut_slice().zeroize();
128196 // X25519 clamp.
129197 scalar[0] &= 248;
130198 scalar[31] &= 127;
@@ -133,18 +201,31 @@ fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> [u8; 32] {
133201}
134202
135203/// Raw X25519 scalar multiplication (no hashing), as libsodium `crypto_scalarmult`.
136−fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
137− x25519_dalek::x25519(*scalar, *point)
204+///
205+/// An all-zero output means the peer supplied a low-order point: every scalar
206+/// then yields the same shared secret, so the "encryption" would be forgeable by
207+/// anyone. libsodium's `crypto_scalarmult` rejects this and so do we.
208+fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, CryptoError> {
209+ let shared = Zeroizing::new(x25519_dalek::x25519(*scalar, *point));
210+ if shared.iter().all(|b| *b == 0) {
211+ return Err(CryptoError::InvalidEphemeralKey);
212+ }
213+ Ok(shared)
138214}
139215
140216/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v1", 32).
141−fn derive_key(raw_secret: &[u8; 32], eph_pub: &[u8; 32], recip_x_pub: &[u8; 32]) -> [u8; 32] {
217+fn derive_key(
218+ raw_secret: &[u8; 32],
219+ eph_pub: &[u8; 32],
220+ recip_x_pub: &[u8; 32],
221+) -> Zeroizing<[u8; 32]> {
142222 let mut salt = [0u8; 64];
143223 salt[..32].copy_from_slice(eph_pub);
144224 salt[32..].copy_from_slice(recip_x_pub);
145225 let hk = Hkdf::<Sha256>::new(Some(&salt), raw_secret);
146− let mut okm = [0u8; 32];
147− hk.expand(HKDF_INFO, &mut okm).expect("32 is a valid length");
226+ let mut okm = Zeroizing::new([0u8; 32]);
227+ hk.expand(HKDF_INFO, &mut *okm)
228+ .expect("32 is a valid length");
148229 okm
149230}
150231
@@ -153,8 +234,23 @@ fn compress(plaintext: &[u8]) -> Vec<u8> {
153234 zstd::encode_all(plaintext, 0).expect("zstd compression is infallible for in-memory input")
154235}
155236
237+/// Decompress a zstd frame, refusing to produce more than `MAX_PLAINTEXT_LEN`.
238+///
239+/// `zstd::decode_all` grows its output buffer to whatever the frame expands to,
240+/// which is attacker-chosen. Streaming through a `take`-limited reader bounds
241+/// the allocation instead: we read one byte past the cap so that a frame which
242+/// *would* overrun is detected rather than silently truncated.
156243fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
157− zstd::decode_all(data).map_err(|_| CryptoError::Decompress)
244+ let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
245+ let mut out = Vec::new();
246+ (&mut decoder)
247+ .take(MAX_PLAINTEXT_LEN as u64 + 1)
248+ .read_to_end(&mut out)
249+ .map_err(|_| CryptoError::Decompress)?;
250+ if out.len() > MAX_PLAINTEXT_LEN {
251+ return Err(CryptoError::Decompress);
252+ }
253+ Ok(out)
158254}
159255
160256/// Encrypt `plaintext` for `recipient_ed_pub` (32-byte Ed25519 pubkey).
@@ -168,12 +264,12 @@ pub fn encrypt(
168264 let sender_pub = sender.public_key();
169265
170266 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
171− let mut eph_priv = [0u8; 32];
172− rng.fill_bytes(&mut eph_priv);
173− let eph_pub = x25519_dalek::x25519(eph_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
267+ let mut eph_priv = Zeroizing::new([0u8; 32]);
268+ rng.fill_bytes(&mut *eph_priv);
269+ let eph_pub = x25519_dalek::x25519(*eph_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
174270
175271 let recip_x = ed25519_pk_to_x25519(recipient_ed_pub)?;
176− let raw_secret = x25519(&eph_priv, &recip_x);
272+ let raw_secret = x25519(&eph_priv, &recip_x)?;
177273 let key = derive_key(&raw_secret, &eph_pub, &recip_x);
178274
179275 let compressed = compress(plaintext);
@@ -192,10 +288,11 @@ pub fn encrypt(
192288
193289 let mut nonce = [0u8; NONCE_LEN];
194290 rng.fill_bytes(&mut nonce);
195− let cipher = XChaCha20Poly1305::new(&key.into());
291+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
196292 let ciphertext = cipher
197293 .encrypt(XNonce::from_slice(&nonce), inner.as_ref())
198− .map_err(|_| CryptoError::Decrypt)?;
294+ .map_err(|_| CryptoError::Encrypt)?;
295+ inner.zeroize();
199296
200297 let mut blob = Vec::with_capacity(32 + NONCE_LEN + ciphertext.len());
201298 blob.extend_from_slice(&eph_pub);
@@ -216,6 +313,9 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
216313 if blob.len() < MIN_BLOB_LEN {
217314 return Err(CryptoError::BlobTooShort);
218315 }
316+ if blob.len() > MAX_BLOB_LEN {
317+ return Err(CryptoError::BlobTooLong);
318+ }
219319 let mut eph_pub = [0u8; 32];
220320 eph_pub.copy_from_slice(&blob[..32]);
221321 let mut nonce = [0u8; NONCE_LEN];
@@ -223,16 +323,17 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
223323 let ciphertext = &blob[32 + NONCE_LEN..];
224324
225325 let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
226− let raw_secret = x25519(&recip_x_priv, &eph_pub);
326+ let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
227327 let recip_x_pub = ed25519_pk_to_x25519(&recipient.public_key())?;
228328 let key = derive_key(&raw_secret, &eph_pub, &recip_x_pub);
229329
230− let cipher = XChaCha20Poly1305::new(&key.into());
231− let inner = cipher
330+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
331+ let mut inner = cipher
232332 .decrypt(XNonce::from_slice(&nonce), ciphertext)
233333 .map_err(|_| CryptoError::Decrypt)?;
234334
235335 if inner.len() < 32 + 64 {
336+ inner.zeroize();
236337 return Err(CryptoError::BlobTooShort);
237338 }
238339 let mut sender_pub = [0u8; 32];
@@ -279,6 +380,21 @@ mod tests {
279380 assert_eq!(id.public_key(), id2.public_key());
280381 }
281382
383+ /// A 64-byte key whose trailing pubkey doesn't match its seed would sign
384+ /// under one identity while claiming another.
385+ #[test]
386+ fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
387+ let id = Identity::from_seed(&[3u8; 32]);
388+ assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
389+
390+ let mut tampered = id.secret_key;
391+ tampered[32] ^= 0x01; // claim a different public key
392+ assert!(matches!(
393+ Identity::from_secret_bytes(&tampered),
394+ Err(CryptoError::BadKeyLength)
395+ ));
396+ }
397+
282398 #[test]
283399 fn round_trip_recovers_plaintext_and_sender() {
284400 let alice = Identity::generate(&mut rng(10));
@@ -337,6 +453,78 @@ mod tests {
337453 );
338454 }
339455
456+ /// An oversized blob is rejected on length alone — before it costs us a
457+ /// scalar mult, an AEAD pass, or a decompression.
458+ #[test]
459+ fn too_long_blob_errors() {
460+ let bob = Identity::generate(&mut rng(2));
461+ let huge = vec![0u8; MAX_BLOB_LEN + 1];
462+ assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
463+ }
464+
465+ /// The decompression bomb: a small zstd frame that expands past the cap must
466+ /// error out rather than allocate its way to a jetsam kill.
467+ #[test]
468+ fn a_decompression_bomb_is_refused() {
469+ let bomb = compress(&vec![0u8; MAX_PLAINTEXT_LEN + 1]);
470+ assert!(
471+ bomb.len() < 4096,
472+ "the frame really is small ({} bytes)",
473+ bomb.len()
474+ );
475+ assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
476+
477+ // ...and the same bomb wrapped in a well-formed, correctly signed blob
478+ // (i.e. from a real peer) dies at the same wall.
479+ let alice = Identity::generate(&mut rng(1));
480+ let bob = Identity::generate(&mut rng(2));
481+ let blob = encrypt(
482+ &mut rng(3),
483+ &alice,
484+ &bob.public_key(),
485+ &vec![0u8; MAX_PLAINTEXT_LEN + 1],
486+ )
487+ .unwrap();
488+ assert_eq!(decrypt(&bob, &blob).unwrap_err(), CryptoError::Decompress);
489+ }
490+
491+ /// A plaintext right at the cap still round-trips: the bound is inclusive.
492+ #[test]
493+ fn a_plaintext_at_the_cap_still_round_trips() {
494+ let alice = Identity::generate(&mut rng(1));
495+ let bob = Identity::generate(&mut rng(2));
496+ let msg = vec![7u8; MAX_PLAINTEXT_LEN];
497+ let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
498+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext, msg);
499+ }
500+
501+ /// A low-order ephemeral point makes every recipient derive the same shared
502+ /// secret, so the sender's "encryption" is forgeable. Reject it.
503+ #[test]
504+ fn a_low_order_ephemeral_point_is_rejected() {
505+ let bob = Identity::generate(&mut rng(2));
506+ // eph_pub = 0 is the canonical low-order point; x25519 with it yields
507+ // an all-zero shared secret for any scalar.
508+ let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
509+ blob[..32].fill(0);
510+ assert_eq!(
511+ decrypt(&bob, &blob).unwrap_err(),
512+ CryptoError::InvalidEphemeralKey
513+ );
514+ }
515+
516+ /// A recipient key that isn't a curve point can't be encrypted to, and the
517+ /// error says so rather than blaming decryption.
518+ #[test]
519+ fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
520+ let alice = Identity::generate(&mut rng(1));
521+ let not_a_point = [0xabu8; 32];
522+ assert_eq!(
523+ encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
524+ CryptoError::InvalidPublicKey
525+ );
526+ }
527+
340528 #[test]
341529 fn sk_to_x25519_clamps() {
342530 let id = Identity::generate(&mut rng(5));
@@ -349,11 +537,25 @@ mod tests {
349537 #[test]
350538 fn challenge_signature_is_deterministic_and_host_bound() {
351539 let id = Identity::from_seed(&[7u8; 32]);
352− let a = id.sign_challenge("relay.example.com", "deadbeef");
353− let b = id.sign_challenge("relay.example.com", "deadbeef");
354− let c = id.sign_challenge("other.example.com", "deadbeef");
540+ let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
541+ let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
542+ let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
355543 assert_eq!(a, b, "Ed25519 is deterministic");
356544 assert_ne!(a, c, "signature is bound to the host");
357545 assert_eq!(a.len(), 128, "64-byte sig in hex");
358546 }
547+
548+ /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
549+ #[test]
550+ fn an_oversized_challenge_is_not_signed() {
551+ let id = Identity::from_seed(&[7u8; 32]);
552+ let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
553+ assert_eq!(
554+ id.sign_challenge("relay.example.com", &huge).unwrap_err(),
555+ CryptoError::ChallengeTooLong
556+ );
557+ // The bound itself is inclusive.
558+ let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
559+ assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
560+ }
359561}
core/src/ffi.rs+44−15
@@ -1,8 +1,21 @@
11//! The FFI surface the Swift shell calls, via BoltFFI. Mirrors the Crux
22//! counter example's `ffi.rs`: a `Bridge` that takes/returns bincode bytes.
3+//!
4+//! Nothing here may unwind. A panic crossing the Rust/Swift boundary is
5+//! undefined behavior, not a crash report — so every exported method runs its
6+//! body inside `catch_unwind` and turns a panic into an empty response. The
7+//! shell already tolerates an empty effect list (it just does nothing) and an
8+//! empty view (it renders the previous one), so a bug degrades into a stalled
9+//! UI rather than a corrupted process.
10+//!
11+//! Note the release profile deliberately does *not* set `panic = "abort"`:
12+//! aborting would fire before `catch_unwind` ever ran, which is strictly worse
13+//! than catching.
314
415#![allow(clippy::used_underscore_items)]
516
17+use std::panic::{catch_unwind, AssertUnwindSafe};
18+
619use crux_core::{
720 bridge::{Bridge, EffectId},
821 Core,
@@ -21,6 +34,15 @@ impl Default for CoreFFI {
2134 }
2235}
2336
37+/// Run `f`, converting a panic (or a bridge error) into an empty byte vec.
38+///
39+/// `AssertUnwindSafe` is sound here because a poisoned `Bridge` is never read
40+/// again for correctness: the worst case is a dropped effect batch, and the next
41+/// event starts a fresh `update`.
42+fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
43+ catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
44+}
45+
2446#[boltffi::export]
2547impl CoreFFI {
2648 #[must_use]
@@ -31,32 +53,39 @@ impl CoreFFI {
3153 }
3254
3355 /// Send an event to the app and return the resulting effects (bincode).
56+ /// Empty on a serialization failure — see the module docs.
3457 #[must_use]
3558 pub fn update(&self, data: &[u8]) -> Vec<u8> {
36− let mut effects = Vec::new();
37− match self.core.update(data, &mut effects) {
38− Ok(()) => effects,
39− Err(e) => panic!("{e}"),
40− }
59+ guard(|| {
60+ let mut effects = Vec::new();
61+ match self.core.update(data, &mut effects) {
62+ Ok(()) => effects,
63+ Err(_) => Vec::new(),
64+ }
65+ })
4166 }
4267
4368 /// Resolve an effect request and return the next batch of effects.
4469 #[must_use]
4570 pub fn resolve(&self, id: u32, data: &[u8]) -> Vec<u8> {
46− let mut effects = Vec::new();
47− match self.core.resolve(EffectId(id), data, &mut effects) {
48− Ok(()) => effects,
49− Err(e) => panic!("{e}"),
50− }
71+ guard(|| {
72+ let mut effects = Vec::new();
73+ match self.core.resolve(EffectId(id), data, &mut effects) {
74+ Ok(()) => effects,
75+ Err(_) => Vec::new(),
76+ }
77+ })
5178 }
5279
5380 /// Get the current serialized `ViewModel`.
5481 #[must_use]
5582 pub fn view(&self) -> Vec<u8> {
56− let mut view_model = Vec::new();
57− match self.core.view(&mut view_model) {
58− Ok(()) => view_model,
59− Err(e) => panic!("{e}"),
60− }
83+ guard(|| {
84+ let mut view_model = Vec::new();
85+ match self.core.view(&mut view_model) {
86+ Ok(()) => view_model,
87+ Err(_) => Vec::new(),
88+ }
89+ })
6190 }
6291}
core/src/model.rs+22−8
@@ -17,7 +17,15 @@ pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
1717/// `messages:<peer>` kv blob are trimmed to this many most-recent messages.
1818pub const MAX_MESSAGES_PER_PEER: usize = 1000;
1919
20+/// Every persisted struct is `#[serde(default)]`.
21+///
22+/// A kv blob that fails to deserialize is indistinguishable from an absent key
23+/// (see `app::parse_kv`), and the next mutation overwrites it — so a field added
24+/// in a later release would silently wipe the user's contacts, outbox, or
25+/// history on first launch. Defaulting the missing fields keeps old blobs
26+/// loadable across schema evolution instead.
2027#[derive(Serialize, Deserialize, Clone, Debug)]
28+#[serde(default)]
2129pub struct Settings {
2230 pub server_url: String,
2331}
@@ -31,6 +39,7 @@ impl Default for Settings {
3139}
3240
3341#[derive(Serialize, Deserialize, Clone, Debug, Default)]
42+#[serde(default)]
3443pub struct OwnProfile {
3544 pub display_name: String,
3645 pub bio: String,
@@ -38,7 +47,8 @@ pub struct OwnProfile {
3847 pub photo: Option<String>,
3948}
4049
41−#[derive(Serialize, Deserialize, Clone, Debug)]
50+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
51+#[serde(default)]
4252pub struct Contact {
4353 pub pubkey: String,
4454 pub nickname: String,
@@ -77,7 +87,8 @@ impl Contact {
7787 }
7888}
7989
80−#[derive(Serialize, Deserialize, Clone, Debug)]
90+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
91+#[serde(default)]
8192pub struct StoredMessage {
8293 pub id: String,
8394 pub body: String,
@@ -88,7 +99,8 @@ pub struct StoredMessage {
8899}
89100
90101/// A queued outbound payload awaiting encryption + send.
91−#[derive(Serialize, Deserialize, Clone, Debug)]
102+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
103+#[serde(default)]
92104pub struct OutboxItem {
93105 pub recipient: String,
94106 /// The serialized plaintext payload JSON (encrypted only at send time).
@@ -99,12 +111,14 @@ pub struct OutboxItem {
99111// @p helpers
100112// ---------------------------------------------------------------------------
101113
102−/// Full @p rendering of a hex pubkey.
114+/// Full @p rendering of a hex pubkey. Falls back to the input for anything that
115+/// isn't an even-length hex string — the @p encoding is only defined on byte
116+/// *pairs*, so an odd-length input has no syllable spelling at all.
103117pub fn hex_to_ob(hex_key: &str) -> String {
104− match hex::decode(hex_key) {
105− Ok(bytes) => phonemic::to_ob(&bytes),
106− Err(_) => hex_key.to_string(),
107− }
118+ hex::decode(hex_key)
119+ .ok()
120+ .and_then(|bytes| phonemic::to_ob(&bytes))
121+ .unwrap_or_else(|| hex_key.to_string())
108122}
109123
110124/// Truncated @p: first two and last two syllables (`a-b-…-y-z`).
core/src/phonemic.rs+35−18
@@ -57,18 +57,24 @@ fn suffix_index(s: &str) -> Option<u8> {
5757 SUFFIXES.iter().position(|p| *p == s).map(|i| i as u8)
5858}
5959
60−/// Encode bytes as a hyphen-joined `@p` string.
61−pub fn to_ob(bytes: &[u8]) -> String {
62− bytes
63− .chunks(2)
64− .map(|pair| {
65− let hi = pair[0] as usize;
66− // An odd trailing byte (never happens for 32-byte keys) reuses suffix 0.
67− let lo = pair.get(1).copied().unwrap_or(0) as usize;
68− format!("{}{}", PREFIXES[hi], SUFFIXES[lo])
69− })
70− .collect::<Vec<_>>()
71− .join("-")
60+/// Encode bytes as a hyphen-joined `@p` string. `None` for an odd-length input.
61+///
62+/// A syllable *is* a byte pair, so an odd trailing byte has no spelling. Padding
63+/// it with suffix 0 (as this used to) made the encoding non-injective: `[x]` and
64+/// `[x, 0]` produced the same @p, and `from_ob` decoded both back to `[x, 0]`.
65+/// Keys are always 32 bytes, so no caller is affected — but a display helper
66+/// must not quietly invent a byte.
67+pub fn to_ob(bytes: &[u8]) -> Option<String> {
68+ if !bytes.len().is_multiple_of(2) {
69+ return None;
70+ }
71+ Some(
72+ bytes
73+ .chunks_exact(2)
74+ .map(|pair| format!("{}{}", PREFIXES[pair[0] as usize], SUFFIXES[pair[1] as usize]))
75+ .collect::<Vec<_>>()
76+ .join("-"),
77+ )
7278}
7379
7480/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
@@ -135,17 +141,27 @@ mod tests {
135141 #[test]
136142 fn round_trip_32_bytes() {
137143 let key: Vec<u8> = (0u8..32).collect();
138− let ob = to_ob(&key);
144+ let ob = to_ob(&key).unwrap();
139145 assert_eq!(ob.split('-').count(), 16, "16 syllables for 32 bytes");
140146 assert_eq!(from_ob(&ob), Some(key));
141147 }
142148
143149 #[test]
144150 fn zero_key_is_dozzod_repeated() {
145− let ob = to_ob(&[0u8; 32]);
151+ let ob = to_ob(&[0u8; 32]).unwrap();
146152 assert!(ob.starts_with("dozzod-dozzod"));
147153 }
148154
155+ /// A syllable is a byte *pair*. Padding an odd trailing byte with suffix 0
156+ /// made `to_ob` non-injective: `[7]` and `[7, 0]` spelled the same, and
157+ /// `from_ob` decoded both to `[7, 0]`.
158+ #[test]
159+ fn odd_length_input_has_no_spelling() {
160+ assert_eq!(to_ob(&[7u8]), None);
161+ assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
162+ assert_eq!(to_ob(&[]), Some(String::new()));
163+ }
164+
149165 /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
150166 /// curve point, so tests can't use an arbitrary byte pattern.
151167 fn real_key() -> [u8; 32] {
@@ -160,7 +176,7 @@ mod tests {
160176 fn try_parse_accepts_hex_and_ob() {
161177 let key = real_key();
162178 let hex_str = hex::encode(key);
163− let ob = to_ob(&key);
179+ let ob = to_ob(&key).unwrap();
164180 assert_eq!(try_parse_pubkey(&hex_str), Some(hex_str.clone()));
165181 assert_eq!(try_parse_pubkey(&ob), Some(hex_str.clone()));
166182 assert_eq!(try_parse_pubkey(&hex_str.to_uppercase()), Some(hex_str));
@@ -180,17 +196,18 @@ mod tests {
180196 "fixture must really be off-curve"
181197 );
182198 // Both spellings of it: raw hex, and the @p it round-trips through.
199+ let ob = to_ob(&NOT_A_POINT).unwrap();
183200 assert_eq!(try_parse_pubkey(&hex::encode(NOT_A_POINT)), None);
184− assert_eq!(try_parse_pubkey(&to_ob(&NOT_A_POINT)), None);
201+ assert_eq!(try_parse_pubkey(&ob), None);
185202 // The syllable encoding itself is still lossless — only the key check fails.
186− assert_eq!(from_ob(&to_ob(&NOT_A_POINT)), Some(NOT_A_POINT.to_vec()));
203+ assert_eq!(from_ob(&ob), Some(NOT_A_POINT.to_vec()));
187204 }
188205
189206 #[test]
190207 fn ob_parsing_is_case_insensitive() {
191208 let key = real_key();
192209 let hex_str = hex::encode(key);
193− let ob = to_ob(&key);
210+ let ob = to_ob(&key).unwrap();
194211 assert_eq!(from_ob(&ob.to_uppercase()), Some(key.to_vec()));
195212 assert_eq!(try_parse_pubkey(&ob.to_uppercase()), Some(hex_str.clone()));
196213 // Mixed case, as a QR scan or a paste from a title-cased field might give.
core/src/protocol.rs+129−10
@@ -5,6 +5,23 @@
55
66use serde::{Deserialize, Serialize};
77
8+/// Caps on the attacker-chosen fields of an incoming payload.
9+///
10+/// The AEAD proves the blob came from *someone*, not that they are polite: a
11+/// peer can put a 40 MiB string in `body` or `bio` and we would store it, render
12+/// it, and write it back to disk on every subsequent message. Anything over the
13+/// cap is treated like any other malformed payload — dropped silently
14+/// (PROTOCOL.md §4).
15+pub const MAX_BODY_LEN: usize = 64 * 1024;
16+pub const MAX_DISPLAY_NAME_LEN: usize = 128;
17+pub const MAX_BIO_LEN: usize = 1024;
18+pub const MAX_PHOTO_LEN: usize = 64 * 1024;
19+/// Message ids are UUIDs; they are stored, compared, and echoed back in acks.
20+pub const MAX_ID_LEN: usize = 128;
21+/// Ids in one `delivery.ack`. We scan the conversation once per id, so an
22+/// unbounded list is O(n·m) work a peer can dictate for free.
23+pub const MAX_ACK_IDS: usize = 1024;
24+
825/// A decrypted plaintext payload. `photo` stays base64 (as on the wire).
926#[derive(Debug, Clone, PartialEq, Eq)]
1027pub enum Payload {
@@ -55,13 +72,21 @@ pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
5572 display_name,
5673 bio,
5774 photo,
58− } => serde_json::json!({
59− "type": "profile",
60− "display_name": display_name,
61− "bio": bio,
62− "photo": photo,
63− "ts": ts,
64− }),
75+ } => {
76+ let mut obj = serde_json::json!({
77+ "type": "profile",
78+ "display_name": display_name,
79+ "bio": bio,
80+ "ts": ts,
81+ });
82+ // Omit `photo` entirely when there isn't one, rather than sending an
83+ // explicit null: "no photo" and "field absent" mean the same thing to
84+ // a reader, and the null costs bytes in every broadcast.
85+ if let Some(photo) = photo {
86+ obj["photo"] = serde_json::Value::String(photo.clone());
87+ }
88+ obj
89+ }
6590 };
6691 value.to_string()
6792}
@@ -76,15 +101,23 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
76101 "text" => {
77102 let id = v.get("id")?.as_str()?.to_string();
78103 let body = v.get("body")?.as_str()?.to_string();
104+ if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
105+ return None;
106+ }
79107 Payload::Text { id, body }
80108 }
81109 "delivery.ack" => {
82− let ack_ids = v
83− .get("ack_ids")?
84− .as_array()?
110+ let raw = v.get("ack_ids")?.as_array()?;
111+ if raw.len() > MAX_ACK_IDS {
112+ return None;
113+ }
114+ let ack_ids: Vec<String> = raw
85115 .iter()
86116 .filter_map(|x| x.as_str().map(str::to_string))
87117 .collect();
118+ if ack_ids.iter().any(|id| id.len() > MAX_ID_LEN) {
119+ return None;
120+ }
88121 Payload::DeliveryAck { ack_ids }
89122 }
90123 "profile" => {
@@ -102,6 +135,12 @@ pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
102135 .get("photo")
103136 .and_then(|x| x.as_str())
104137 .map(str::to_string);
138+ if display_name.chars().count() > MAX_DISPLAY_NAME_LEN
139+ || bio.chars().count() > MAX_BIO_LEN
140+ || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
141+ {
142+ return None;
143+ }
105144 Payload::Profile {
106145 display_name,
107146 bio,
@@ -175,6 +214,86 @@ mod tests {
175214 assert_eq!(parse_payload(json.as_bytes()).unwrap().ts, 0);
176215 }
177216
217+ /// A peer can put anything inside the AEAD; the caps are what stop a 40 MiB
218+ /// `body` from being stored, rendered, and rewritten to disk forever.
219+ #[test]
220+ fn oversized_fields_are_dropped() {
221+ let big = "x".repeat(MAX_BODY_LEN + 1);
222+ let json = serialize_payload(
223+ &Payload::Text {
224+ id: "x".into(),
225+ body: big,
226+ },
227+ 1,
228+ );
229+ assert!(parse_payload(json.as_bytes()).is_none(), "body over cap");
230+
231+ let long_bio = serialize_payload(
232+ &Payload::Profile {
233+ display_name: "A".into(),
234+ bio: "b".repeat(MAX_BIO_LEN + 1),
235+ photo: None,
236+ },
237+ 1,
238+ );
239+ assert!(parse_payload(long_bio.as_bytes()).is_none(), "bio over cap");
240+
241+ let long_name = serialize_payload(
242+ &Payload::Profile {
243+ display_name: "n".repeat(MAX_DISPLAY_NAME_LEN + 1),
244+ bio: String::new(),
245+ photo: None,
246+ },
247+ 1,
248+ );
249+ assert!(parse_payload(long_name.as_bytes()).is_none(), "name over cap");
250+
251+ let big_photo = serialize_payload(
252+ &Payload::Profile {
253+ display_name: String::new(),
254+ bio: String::new(),
255+ photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
256+ },
257+ 1,
258+ );
259+ assert!(parse_payload(big_photo.as_bytes()).is_none(), "photo over cap");
260+
261+ let many_acks = serialize_payload(
262+ &Payload::DeliveryAck {
263+ ack_ids: vec!["a".to_string(); MAX_ACK_IDS + 1],
264+ },
265+ 1,
266+ );
267+ assert!(parse_payload(many_acks.as_bytes()).is_none(), "ack list over cap");
268+ }
269+
270+ /// The caps are inclusive: a payload sitting exactly at the limit still works.
271+ #[test]
272+ fn fields_at_the_cap_still_parse() {
273+ let at_cap = serialize_payload(
274+ &Payload::Text {
275+ id: "x".into(),
276+ body: "x".repeat(MAX_BODY_LEN),
277+ },
278+ 1,
279+ );
280+ assert!(parse_payload(at_cap.as_bytes()).is_some());
281+ }
282+
283+ /// `"photo": null` is noise on every profile broadcast; absent means absent.
284+ #[test]
285+ fn a_photoless_profile_omits_the_field() {
286+ let json = serialize_payload(
287+ &Payload::Profile {
288+ display_name: "A".into(),
289+ bio: "B".into(),
290+ photo: None,
291+ },
292+ 1,
293+ );
294+ assert!(!json.contains("photo"), "got: {json}");
295+ }
296+
178297 #[test]
179298 fn snake_case_field_names_on_wire() {
180299 let p = Payload::Profile {
install.sh+129−9
@@ -1,6 +1,16 @@
11#!/bin/sh
22set -eu
33
4+# `pipefail` is not in POSIX sh, so it cannot go in the `set -eu` above: dash
5+# before 0.5.12 (and other strict shells) abort on the unknown option, and with
6+# `set -e` that kills the install outright. Probe in a subshell and enable it only
7+# where it exists. It matters here — this script pipes `curl` into `gpg`, `tee`,
8+# and `tar`, and without pipefail a failed download is masked by the exit status
9+# of the *last* command in the pipe, so a 404 would sail through as success.
10+if (set -o pipefail 2>/dev/null); then
11+ set -o pipefail
12+fi
13+
414REPO="ilyakooo0/skrepka"
515INSTALL_BIN="/usr/local/bin/skrepka-server"
616UNIT_DEST="/etc/systemd/system/skrepka.service"
@@ -13,11 +23,44 @@ if [ -z "$DOMAIN" ]; then
1323 exit 1
1424fi
1525
26+# Normalize DOMAIN before it reaches the Caddyfile, the systemd unit, or
27+# --serverHost. Lowercase it, and strip a scheme, a path, and a :port if the
28+# operator pasted a URL. This is not cosmetic: --serverHost is the name clients
29+# bind their auth signature to ("skrepka-auth-v1:<host>:<challenge>", PROTOCOL.md
30+# §6) and the server compares it against the bare lowercased hostname, so
31+# "HTTPS://Relay.Example.com/" as-is would never match a client's signature. It is
32+# also the federation identity, which peers reject via isValidServerName unless it
33+# is lowercase and free of a trailing dot.
34+DOMAIN=$(printf '%s' "$DOMAIN" | tr 'A-Z' 'a-z' | sed 's|^[a-z][a-z0-9+.-]*://||; s|/.*||; s|:.*||; s|\.*$||')
35+
36+# Validate what is left is a plausible hostname. An unvalidated DOMAIN is
37+# interpolated into a Caddyfile and a systemd unit, so a stray space, quote, or
38+# newline is a config-injection vector, not just a typo.
39+case "$DOMAIN" in
40+ "" | *[!a-z0-9.-]* | -* | .* )
41+ echo "error: DOMAIN must be a valid hostname (letters, digits, '.' and '-'), got: '${DOMAIN}'" >&2
42+ echo "usage: DOMAIN=skrepka.example.com sh install.sh" >&2
43+ exit 1
44+ ;;
45+esac
46+
1647if [ "$(id -u)" -ne 0 ]; then
1748 echo "error: must be run as root" >&2
1849 exit 1
1950fi
2051
52+# SHA-256 of $1 on stdout, or empty when no hashing tool exists. Linux ships
53+# sha256sum (coreutils); macOS ships shasum instead.
54+sha256_of() {
55+ if command -v sha256sum >/dev/null 2>&1; then
56+ sha256sum "$1" | cut -d' ' -f1
57+ elif command -v shasum >/dev/null 2>&1; then
58+ shasum -a 256 "$1" | cut -d' ' -f1
59+ else
60+ echo ""
61+ fi
62+}
63+
2164detect_platform() {
2265 os=$(uname -s)
2366 arch=$(uname -m)
@@ -81,21 +124,60 @@ echo "Detected platform: ${PLATFORM}"
81124echo "Downloading latest release..."
82125
83126DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
127+CHECKSUM_URL="${DOWNLOAD_URL}.sha256"
84128
85129# Download to a temp file next to the destination, then rename. Writing straight
86130# to $INSTALL_BIN fails with ETXTBSY while the service is running; rename(2) is
87131# atomic and happily replaces a busy binary (the running process keeps the old
88132# inode until it is restarted below).
89133TMP_BIN="${INSTALL_BIN}.new.$$"
90−trap 'rm -f "$TMP_BIN"' EXIT INT TERM
134+TMP_SUM="${TMP_BIN}.sha256"
135+trap 'rm -f "$TMP_BIN" "$TMP_SUM"' EXIT INT TERM
91136
92−if command -v curl >/dev/null 2>&1; then
93− curl -fSL "$DOWNLOAD_URL" -o "$TMP_BIN"
94−elif command -v wget >/dev/null 2>&1; then
95− wget -q "$DOWNLOAD_URL" -O "$TMP_BIN"
137+# $1 = url, $2 = destination. Returns non-zero on any HTTP or transport error
138+# (curl -f, wget's default) so an optional download can be probed with `if`.
139+fetch_to() {
140+ if command -v curl >/dev/null 2>&1; then
141+ curl -fSL "$1" -o "$2"
142+ elif command -v wget >/dev/null 2>&1; then
143+ wget -q "$1" -O "$2"
144+ else
145+ echo "error: curl or wget required" >&2
146+ exit 1
147+ fi
148+}
149+
150+fetch_to "$DOWNLOAD_URL" "$TMP_BIN"
151+
152+# Verify the binary against the release's published SHA-256 before it is ever
153+# executed as root. This is the only integrity check in the chain: the download is
154+# HTTPS, but that authenticates GitHub's CDN, not the artifact — a compromised or
155+# swapped release asset would otherwise be installed and launched unquestioned.
156+#
157+# The check is best-effort by necessity: releases cut before CI began publishing
158+# .sha256 files have no checksum to compare against, and refusing to install from
159+# them would break upgrades from every existing deployment. So a *missing*
160+# checksum warns and proceeds, while a checksum that is present and does *not*
161+# match is fatal — the case that actually indicates tampering.
162+if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
163+ EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
164+ ACTUAL=$(sha256_of "$TMP_BIN")
165+ if [ -z "$ACTUAL" ]; then
166+ echo "warning: no sha256sum/shasum available; skipping checksum verification" >&2
167+ elif [ -z "$EXPECTED" ]; then
168+ echo "warning: checksum file is empty; skipping verification" >&2
169+ elif [ "$EXPECTED" != "$ACTUAL" ]; then
170+ echo "error: checksum verification FAILED for ${ASSET}" >&2
171+ echo " expected: ${EXPECTED}" >&2
172+ echo " actual: ${ACTUAL}" >&2
173+ echo "refusing to install a binary that does not match its published checksum" >&2
174+ exit 1
175+ else
176+ echo "Checksum verified (${ACTUAL})"
177+ fi
96178else
97− echo "error: curl or wget required" >&2
98− exit 1
179+ echo "warning: no published checksum at ${CHECKSUM_URL}; skipping verification" >&2
180+ echo " (releases predating checksum publication have none)" >&2
99181fi
100182
101183chmod 755 "$TMP_BIN"
@@ -112,6 +194,13 @@ Wants=network-online.target
112194
113195[Service]
114196Type=simple
197+# --bindHost keeps the relay on the loopback interface. Caddy terminates TLS and
198+# reverse-proxies to localhost:8080, so nothing needs to reach the backend
199+# directly — and it must not. The server trusts X-Forwarded-For for rate-limit
200+# and session-IP bucketing (--trustForwardedFor defaults to True, which is correct
201+# *behind* Caddy), so a backend reachable from the internet lets any client set
202+# that header itself and mint a fresh rate-limit bucket per request, defeating
203+# every per-IP limit at once.
115204# --serverHost is the federation identity AND the hostname clients bind their
116205# auth signature to ("skrepka-auth-v1:<host>:<challenge>"). It defaults to
117206# "localhost", which no client ever dials, so /auth/verify would reject every
@@ -119,7 +208,17 @@ Type=simple
119208# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
120209# envelope, otherwise the runtime's default 16 MiB body cap rejects a
121210# max-size blob with 413 before the handler's BlobHex check is ever reached.
122−ExecStart=/usr/local/bin/skrepka-server --serverHost=${DOMAIN} --http-max-body-bytes=42M
211+#
212+# WARNING: as of Knot 2026.6.26.1947 the runtime ACCEPTS but SILENTLY IGNORES
213+# --<name>=<value> constant overrides; only --debug, --help and
214+# --http-max-body-bytes are honoured at startup (constant overrides currently work
215+# only at `knot build` time). So --bindHost and --serverHost below are inert on
216+# today's released binary: it listens on 0.0.0.0:8080 regardless. They are kept
217+# because they are the documented interface and become effective the moment the
218+# runtime honours them. Until then the loopback restriction is enforced at the
219+# firewall instead (see the port-8080 rules below) — do not remove those on the
220+# assumption that --bindHost is doing the work.
221+ExecStart=/usr/local/bin/skrepka-server --bindHost=127.0.0.1 --serverHost=${DOMAIN} --http-max-body-bytes=42M
123222Restart=on-failure
124223RestartSec=5
125224
@@ -148,15 +247,36 @@ ${DOMAIN} {
148247}
149248CADDYEOF
150249
250+ # Ports 80/443 are the public surface (Caddy). Port 8080 — the relay backend —
251+ # must never be reachable from off-box: it speaks plain HTTP and trusts
252+ # X-Forwarded-For, so a direct caller can forge that header and bypass every
253+ # per-IP rate limit. This is normally the job of --bindHost=127.0.0.1 in the
254+ # unit above, but the current runtime ignores constant overrides (see the
255+ # WARNING there), so the binary really is listening on 0.0.0.0:8080 and the
256+ # firewall is what actually closes it. Loopback is unaffected by these rules —
257+ # ufw always permits the `lo` interface and firewalld puts it in the trusted
258+ # zone — so Caddy -> localhost:8080 keeps working.
151259 if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then
152− echo "Opening ports 80, 443 (ufw)..."
260+ echo "Opening ports 80, 443 and blocking 8080 (ufw)..."
153261 ufw allow 80/tcp
154262 ufw allow 443/tcp
263+ ufw deny 8080/tcp
155264 elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
156265 echo "Opening ports 80, 443 (firewalld)..."
157266 firewall-cmd --add-port=80/tcp --permanent
158267 firewall-cmd --add-port=443/tcp --permanent
268+ # firewalld denies anything not explicitly opened, so 8080 is already closed;
269+ # just make sure a previous install did not leave it open.
270+ firewall-cmd --remove-port=8080/tcp --permanent 2>/dev/null || true
159271 firewall-cmd --reload
272+ else
273+ echo "" >&2
274+ echo "WARNING: no active ufw/firewalld found — port 8080 was NOT firewalled." >&2
275+ echo " The relay backend listens on 0.0.0.0:8080 in plain HTTP and trusts" >&2
276+ echo " X-Forwarded-For, so anyone who can reach it directly can spoof that" >&2
277+ echo " header and bypass all per-IP rate limiting." >&2
278+ echo " Block inbound 8080 at your host or cloud firewall / security group." >&2
279+ echo "" >&2
160280 fi
161281
162282 systemctl daemon-reload
server.knot+293−52
@@ -72,6 +72,15 @@ messageMaxAge = 2592000000
7272-- Max length of a hex-encoded message body / opaque blob (40 MiB hex ≈ 20 MiB bytes).
7373maxBlobLen : Int
7474maxBlobLen = 41943040
75+-- Min length of a hex-encoded blob: 336 chars = 168 bytes. A well-formed blob is
76+-- `ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the ciphertext
77+-- seals at minimum `sender_pubkey (32) || signature (64)` plus the 16-byte
78+-- Poly1305 tag — so 32 + 24 + 32 + 64 + 16 = 168 bytes is the floor for a blob
79+-- any client could possibly decrypt (PROTOCOL.md §3). Anything shorter is
80+-- garbage by construction: it cannot carry a sender identity, and storing it only
81+-- burns mailbox rows and the recipient's poll bandwidth. Reject at the boundary.
82+minBlobLen : Int
83+minBlobLen = 336
7584-- Cap on items in a batched send request.
7685maxBatchSize : Int
7786maxBatchSize = 100
@@ -97,6 +106,18 @@ maxSessions = 10000
97106maxChallengesPerKey : Int
98107maxChallengesPerKey = 5
99108-- Per-pubkey ceiling on concurrent sessions.
109+--
110+-- LIMITATION — concurrent sessions for the *same* keypair are not supported, and
111+-- this cap does not make them safe; it only bounds how many can exist. The mailbox
112+-- is keyed by pubkey, not by session, and /poll treats a cursor advance as an ack
113+-- that *deletes* rows (`ackPolledMessages`), so two devices sharing one keypair
114+-- poll the same mailbox and each one's ack destroys messages the other has not
115+-- read yet — whichever polls first wins, and the loser silently never sees them.
116+-- The expected model is one keypair per device (PROTOCOL.md §2: "Each device
117+-- generates its own independent keypair and is treated as a separate identity").
118+-- The cap exists so a key cannot exhaust the global `maxSessions` pool, not to
119+-- make key-sharing work. Fixing key-sharing needs per-session delivery cursors
120+-- instead of destructive acks — out of scope for v0.1.
100121maxSessionsPerKey : Int
101122maxSessionsPerKey = 10
102123-- Validity window for an auth challenge before it must be re-issued (1 min).
@@ -176,9 +197,42 @@ forwardRateLimit = 600
176197trustForwardedFor : Bool
177198trustForwardedFor = True {}
178199
179−-- Master switch for federation; when False the server runs in single-node mode.
200+-- Master switch for federation; when False the server runs in single-node mode:
201+-- no gossip is emitted, no forwards are attempted, and both /federation/*
202+-- endpoints answer 403.
203+--
204+-- Defaults to False. Federation is unauthenticated by design (PROTOCOL.md §10) —
205+-- any host that can reach us may announce presence or inject a blob — so joining
206+-- the mesh is a deliberate operator decision, not something a default hands you.
207+-- Opt in with `--federationEnabled=True`.
180208federationEnabled : Bool
181−federationEnabled = True {}
209+federationEnabled = False {}
210+
211+-- Federation bootstrap seeds: comma-separated peer hostnames this server gossips
212+-- to unconditionally, e.g. `--seedPeers=relay1.example.com,relay2.example.com`.
213+--
214+-- Without seeds the mesh cannot form. Peers are otherwise *only* ever learned
215+-- from inbound gossip (a `*presence` row remembers the `server` that announced a
216+-- key), so two fresh relays that have never heard of each other both start with
217+-- an empty gossip target set and stay that way forever — neither one speaks
218+-- first. Seeding one side is enough to bootstrap a pair: our gossip carries
219+-- `fromServer`, so the seed learns of *us* from the first exchange and gossips
220+-- back from then on.
221+--
222+-- Typed `Text`, not `[Text]`: only scalar top-level constants are startup-
223+-- overridable (see the header note), so the list travels as one comma-separated
224+-- scalar and `seedPeerList` splits it.
225+seedPeers : Text
226+seedPeers = ""
227+
228+-- `seedPeers` split on commas, trimmed, with blanks dropped. Deliberately *not*
229+-- filtered through `isBadServerName` here: every federation egress site applies
230+-- the deny-list itself, so a denylisted seed is refused at exactly the same choke
231+-- point as a denylisted gossip-learned peer, and the parse stays purely
232+-- syntactic.
233+seedPeerList : [Text]
234+seedPeerList = filter (\s -> s != "") (splitOnComma seedPeers)
235+
182236federationUrl : Text -> Text
183237federationUrl = \server -> federationScheme ++ server
184238
@@ -220,10 +274,15 @@ isValidBoundedHex = \maxLen s -> length s <= maxLen && isValidHex s
220274-- True when `s` is lowercase hex of exactly `exactLen` characters (even by definition).
221275isValidExactHex : Int -> Text -> Bool
222276isValidExactHex = \exactLen s -> length s == exactLen && isValidHex s
277+-- True when `s` is lowercase hex within [`minBlobLen`, `maxBlobLen`]. The floor
278+-- rejects blobs too short to be a real envelope; see `minBlobLen`.
279+isValidBlobHex : Text -> Bool
280+isValidBlobHex = \s -> length s >= minBlobLen && isValidBoundedHex maxBlobLen s
223281-- Hex-encoded Ed25519 public key (32 bytes → 64 chars exact).
224282type PubkeyHex = Text where isValidExactHex 64
225−-- Hex-encoded ciphertext or opaque blob; capped at `maxBlobLen` (~20 MiB).
226−type BlobHex = Text where isValidBoundedHex maxBlobLen
283+-- Hex-encoded ciphertext or opaque blob; bounded to [`minBlobLen`, `maxBlobLen`]
284+-- (168 bytes ≈ 20 MiB).
285+type BlobHex = Text where isValidBlobHex
227286-- Hex-encoded Ed25519 signature (64 bytes → 128 chars exact).
228287type SignatureHex = Text where isValidExactHex 128
229288-- Normalized hostname (lowercased, no trailing dot, length-bounded). The SSRF
@@ -442,6 +501,28 @@ beforeColon = \s -> if s == "" || take 1 s == ":"
442501then ""
443502else take 1 s ++ beforeColon (drop 1 s)
444503
504+-- `s` up to and including its first `]` (the whole string if there is none).
505+throughBracket : Text -> Text
506+throughBracket = \s -> if s == ""
507+then ""
508+else if take 1 s == "]"
509+then "]"
510+else take 1 s ++ throughBracket (drop 1 s)
511+
512+-- The bare hostname the auth signature is bound to (PROTOCOL.md §6): `serverHost`
513+-- with an optional `:port` stripped.
514+--
515+-- Deliberately *not* `beforeColon`. A bracketed IPv6 literal is made of colons,
516+-- so cutting at the first one turns `[::1]:8080` into `[`. The client derives
517+-- this side of the contract with a real URL parser (`server_host`, core/src/app.rs)
518+-- and signs over `[::1]`, so truncating here would reject every signature an
519+-- IPv6-hosted relay is ever sent. For a DNS name or an IPv4 literal the two
520+-- functions agree character for character.
521+authHost : Text -> Text
522+authHost = \s -> if take 1 s == "["
523+then throughBracket s
524+else beforeColon s
525+
445526-- True when the host part is a bare integer (no dots) — a decimal or octal
446527-- "dword" IP like `2130706433` or `017700000001` (both = 127.0.0.1). These
447528-- carry no dotted octets, so the prefix list below never matches them, yet
@@ -467,6 +548,28 @@ decimalValueGo = \acc s -> if s == ""
467548then acc
468549else decimalValueGo (acc * 10 + digitValue (take 1 s)) (drop 1 s)
469550
551+-- Substring of `s` before / after its first ",". Same shape as `beforeDot` /
552+-- `afterDot`, on a different separator.
553+beforeComma : Text -> Text
554+beforeComma = \s -> if s == "" || take 1 s == ","
555+then ""
556+else take 1 s ++ beforeComma (drop 1 s)
557+afterComma : Text -> Text
558+afterComma = \s -> if s == ""
559+then ""
560+else if take 1 s == ","
561+then drop 1 s
562+else afterComma (drop 1 s)
563+
564+-- Split `s` on commas, trimming each field. The stdlib has no `split`, so this
565+-- is the usual beforeX/afterX recursion. Empty fields survive the split (",,a"
566+-- yields "" twice — relations dedupe, so once) and are dropped by the caller;
567+-- see `seedPeerList`.
568+splitOnComma : Text -> [Text]
569+splitOnComma = \s -> if s == ""
570+then ([] : [Text])
571+else union [trim (beforeComma s)] (splitOnComma (afterComma s))
572+
470573-- Substring of `s` before / after its first ".". `afterDot` is empty when
471574-- there is no dot. Used to pull the first two octets of a dotted IPv4 literal.
472575beforeDot : Text -> Text
@@ -548,8 +651,17 @@ anyOctetLeadingZero = \host ->
548651-- future ingress gap, must not steer a `fetch` at an internal host.
549652-- A legitimate `host:port` server name has exactly one `:`, so the multi-colon
550653-- check leaves that case intact.
654+--
655+-- The name is trailing-dot-stripped (`stripTrailingDot`) before every check.
656+-- The system resolver treats `localhost.` / `foo.internal.` (DNS root form) the
657+-- same as the dotless name, but the bare `== "localhost"` and `.internal`-suffix
658+-- tests would miss the dotted form. Peer-supplied names already arrive stripped
659+-- (`isValidServerName` rejects a trailing dot at the `/federation/gossip`
660+-- boundary), so the only unstripped ingress is `seedPeers`; normalizing here
661+-- keeps the deny-list sound independently of that upstream check — the same
662+-- "don't trust prior validation" posture the rest of this note describes.
551663isBadServerName : Text -> Bool
552−isBadServerName = \sn -> sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost sn || isReservedIPv4 (beforeColon sn) || hasLeadingZeroOctet (beforeColon sn) || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
664+isBadServerName = \raw -> let sn = stripTrailingDot raw in sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost sn || isReservedIPv4 (beforeColon sn) || hasLeadingZeroOctet (beforeColon sn) || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
553665 ".",
554666 ":",
555667 "[",
@@ -651,12 +763,25 @@ shouldRetryServer = \server t failures -> not any (\f -> f.server == server && t
651763-- Bump the failure counter for `server`. `attemptAt` is when the failed call
652764-- started; the update is skipped when a more recently started attempt has
653765-- already written its outcome.
766+--
767+-- Timestamp ties: `now` has millisecond resolution and `retryPendingForwards`
768+-- reuses a single `t` for a whole sweep, so two concurrent attempts on one peer
769+-- routinely carry the *same* `attemptAt` — and a strict `>` staleness test calls
770+-- neither one stale, making the outcome a plain last-writer-wins race (a success
771+-- and a failure at the same millisecond resolve differently depending on which
772+-- `fetch` returns first). The tiebreaker: at an equal timestamp a success
773+-- outranks a failure, so a zero-count row (the `clearFailure` tombstone) is
774+-- treated as stale here, while `clearFailure` overwrites an equal-timestamped
775+-- failure. Ties now resolve the same way in both orders. The cost is that a
776+-- genuine failure landing in the same millisecond as a success is not counted —
777+-- one skipped backoff step inside one millisecond, versus a permanently
778+-- order-dependent counter.
654779recordFailure : Text -> Timestamp -> IO {rw *forwardFailures} {}
655780recordFailure = \server attemptAt -> atomic (do
656781 failures <- *forwardFailures
657782 let prev = findFirst failures (\f -> f.server == server)
658783 let stale = case prev of
659− Just {value: f} -> f.failedAt > attemptAt
784+ Just {value: f} -> f.failedAt > attemptAt || (f.failedAt == attemptAt && f.failures == 0)
660785 _ -> False {}
661786 when (not stale) (do
662787 let prevCount = case prev of
@@ -669,6 +794,9 @@ recordFailure = \server attemptAt -> atomic (do
669794-- Reset the failure counter for `server` to zero (called after any successful
670795-- peer call; single-success recovery — there is no half-open state). Keeps a
671796-- zero-count row so its timestamp can outrank slower concurrent failures.
797+-- The staleness test stays strict (`>`) on purpose: at an equal timestamp this
798+-- write wins, which is the other half of the tie rule described on
799+-- `recordFailure`.
672800clearFailure : Text -> Timestamp -> IO {rw *forwardFailures} {}
673801clearFailure = \server attemptAt -> atomic (do
674802 failures <- *forwardFailures
@@ -768,9 +896,25 @@ pruneExpired = \t -> atomic do
768896-- a peer record into `forwardFailures` (shared with forward retries) so both
769897-- subsystems back off together.
770898
771−-- Fan out one bundle of gossip events to each peer, skipping denylisted
772−-- peers and peers in backoff. No-op when `events` is empty so callers don't
773−-- need to guard. Records or clears federation-failure counters per call.
899+-- The gossip fan-out set: peers learned from `*presence` plus the configured
900+-- bootstrap seeds, minus ourselves.
901+--
902+-- This union is the whole bootstrap mechanism (see `seedPeers`). Seeds are *not*
903+-- written into `*presence` to make them stick: a `PresenceEntry` is keyed by the
904+-- pubkey it announces, and a seed peer is a hostname we know nothing else about —
905+-- there is no pubkey to key a row on, and inventing one would put a phantom key
906+-- into the very table `forwardQueuedToServer` trusts to route mail. Folding seeds
907+-- in at fan-out time instead means they are gossip *targets* without ever being
908+-- presence *facts*. Once a seed answers, its own gossip creates the real presence
909+-- rows, and from then on it is reachable through the `peers` half of this union
910+-- like any other peer.
911+gossipTargets : [Text] -> [Text]
912+gossipTargets = \peers -> filter (\s -> not isLocalServer s) (union peers seedPeerList)
913+
914+-- Fan out one bundle of gossip events to each peer (presence-learned peers and
915+-- bootstrap seeds alike), skipping denylisted peers and peers in backoff. No-op
916+-- when `events` is empty so callers don't need to guard. Records or clears
917+-- federation-failure counters per call.
774918--
775919-- Also a no-op when `serverHost` fails the `ServerName` predicate: the gossip
776920-- body must carry a valid `fromServer`, and a peer would reject (400) a name we
@@ -781,7 +925,7 @@ sendBatchedGossip = \peers events -> case localServerName of
781925 Just {value: me} -> when (count events > 0) (do
782926 t <- now
783927 failures <- *forwardFailures
784− forEach peers (\server -> when (not (isBadServerName server) && shouldRetryServer server t failures) (do
928+ forEach (gossipTargets peers) (\server -> when (not (isBadServerName server) && shouldRetryServer server t failures) (do
785929 result <- fetch (federationUrl server) (RecvGossip {events, fromServer: me})
786930 case result of
787931 Err _ -> recordFailure server t
@@ -796,10 +940,48 @@ sendBatchedGossip = \peers events -> case localServerName of
796940-- comes online: any messages already buffered for that pubkey are enqueued
797941-- as forwards to the newly-known server.
798942
799−-- Walk pending forwards grouped by destination, retry each (up to maxBatchSize
800−-- per peer) using `/federation/forward`. Drops successful entries and records
801−-- the peer on the message's `deliveredTo`; for failures bumps the retry count
802−-- and gives up after `maxForwardRetries`.
943+-- Attempt every forward in `batch` to one peer, in order, and report whether any
944+-- attempt failed. Drops successful entries from `*forwards` and records the peer
945+-- on the message's `deliveredTo`; on failure bumps the retry count and gives up
946+-- after `maxForwardRetries`.
947+--
948+-- Deliberately does *not* touch `*forwardFailures` — the caller records at most
949+-- one failure per peer per sweep from the Bool this returns. Recording per failing
950+-- *message* (as this loop used to) let a peer holding N buffered messages take N
951+-- backoff steps in a single sweep: one unreachable peer with a full outbox jumped
952+-- straight to `retryBackoffMax` and `forwardFailureMaxAge` then pinned it there for
953+-- 24 h, so the peer stayed un-retried long after it came back. The counter is meant
954+-- to count consecutive failed *sweeps*, and now does.
955+--
956+-- Recursive rather than `forEach` because the result has to be accumulated across
957+-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
958+retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} Bool
959+retryForwardsTo = \server batch -> case head batch of
960+ Nothing {} -> yield (False {})
961+ Just {value: f} -> do
962+ result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
963+ let success = case result of
964+ Err _ -> False {}
965+ Ok _ -> True {}
966+ let isMatch = forwardMatches f.encryptedBlob server
967+ let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
968+ atomic (do
969+ cur <- *forwards
970+ *forwards = map (\x -> if isMatch x
971+ then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
972+ else x) (filter (\x -> not isDone x) cur)
973+ when success (do
974+ msgs <- *messages
975+ *messages = map (\m -> if m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob && not any (\d -> d.server == server) m.deliveredTo
976+ then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
977+ else m) msgs))
978+ restFailed <- retryForwardsTo server (drop 1 batch)
979+ yield (not success || restFailed)
980+
981+-- Walk pending forwards grouped by destination and retry each peer's backlog (up
982+-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
983+-- per sweep: a sweep in which every attempt succeeded clears the peer's backoff, a
984+-- sweep with any failure takes exactly one backoff step (see `retryForwardsTo`).
803985retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
804986retryPendingForwards = do
805987 t <- now
@@ -813,34 +995,22 @@ retryPendingForwards = do
813995 then atomic (do
814996 cur <- *forwards
815997 *forwards = filter (\x -> x.toServer != server) cur)
816− -- Order the per-peer backlog deterministically before `take maxBatchSize`:
817− -- `[a]` is an unordered set, so an unsorted `take` returns an
818− -- implementation-defined subset that could keep retrying the same entries
819− -- while starving others past the cap. A stable key drains a fixed prefix as
820− -- entries succeed (removed) or hit `maxForwardRetries` (dropped). (Same class
821− -- of fix as `forwardQueuedToServer`'s `sortBy receivedAt`; `ForwardEntry`
822− -- carries no timestamp, so key on the unique blob.)
823− else when (shouldRetryServer server t failures) (forEach (take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))) (\f -> do
824− result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
825− success <- case result of
826− Err _ -> do
827− recordFailure server t
828− yield (False {})
829− Ok _ -> do
830− clearFailure server t
831− yield (True {})
832− let isMatch = forwardMatches f.encryptedBlob server
833− let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
834− atomic (do
835− cur <- *forwards
836− *forwards = map (\x -> if isMatch x
837− then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
838− else x) (filter (\x -> not isDone x) cur)
839− when success (do
840− msgs <- *messages
841− *messages = map (\m -> if m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob && not any (\d -> d.server == server) m.deliveredTo
842− then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
843− else m) msgs)))))
998+ else when (shouldRetryServer server t failures) (do
999+ -- Order the per-peer backlog deterministically before `take maxBatchSize`:
1000+ -- `[a]` is an unordered set, so an unsorted `take` returns an
1001+ -- implementation-defined subset that could keep retrying the same entries
1002+ -- while starving others past the cap. A stable key drains a fixed prefix as
1003+ -- entries succeed (removed) or hit `maxForwardRetries` (dropped). (Same class
1004+ -- of fix as `forwardQueuedToServer`'s `sortBy receivedAt`; `ForwardEntry`
1005+ -- carries no timestamp, so key on the unique blob.)
1006+ let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1007+ anyFailed <- retryForwardsTo server batch
1008+ -- `count batch > 0` guards the clear: `maxBatchSize` is startup-overridable,
1009+ -- and at 0 every batch is empty — an empty sweep must not be read as a
1010+ -- success and reset a failing peer's backoff.
1011+ if anyFailed
1012+ then recordFailure server t
1013+ else when (count batch > 0) (clearFailure server t)))
8441014
8451015-- Forward queued messages to a server when a remote key comes online.
8461016-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
@@ -854,7 +1024,15 @@ retryPendingForwards = do
8541024-- what this forwards (e.g. dropping the `deliveredTo` filter) without revisiting
8551025-- that note.
8561026forwardQueuedToServer : PubkeyHex -> ServerName -> IO {rw *forwards, r *messages} {}
857−forwardQueuedToServer = \pubkey server -> atomic (do
1027+forwardQueuedToServer = \pubkey serverName -> atomic (do
1028+ -- Widen the refined `ServerName` to its base `Text` once, up front. Server names
1029+ -- are plain `Text` everywhere in state (`ForwardEntry.toServer`,
1030+ -- `Message.deliveredTo`, `PresenceEntry.server` — see `collectPeers` for why),
1031+ -- and Knot does not implicitly coerce between a refined type and its base inside
1032+ -- a comparison: `f.toServer == server` would try to use the `Text` field where a
1033+ -- `ServerName` is required and fail to typecheck. Widening is always sound (the
1034+ -- predicate only ever adds constraints); narrowing is what needs `refine`.
1035+ let server = (serverName : Text)
8581036 msgs <- *messages
8591037 fwds <- *forwards
8601038 let existing = count (filter (\f -> f.toServer == server) fwds)
@@ -906,7 +1084,7 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
9061084 -- The client signs (target host, challenge) under a domain-separation tag so a
9071085 -- relay server cannot replay the signature to a different server. We verify
9081086 -- against our own configured hostname (port stripped), which a relay cannot forge.
909− let signedText = "skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge
1087+ let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
9101088 let sigValid = case bytesFromHex pubkey of
9111089 Just {value: pk} -> case bytesFromHex signature of
9121090 Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
@@ -925,6 +1103,22 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
9251103 then filter (\s -> s.pubkey != pubkey) ses
9261104 else capPerKey ses pubkey maxSessionsPerKey
9271105 let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1106+ -- LIMITATION — global LRU eviction is Sybil-floodable. `capList` evicts the
1107+ -- soonest-to-expire sessions once the global `maxSessions` pool is full, and
1108+ -- it does not care whose they are: an attacker minting fresh keypairs (free)
1109+ -- from enough IPs to stay under `authRateLimit` can push `maxSessions` worth
1110+ -- of throwaway sessions through and evict *live* users' sessions, which
1111+ -- shows up client-side as a 401 and a forced re-handshake.
1112+ --
1113+ -- `maxSessionsPerKey` bounds any single key's share, and the per-IP
1114+ -- `authRateLimit` bounds the rate from any single source, so the flood needs
1115+ -- real distributed resources. Deliberately not fixed with a per-IP session
1116+ -- cap: `clientIp` is empty for every session whenever there is no trusted
1117+ -- proxy (`clientIpOf`), so such a cap would collapse to a single global
1118+ -- bucket in exactly the deployments that have no XFF — and even with a proxy
1119+ -- it would punish carrier-NAT users, where thousands of legitimate clients
1120+ -- genuinely do share one address. A real fix prices session creation (proof
1121+ -- of work, or a per-key deposit) rather than counting IPs.
9281122 *sessions = capList (union pruned [newSession]) maxSessions
9291123 peers <- refreshLocalPresence pubkey expiry
9301124 yield (Just {value: peers}))
@@ -1024,6 +1218,12 @@ doPoll = \s t -> do
10241218
10251219-- Treat the supplied cursor as an ack: drop all messages with
10261220-- receivedAt <= cursor, plus their pending forwards.
1221+--
1222+-- The ack is destructive and keyed on `pubkey` alone — no session identity enters
1223+-- into it. Two concurrent sessions for the same keypair therefore share one
1224+-- mailbox and race: whichever polls first acks the rows out from under the other,
1225+-- which never sees them. That is an accepted limitation, not a bug to fix here —
1226+-- one keypair per device is the model. See `maxSessionsPerKey`.
10271227ackPolledMessages : PubkeyHex -> Int -> IO {rw *forwards, rw *messages} {}
10281228ackPolledMessages = \pubkey cursor -> do
10291229 msgs <- *messages
@@ -1061,9 +1261,11 @@ handleSendMessages = \messages authorization clientIp -> do
10611261 -- Refined-type validation at the route boundary only fires for top-level
10621262 -- scalar body fields; `to`/`encryptedBlob` are nested inside the `messages`
10631263 -- list, so the runtime does not check them. Enforce the `PubkeyHex` /
1064− -- `BlobHex` contracts here so a malformed key or over-`maxBlobLen` / non-hex
1065− -- blob is rejected instead of stored.
1066− else if any (\m -> not (isValidExactHex 64 m.to && isValidBoundedHex maxBlobLen m.encryptedBlob)) messages
1264+ -- `BlobHex` contracts here so a malformed key, or a non-hex / under-
1265+ -- `minBlobLen` / over-`maxBlobLen` blob, is rejected instead of stored.
1266+ -- `isValidBlobHex` is the same predicate `BlobHex` refines on, so this
1267+ -- check and the route boundary cannot drift apart.
1268+ else if any (\m -> not (isValidExactHex 64 m.to && isValidBlobHex m.encryptedBlob)) messages
10671269 then yield (Err {error: {status: 400, message: "invalid_message"}})
10681270 else if any (\m -> m.to == senderKey) messages
10691271 then yield (Err {error: {status: 400, message: "self_send"}})
@@ -1175,9 +1377,16 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
11751377handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards, rw *presence, clock} (Result HttpError {})
11761378handleRecvGossip = \events fromServer -> do
11771379 t <- now
1380+ -- `fromServer` arrives refined from the route boundary — that is exactly where
1381+ -- the `ServerName` predicate is worth enforcing, since the name is peer-supplied.
1382+ -- Internally, though, every server name in state is plain `Text` (see
1383+ -- `collectPeers`), so widen once here: a `PresenceEntry.server` field cannot be
1384+ -- built from, or compared against, the refined type. `forwardQueuedToServer`
1385+ -- below still takes the refined `fromServer` — it wants the checked name.
1386+ let from = (fromServer : Text)
11781387 if not federationEnabled
11791388 then yield (Err {error: {status: 403, message: "federation_disabled"}})
1180− else if isLocalServer fromServer || isBadServerName fromServer || count events > maxGossipEvents
1389+ else if isLocalServer from || isBadServerName from || count events > maxGossipEvents
11811390 then yield (Err {error: {status: 400, message: "invalid_request"}})
11821391 else do
11831392 newlyOnline <- atomic (do
@@ -1196,15 +1405,15 @@ handleRecvGossip = \events fromServer -> do
11961405 Ok {value: pk} -> if e.eventType == "online"
11971406 then {
11981407 touched: union acc.touched [pk],
1199− added: union acc.added [{pubkey: pk, server: fromServer, expiresAt: t + onlineGossipTtl}]
1408+ added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}]
12001409 }
12011410 else if e.eventType == "offline"
12021411 then {acc.added, touched: union acc.touched [pk]}
12031412 else acc
12041413 Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry])} events
1205− let kept = filter (\p -> p.server != fromServer || not elem p.pubkey collected.touched) pres
1414+ let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
12061415 *presence = union kept collected.added
1207− yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == fromServer && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1416+ yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
12081417 fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
12091418 yield (Ok {value: {}})
12101419
@@ -1283,10 +1492,21 @@ api = serve Api where
12831492backgroundPrune : IO {rw *challenges, rw *forwardFailures, rw *messages, rw *presence, rw *sessions, network, clock} a
12841493backgroundPrune = do
12851494 t <- now
1495+ -- Snapshot the peer set *before* the sweep. `pruneExpired` drops every expired
1496+ -- presence row, and a peer is only known to us through the rows it announced —
1497+ -- so a peer whose keys all expired in this same sweep vanishes from `*presence`
1498+ -- at the very moment we need to tell it that our keys went offline. Reading
1499+ -- peers only after the prune (as this used to) silently skipped exactly those
1500+ -- peers, which then held a stale `online` row for our users until their own
1501+ -- `onlineGossipTtl` lapsed — up to 90 minutes of mail routed at a relay the
1502+ -- user is no longer on. Union the pre- and post-sweep sets so both the peers
1503+ -- that survived and the peers that expired out get the offline event.
1504+ prePres <- *presence
1505+ let prePeers = collectPeers prePres (\p -> p.server)
12861506 expiredLocal <- pruneExpired t
12871507 when federationEnabled (fork (do
12881508 pres <- *presence
1289− sendBatchedGossip (collectPeers pres (\p -> p.server)) (map (\pk -> {eventType: "offline", pubkey: Just {value: pk}}) expiredLocal)))
1509+ sendBatchedGossip (union prePeers (collectPeers pres (\p -> p.server))) (map (\pk -> {eventType: "offline", pubkey: Just {value: pk}}) expiredLocal)))
12901510 sleep backgroundPruneInterval
12911511 backgroundPrune
12921512
@@ -1310,6 +1530,27 @@ backgroundRetryForwards = do
13101530main = do
13111531 logInfo ("Skrepka relay server starting on " ++ bindHost ++ ":" ++ show listenPort ++ " ...")
13121532 logInfo (" federation: " ++ (if federationEnabled then "enabled" else "disabled"))
1533+ -- The warning three comments have promised since `serverHost`: a host that
1534+ -- fails `isValidServerName` cannot be refined into the `fromServer` a gossip
1535+ -- body requires, so `sendBatchedGossip` silently no-ops and this relay never
1536+ -- announces presence to anyone. Say so at startup rather than leaving the
1537+ -- operator to discover a federation-shaped hole by its absence.
1538+ case localServerName of
1539+ Nothing {} -> logWarn ("serverHost '" ++ serverHost ++ "' is not a valid ServerName (must be lowercase, no trailing dot, at most " ++ show maxServerNameLen ++ " chars); federation egress disabled")
1540+ _ -> yield {}
1541+ -- serverHost is still the compiled-in default. The auth signature is bound to
1542+ -- the hostname the client dialed (`skrepka-auth-v1:<host>:<challenge>`,
1543+ -- PROTOCOL.md §6), and /auth/verify checks it against `beforeColon serverHost`
1544+ -- — so any client that reaches this relay under a name other than "localhost"
1545+ -- fails verification with 401, and no session is ever issued. That is exactly
1546+ -- the install.sh deployment (Caddy fronts a public DOMAIN, clients dial it),
1547+ -- which passes `--serverHost=<domain>` — but the current Knot runtime accepts
1548+ -- and then IGNORES `--<name>=<value>` constant overrides at startup, so the
1549+ -- flag is inert and serverHost stays "localhost". A public relay must bake the
1550+ -- real name at build time (`knot build server.knot --serverHost=<host>`).
1551+ -- Warn loudly rather than let every login fail silently.
1552+ when (serverHost == "localhost") (logWarn "serverHost is the default 'localhost': every client that dials this relay under any other hostname will fail /auth/verify (the auth signature is host-bound, PROTOCOL.md §6), so no session is ever issued. A public relay must set serverHost to its real hostname; note the Knot runtime currently ignores --serverHost at startup, so bake it at build time: knot build server.knot --serverHost=<host>")
1553+ when (federationEnabled && count seedPeerList > 0) (logInfo (" seed peers: " ++ show seedPeerList))
13131554 fork backgroundPrune
13141555 when federationEnabled (fork backgroundRetryForwards)
13151556 listenOn bindHost listenPort api